Compare commits
69 Commits
7bf24e39f9
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| dab8f7e121 | |||
| f5d56eb6c0 | |||
| 6ad0102280 | |||
| b7d268dd77 | |||
| d4c9150c42 | |||
| e1b5822a59 | |||
| fc03b7755b | |||
| e6733d6a27 | |||
| 77a6902b13 | |||
| 377e602bd3 | |||
| f3bf67a641 | |||
| b98505fd31 | |||
| 99692ef217 | |||
| 48f68cf125 | |||
| 03ce088d24 | |||
| d5fa689dad | |||
| a73aeb5544 | |||
| c796a740d1 | |||
| 5195809869 | |||
| 0f1ffc1c3d | |||
| fc7ca12a01 | |||
| 3533b694f7 | |||
| a1cc5ac0f9 | |||
| ef8585fe66 | |||
| 337259a099 | |||
| f173cc0a62 | |||
| aa87ced07b | |||
| b710f6e6f9 | |||
| 7fea2bfef8 | |||
| 005867999f | |||
| fc76ca19a1 | |||
| 4c30e45e73 | |||
| d48c3331c6 | |||
| d0ec2fe820 | |||
| 8bc2924bea | |||
| bfa8a28add | |||
| 630521778d | |||
| f1129fd3c4 | |||
| a2cdc203c2 | |||
| 90989e81ad | |||
| 85512a7ba0 | |||
| c83b817456 | |||
| 35bbaae704 | |||
| 16e3917fce | |||
| 8517c504fb | |||
| 348d7b8119 | |||
| b32408562a | |||
| 5191479cff | |||
| 1b30d05620 | |||
| 31c7952f32 | |||
| c3969a5f28 | |||
| 90055b415c | |||
| c21a3ad944 | |||
| 8990f2b404 | |||
| 51e00a4e37 | |||
| 79e0ce8051 | |||
| 30f3f97f56 | |||
| d00eb6ee2b | |||
| d01af5e5c1 | |||
| c261ab9ea2 | |||
| 648291e5d8 | |||
| 151546d3f5 | |||
| 4133a478e4 | |||
| ee4c1bfece | |||
| 6db15dbe3b | |||
| eaaa02b122 | |||
| 88d956676e | |||
| fefe3602eb | |||
| 9ee65cbf07 |
@@ -70,3 +70,9 @@ PYTORRENT_SESSION_COOKIE_SECURE=false
|
|||||||
# bypass auth on specific hosts (ex. local ip)
|
# bypass auth on specific hosts (ex. local ip)
|
||||||
PYTORRENT_AUTH_BYPASS_HOSTS=10.11.1.11:8090,10.11.1.11
|
PYTORRENT_AUTH_BYPASS_HOSTS=10.11.1.11:8090,10.11.1.11
|
||||||
PYTORRENT_AUTH_BYPASS_USER=admin
|
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
|
||||||
+2
-1
@@ -40,8 +40,9 @@ data/logs/*
|
|||||||
!data/logs/
|
!data/logs/
|
||||||
!data/logs/README.md
|
!data/logs/README.md
|
||||||
|
|
||||||
|
|
||||||
todo.txt
|
todo.txt
|
||||||
!pytorrent/static/libs/pytorrent-themes/
|
!pytorrent/static/libs/pytorrent-themes/
|
||||||
!pytorrent/static/libs/pytorrent-themes/**
|
!pytorrent/static/libs/pytorrent-themes/**
|
||||||
|
*/static/libs/
|
||||||
smart_queue_scoring_todo.md
|
smart_queue_scoring_todo.md
|
||||||
|
data/mock_rtorrent_state.json
|
||||||
-198
@@ -1,198 +0,0 @@
|
|||||||
# pyTorrent stack installer
|
|
||||||
|
|
||||||
This document describes the one-command installer for installing **rTorrent + pyTorrent** from a clean server.
|
|
||||||
|
|
||||||
The installer is split into two layers:
|
|
||||||
|
|
||||||
- `scripts/install_stack.sh` - public bootstrap script intended to be downloaded directly from Git.
|
|
||||||
- `scripts/stack_installers/` - OS-specific installers and helper scripts used by the bootstrap script.
|
|
||||||
|
|
||||||
## Quick install
|
|
||||||
|
|
||||||
Run as root or through `sudo`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh | sudo bash
|
|
||||||
```
|
|
||||||
|
|
||||||
The bootstrap script downloads the current pyTorrent repository, detects the operating system family, and runs the matching installer:
|
|
||||||
|
|
||||||
- Debian / Ubuntu: `scripts/stack_installers/install_stack_debian_ubuntu.sh`
|
|
||||||
- RHEL-compatible systems: `scripts/stack_installers/install_stack_rhel.sh`
|
|
||||||
|
|
||||||
Supported RHEL-compatible systems include RHEL, Rocky Linux, AlmaLinux, CentOS Stream, and Fedora-like systems where `dnf` or `yum` is available.
|
|
||||||
|
|
||||||
## What gets installed
|
|
||||||
|
|
||||||
Default installation includes:
|
|
||||||
|
|
||||||
- rTorrent `v0.16.11`
|
|
||||||
- libtorrent `v0.16.11`
|
|
||||||
- minimal rTorrent build without c-ares/custom curl
|
|
||||||
- rTorrent system user: `rtorrent`
|
|
||||||
- rTorrent SCGI endpoint: `scgi://127.0.0.1:5000`
|
|
||||||
- rTorrent incoming BitTorrent port: `51300`
|
|
||||||
- pyTorrent application directory: `/opt/pytorrent`
|
|
||||||
- pyTorrent HTTP port: `8090`
|
|
||||||
- pyTorrent profile configured through the HTTP API
|
|
||||||
|
|
||||||
The installer creates or updates a pyTorrent rTorrent profile through API after both services are installed.
|
|
||||||
|
|
||||||
## Recommended usage with overrides
|
|
||||||
|
|
||||||
Environment variables must be passed to the `sudo bash` process.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
|
||||||
| sudo PYTORRENT_PORT=8091 RTORRENT_SCGI_PORT=5001 bash
|
|
||||||
```
|
|
||||||
|
|
||||||
Another example with a custom profile name:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
|
||||||
| sudo PYTORRENT_PROFILE_NAME="Local rTorrent" PYTORRENT_PORT=8090 bash
|
|
||||||
```
|
|
||||||
|
|
||||||
## Bootstrap parameters
|
|
||||||
|
|
||||||
These variables are used by `scripts/install_stack.sh`.
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `PYTORRENT_REPO_URL` | `https://git.linuxiarz.pl/gru/pyTorrent` | Git repository base URL. |
|
|
||||||
| `PYTORRENT_REPO_BRANCH` | `master` | Branch used to download the repository archive. |
|
|
||||||
| `PYTORRENT_ARCHIVE_URL` | derived from repo URL and branch | Custom repository archive URL. |
|
|
||||||
| `PYTORRENT_BOOTSTRAP_DIR` | `/tmp/pytorrent-stack-installer` | Temporary directory used by the bootstrap script. |
|
|
||||||
| `PYTORRENT_KEEP_BOOTSTRAP_DIR` | `0` | Set to `1` to keep the temporary directory after installation. |
|
|
||||||
|
|
||||||
Example using a different branch:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
|
||||||
| sudo PYTORRENT_REPO_BRANCH=develop bash
|
|
||||||
```
|
|
||||||
|
|
||||||
## rTorrent parameters
|
|
||||||
|
|
||||||
These variables are used by both stack installers.
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `RTORRENT_USER` | `rtorrent` | System user used to run rTorrent. |
|
|
||||||
| `RTORRENT_HOME` | `/home/${RTORRENT_USER}` | Home directory for the rTorrent user. |
|
|
||||||
| `RTORRENT_BASE_DIR` | `/opt/rtorrent_build` | Build and install directory for xmlrpc-c, libtorrent and rTorrent. |
|
|
||||||
| `RTORRENT_SCGI_PORT` | `5000` | Local SCGI port for rTorrent XMLRPC/SCGI. |
|
|
||||||
| `RTORRENT_TORRENT_PORT` | `51300` | Incoming BitTorrent listen port. |
|
|
||||||
| `RTORRENT_REF` | `v0.16.11` | rTorrent Git tag, branch, or commit. |
|
|
||||||
| `LIBTORRENT_REF` | `v0.16.11` | libtorrent Git tag, branch, or commit. |
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
|
||||||
| sudo RTORRENT_USER=rtorrent RTORRENT_SCGI_PORT=5001 RTORRENT_TORRENT_PORT=51400 bash
|
|
||||||
```
|
|
||||||
|
|
||||||
## pyTorrent parameters
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `PYTORRENT_APP_DIR` | `/opt/pytorrent` | pyTorrent installation directory. |
|
|
||||||
| `PYTORRENT_PORT` | `8090` | HTTP port used by the pyTorrent service. |
|
|
||||||
| `PYTORRENT_BASE_URL` | `http://127.0.0.1:${PYTORRENT_PORT}` | Base URL used by the API configurator. |
|
|
||||||
| `PYTORRENT_PROFILE_NAME` | `Local rTorrent` | Name of the rTorrent profile created in pyTorrent. |
|
|
||||||
| `PYTORRENT_API_TOKEN` | empty | Bearer token used when pyTorrent API authentication is enabled. |
|
|
||||||
| `PYTORRENT_SERVICE_NAME` | `pytorrent` | systemd service name for pyTorrent. |
|
|
||||||
| `PYTORRENT_RTORRENT_SCGI_URL` | `scgi://127.0.0.1:${RTORRENT_SCGI_PORT}` | SCGI URL saved in the pyTorrent rTorrent profile. |
|
|
||||||
|
|
||||||
Example with API token:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
|
||||||
| sudo PYTORRENT_API_TOKEN="pt_xxx" bash
|
|
||||||
```
|
|
||||||
|
|
||||||
## API configurator parameters
|
|
||||||
|
|
||||||
The API configurator can be run manually:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
/opt/pytorrent/venv/bin/python /opt/pytorrent/scripts/stack_installers/configure_pytorrent_api.py \
|
|
||||||
--base-url http://127.0.0.1:8090 \
|
|
||||||
--profile-name "Local rTorrent" \
|
|
||||||
--scgi-url scgi://127.0.0.1:5000
|
|
||||||
```
|
|
||||||
|
|
||||||
CLI options:
|
|
||||||
|
|
||||||
| Option | Environment variable | Default | Description |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `--base-url` | `PYTORRENT_BASE_URL` | `http://127.0.0.1:8090` | pyTorrent API base URL. |
|
|
||||||
| `--api-token` | `PYTORRENT_API_TOKEN` | empty | Bearer token for authenticated API calls. |
|
|
||||||
| `--profile-name` | `PYTORRENT_RTORRENT_PROFILE_NAME` | `Local rTorrent` | Profile name to create or update. |
|
|
||||||
| `--scgi-url` | `PYTORRENT_RTORRENT_SCGI_URL` | `scgi://127.0.0.1:5000` | rTorrent SCGI URL. |
|
|
||||||
| `--timeout` | `PYTORRENT_RTORRENT_TIMEOUT` | `10` | rTorrent request timeout in seconds. |
|
|
||||||
| `--wait` | `PYTORRENT_API_WAIT_SECONDS` | `90` | Time to wait for the pyTorrent API to become available. |
|
|
||||||
| `--remote` | `PYTORRENT_RTORRENT_REMOTE` | `0` | Mark profile as remote. Accepts `1`, `true`, `yes`, `on`. |
|
|
||||||
|
|
||||||
## Local installation without bootstrap
|
|
||||||
|
|
||||||
If the repository is already cloned:
|
|
||||||
|
|
||||||
Debian / Ubuntu:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo bash scripts/stack_installers/install_stack_debian_ubuntu.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
RHEL-compatible systems:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo bash scripts/stack_installers/install_stack_rhel.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Installed service hints
|
|
||||||
|
|
||||||
Check services:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl status pytorrent
|
|
||||||
systemctl status rtorrent@rtorrent.service
|
|
||||||
```
|
|
||||||
|
|
||||||
Check logs:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
tail -f /data/logs/app.log /data/logs/error.log
|
|
||||||
journalctl -u pytorrent -f
|
|
||||||
journalctl -u rtorrent@rtorrent.service -f
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- The default rTorrent build is intentionally minimal.
|
|
||||||
- c-ares and custom curl are not enabled by the stack installer defaults.
|
|
||||||
- The rTorrent installer overwrites the generated `.rtorrent.rc` because the stack installer passes `--force-config`.
|
|
||||||
- pyTorrent is configured through the HTTP API after the service starts.
|
|
||||||
- If API authentication is enabled before profile configuration, pass `PYTORRENT_API_TOKEN`.
|
|
||||||
|
|
||||||
|
|
||||||
## Build logs and troubleshooting
|
|
||||||
|
|
||||||
The stack installer writes quiet build output to `/var/log/pytorrent-installer` by default.
|
|
||||||
Override it with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PYTORRENT_STACK_LOG_DIR=/tmp/pytorrent-build-logs
|
|
||||||
```
|
|
||||||
|
|
||||||
For full command output during rTorrent/libtorrent compilation, run with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PYTORRENT_DEBUG_INSTALL=1
|
|
||||||
```
|
|
||||||
|
|
||||||
On RHEL-compatible systems the installer also tries to enable CRB/PowerTools and installs `libcurl-devel`, `redhat-rpm-config`, `patch`, `diffutils`, `findutils`, `file`, and `libstdc++-devel`, because minimal Alma/Rocky images often do not include enough build tooling.
|
|
||||||
@@ -1,124 +1,346 @@
|
|||||||
# pyTorrent
|
# 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.
|
## Install pyTorrent only - recommended first path
|
||||||
- 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.
|
|
||||||
|
|
||||||
## 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
|
```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.
|
Local install after cloning:
|
||||||
|
|
||||||
Optional environment variables:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PYTORRENT_USER=pytorrent \
|
git clone https://github.com/zdzichu6969/pyTorrent.git
|
||||||
PYTORRENT_APP_DIR=/opt/pytorrent \
|
cd pyTorrent
|
||||||
PYTORRENT_SERVICE_NAME=pytorrent \
|
sudo bash scripts/install_pytorrent_only.sh
|
||||||
sudo -E bash scripts/install_debian_ubuntu.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Check the service with:
|
Non-interactive example for an existing rTorrent SCGI endpoint:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl status pytorrent
|
sudo bash scripts/install_pytorrent_only.sh \
|
||||||
sudo journalctl -u pytorrent -f
|
--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
|
```bash
|
||||||
|
git clone https://github.com/zdzichu6969/pyTorrent.git
|
||||||
|
cd pyTorrent
|
||||||
./install.sh
|
./install.sh
|
||||||
. venv/bin/activate
|
. .venv/bin/activate
|
||||||
python app.py
|
python app.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Default URL: `http://127.0.0.1:8090`.
|
Default URL:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:8090
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the example environment file before customizing the app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
## rTorrent SCGI profile
|
||||||
|
|
||||||
|
Example pyTorrent profile URL:
|
||||||
|
|
||||||
|
```text
|
||||||
|
scgi://127.0.0.1:5000/RPC2
|
||||||
|
```
|
||||||
|
|
||||||
|
Example rTorrent configuration:
|
||||||
|
|
||||||
|
```text
|
||||||
|
network.scgi.open_port = 127.0.0.1:5000
|
||||||
|
```
|
||||||
|
|
||||||
|
For production, keep SCGI bound to localhost or a private trusted network only.
|
||||||
|
|
||||||
|
## Optional stack installer
|
||||||
|
|
||||||
|
The repository also includes a stack installer for a clean server. It can install and configure rTorrent + pyTorrent together.
|
||||||
|
|
||||||
|
Supported systems:
|
||||||
|
|
||||||
|
- Debian / Ubuntu
|
||||||
|
- RHEL-compatible distributions: RHEL, Rocky Linux, AlmaLinux, CentOS Stream, Fedora-like systems with `dnf` or `yum`
|
||||||
|
- Arch Linux
|
||||||
|
|
||||||
|
After cloning the repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/install_stack.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The default stack install creates:
|
||||||
|
|
||||||
|
| Component | Default |
|
||||||
|
| --- | --- |
|
||||||
|
| rTorrent user | `rtorrent` |
|
||||||
|
| rTorrent SCGI | `scgi://127.0.0.1:5000` |
|
||||||
|
| BitTorrent port | `51300` |
|
||||||
|
| pyTorrent app dir | `/opt/pytorrent` |
|
||||||
|
| pyTorrent HTTP port | `8090` |
|
||||||
|
| pyTorrent service | `pytorrent` |
|
||||||
|
|
||||||
|
### Optional one-line full stack install with rTorrent
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/zdzichu6969/pyTorrent/master/scripts/install_stack.sh \
|
||||||
|
| PYTORRENT_PORT=8090 \
|
||||||
|
RTORRENT_SCGI_PORT=5000 \
|
||||||
|
PYTORRENT_PROFILE_NAME="Local rTorrent" \
|
||||||
|
bash
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installer variables
|
||||||
|
|
||||||
|
### Bootstrap
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `PYTORRENT_REPO_URL` | repository URL | Repository base URL. |
|
||||||
|
| `PYTORRENT_REPO_BRANCH` | `master` | Branch used by the bootstrap installer. |
|
||||||
|
| `PYTORRENT_ARCHIVE_URL` | derived | Custom repository archive URL. Required for GitHub one-line install unless the script default is updated. |
|
||||||
|
| `PYTORRENT_BOOTSTRAP_DIR` | `/tmp/pytorrent-stack-installer` | Temporary bootstrap directory. |
|
||||||
|
| `PYTORRENT_KEEP_BOOTSTRAP_DIR` | `0` | Set to `1` to keep bootstrap files after install. |
|
||||||
|
|
||||||
|
### rTorrent
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `RTORRENT_USER` | `rtorrent` | System user used to run rTorrent. |
|
||||||
|
| `RTORRENT_HOME` | `/home/${RTORRENT_USER}` | Home directory for the rTorrent user. |
|
||||||
|
| `RTORRENT_BASE_DIR` | `/opt/rtorrent_build` | Build/install directory for source installs. |
|
||||||
|
| `RTORRENT_SCGI_PORT` | `5000` | Local SCGI port. |
|
||||||
|
| `RTORRENT_TORRENT_PORT` | `51300` | Incoming BitTorrent port. |
|
||||||
|
| `RTORRENT_REF` | `v0.16.11` | rTorrent Git tag, branch or commit for source builds. |
|
||||||
|
| `LIBTORRENT_REF` | `v0.16.11` | libtorrent Git tag, branch or commit for source builds. |
|
||||||
|
| `RTORRENT_WITH_XMLRPC_C` | `0` | Set to `1` to build with classic xmlrpc-c. |
|
||||||
|
| `RTORRENT_BUILD_FROM_SOURCE` | distro-specific | On Arch, set to `1` to compile instead of using `pacman`. |
|
||||||
|
| `RTORRENT_FORCE_CONFIG` | `1` | Overwrite generated `.rtorrent.rc` when supported. |
|
||||||
|
|
||||||
|
### pyTorrent
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `PYTORRENT_APP_DIR` | `/opt/pytorrent` | Installation directory. |
|
||||||
|
| `PYTORRENT_PORT` | `8090` | HTTP port. |
|
||||||
|
| `PYTORRENT_BASE_URL` | `http://127.0.0.1:${PYTORRENT_PORT}` | Base URL used by the API configurator. |
|
||||||
|
| `PYTORRENT_PROFILE_NAME` | `Local rTorrent` | rTorrent profile created in pyTorrent. |
|
||||||
|
| `PYTORRENT_API_TOKEN` | empty | Bearer token for authenticated API calls during setup. |
|
||||||
|
| `PYTORRENT_SERVICE_NAME` | `pytorrent` | systemd service name. |
|
||||||
|
| `PYTORRENT_RTORRENT_SCGI_URL` | `scgi://127.0.0.1:${RTORRENT_SCGI_PORT}` | SCGI URL saved in the generated profile. |
|
||||||
|
|
||||||
## Production run
|
## Production run
|
||||||
|
|
||||||
Preferred mode without development Werkzeug:
|
Recommended production command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
. venv/bin/activate
|
. .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.
|
Useful service commands after stack installation:
|
||||||
- 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.
|
```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
|
## 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
|
```env
|
||||||
PYTORRENT_PROXY_FIX_ENABLE=true
|
PYTORRENT_PROXY_FIX_ENABLE=true
|
||||||
PYTORRENT_SESSION_COOKIE_SECURE=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-For
|
||||||
X-Forwarded-Proto
|
X-Forwarded-Proto
|
||||||
X-Forwarded-Host
|
X-Forwarded-Host
|
||||||
X-Forwarded-Port
|
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
|
```text
|
||||||
|
X-Forwarded-Prefix
|
||||||
Example:
|
|
||||||
|
|
||||||
```txt
|
|
||||||
scgi://127.0.0.1:5000/RPC2
|
|
||||||
```
|
```
|
||||||
|
|
||||||
On the rTorrent side:
|
For HTTPS deployments, set allowed origins explicitly:
|
||||||
|
|
||||||
```txt
|
```env
|
||||||
network.scgi.open_port = 127.0.0.1:5000
|
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
|
## GeoIP
|
||||||
|
|
||||||
The installer downloads GeoLite2-City once to:
|
The installer can download the GeoLite2 City database to:
|
||||||
|
|
||||||
```txt
|
```text
|
||||||
data/GeoLite2-City.mmdb
|
data/GeoLite2-City.mmdb
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -128,42 +350,155 @@ Manual download:
|
|||||||
./scripts/download_geoip.sh
|
./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.
|
|
||||||
|
|
||||||
`/api/openapi.json` includes reusable schemas for main API responses, including `TorrentListResponse`, `TorrentSummary`, `TorrentFilterSummary`, `CleanupSummary` and `AppStatus`. `GET /api/torrents` documents the `summary` field used by sidebar filters.
|
|
||||||
|
|
||||||
## Admin CLI
|
|
||||||
|
|
||||||
Reset an existing user's password:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
. venv/bin/activate
|
|
||||||
python -m pytorrent.cli reset-password admin new_password
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Without the password argument, the CLI asks for it interactively:
|
## OpenAPI
|
||||||
|
|
||||||
```bash
|
OpenAPI documentation is available at:
|
||||||
python -m pytorrent.cli reset-password admin
|
|
||||||
|
```text
|
||||||
|
/docs
|
||||||
|
/api/openapi.json
|
||||||
```
|
```
|
||||||
|
|
||||||
The command uses the same database as the app and respects `PYTORRENT_DB_PATH` from `.env`. The reset changes only the password hash and leaves role and permissions unchanged.
|
The API includes profile management, torrent actions, preferences, port checks, system status, planner, poller, RSS, backups and diagnostics endpoints.
|
||||||
|
|
||||||
## API authentication tokens
|
## Configuration reference
|
||||||
|
|
||||||
When `PYTORRENT_AUTH_ENABLE=0`, API endpoints work without authentication.
|
Common environment variables:
|
||||||
|
|
||||||
When `PYTORRENT_AUTH_ENABLE=1`, API access can use either the browser session cookie or a per-user API token. Admin users can generate a token in **Tools -> Users** with **Generate token**. Copy the token immediately; only its prefix and metadata are stored afterwards.
|
```env
|
||||||
|
PYTORRENT_SECRET_KEY=change-me
|
||||||
Use a token in one of these forms:
|
PYTORRENT_DB_PATH=data/pytorrent.sqlite3
|
||||||
|
PYTORRENT_HOST=0.0.0.0
|
||||||
```bash
|
PYTORRENT_PORT=8090
|
||||||
curl -H "Authorization: Bearer pt_xxx" http://127.0.0.1:8080/api/system/status
|
PYTORRENT_DEBUG=0
|
||||||
curl -H "X-API-Key: pt_xxx" http://127.0.0.1:8080/api/system/status
|
PYTORRENT_POLL_INTERVAL=1
|
||||||
|
PYTORRENT_WORKERS=16
|
||||||
|
PYTORRENT_USE_OFFLINE_LIBS=true
|
||||||
|
PYTORRENT_LOG_ENABLE=false
|
||||||
|
PYTORRENT_LOG_DIR=data/logs
|
||||||
```
|
```
|
||||||
|
|
||||||
Token permissions follow the owning user's role and rTorrent profile permissions. Revoked tokens stop working immediately.
|
Retention settings:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PYTORRENT_TRAFFIC_HISTORY_RETENTION_DAYS=90
|
||||||
|
PYTORRENT_JOBS_RETENTION_DAYS=30
|
||||||
|
PYTORRENT_SMART_QUEUE_HISTORY_RETENTION_DAYS=30
|
||||||
|
PYTORRENT_LOG_RETENTION_DAYS=30
|
||||||
|
```
|
||||||
|
|
||||||
|
Database maintenance:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PYTORRENT_DB_VACUUM_ENABLE=true
|
||||||
|
PYTORRENT_DB_VACUUM_EVERY_SECONDS=86400
|
||||||
|
PYTORRENT_DB_VACUUM_MIN_FREE_MB=512
|
||||||
|
PYTORRENT_DB_VACUUM_MIN_FREE_RATIO=0.25
|
||||||
|
```
|
||||||
|
|
||||||
|
See `.env.example` for the full list.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Service does not start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u pytorrent -n 100 --no-pager
|
||||||
|
systemctl status pytorrent
|
||||||
|
```
|
||||||
|
|
||||||
|
### rTorrent connection fails
|
||||||
|
|
||||||
|
Check that rTorrent exposes SCGI locally and that the pyTorrent profile uses the same port:
|
||||||
|
|
||||||
|
```text
|
||||||
|
scgi://127.0.0.1:5000/RPC2
|
||||||
|
```
|
||||||
|
|
||||||
|
### External auth creates users without profiles
|
||||||
|
|
||||||
|
Use admin auto-create mode:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=admin
|
||||||
|
```
|
||||||
|
|
||||||
|
Or grant read-write profile permissions to non-admin users:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=user
|
||||||
|
PYTORRENT_AUTH_PROXY_AUTO_CREATE_PERMISSION=rw
|
||||||
|
```
|
||||||
|
|
||||||
|
### Socket.IO badge stays offline behind reverse proxy
|
||||||
|
|
||||||
|
Forward the same authenticated user header to all pyTorrent paths, including `/socket.io/`:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
auth_request_set $auth_user $upstream_http_remote_user;
|
||||||
|
proxy_set_header Remote-User $auth_user;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build logs
|
||||||
|
|
||||||
|
Source-build installers write logs to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/var/log/pytorrent-installer
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable verbose installer output:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTORRENT_DEBUG_INSTALL=1 bash scripts/install_stack.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Do not expose rTorrent SCGI directly to the public internet.
|
||||||
|
- Use HTTPS and authentication for remote access.
|
||||||
|
- Set a strong `PYTORRENT_SECRET_KEY` before production use.
|
||||||
|
- Review auth bypass settings before publishing or deploying.
|
||||||
|
- Keep `.env` out of Git. Use `.env.example` for public defaults.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
. .venv/bin/activate
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Run a quick Python compile check:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m compileall pytorrent app.py wsgi.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Download offline frontend assets when needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/download_frontend_libs.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
pytorrent/ Application package
|
||||||
|
pytorrent/routes/ Flask routes and API modules
|
||||||
|
pytorrent/services/ rTorrent, planner, queue and helper services
|
||||||
|
pytorrent/static/ Frontend JavaScript and CSS
|
||||||
|
pytorrent/templates/ HTML templates
|
||||||
|
scripts/ Installers and maintenance tools
|
||||||
|
systemd/ systemd service files
|
||||||
|
data/ Runtime data directory
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
AGPL-3
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
# TODO
|
|
||||||
|
|
||||||
## Done
|
|
||||||
|
|
||||||
- Fixed remote system statistics after the rTorrent service split so CPU/RAM are read from the rTorrent host again.
|
|
||||||
- Fixed split system service dependencies for `default_download_path` and `list_torrents`, restoring disk/system status calls.
|
|
||||||
- Fixed split rTorrent package exports so private compatibility helpers, remote caches and post-check state remain visible after refactor.
|
|
||||||
- Restored remote directory browsing in the split rTorrent system service.
|
|
||||||
- Split the largest backend files into smaller route modules while keeping existing API behavior.
|
|
||||||
- Split rTorrent service logic into a package with focused modules and compatibility exports.
|
|
||||||
- Updated OpenAPI coverage for backend routes, including Planner and Poller endpoints.
|
|
||||||
- Split Planner and Adaptive Poller into separate Tools tabs.
|
|
||||||
- Added Download Planner settings:
|
|
||||||
- master enable switch,
|
|
||||||
- night-only downloads as a separate option,
|
|
||||||
- quiet hours,
|
|
||||||
- weekday and weekend speed limits,
|
|
||||||
- CPU protection,
|
|
||||||
- disk protection,
|
|
||||||
- auto-resume for planner-paused torrents.
|
|
||||||
- Added speed presets and Mbit/s sliders while keeping exact B/s fields for rTorrent values.
|
|
||||||
- Moved disk protection configuration out of Preferences and into Planner.
|
|
||||||
- Kept worker-side disk-start protection wired to Planner disk settings.
|
|
||||||
- Added footer Planner shortcut visible only when Planner is enabled.
|
|
||||||
- Added Adaptive Poller settings:
|
|
||||||
- active interval,
|
|
||||||
- idle interval,
|
|
||||||
- error interval,
|
|
||||||
- system stats interval,
|
|
||||||
- slow stats interval,
|
|
||||||
- heartbeat interval.
|
|
||||||
- Reduced unnecessary heartbeat emissions.
|
|
||||||
- Added tick duration tracking for the poller.
|
|
||||||
- Cleaned stale backup files from the package.
|
|
||||||
- Normalized Planner and Poller UX to match Smart Queue style.
|
|
||||||
- Cleaned Planner/Poller CSS and removed redundant UI wrappers.
|
|
||||||
- Kept the WebSocket live refresh responsive by moving slow poller tasks (torrent stats, Smart Queue, automations and Planner enforce) into a guarded background task.
|
|
||||||
- Added a regression smoke check for fixed-mode poller metrics while a slow background task is running.
|
|
||||||
- Set Adaptive Poller startup defaults to the requested fast-live profile: active/torrent loop 0.5s, idle 3s, errors 2s, system stats 1s, queue 5s, heartbeat 5s, slow threshold 10000ms and slowdown multiplier 1.
|
|
||||||
- Added poller runtime diagnostics for real tick gap, effective interval and configured minimum interval so 0.5s polling can be verified from the UI.
|
|
||||||
|
|
||||||
## Planned
|
|
||||||
|
|
||||||
### Phase 1 - Stabilization and maintainability
|
|
||||||
|
|
||||||
- Split `static/app.js` into smaller frontend modules:
|
|
||||||
- `api.js`,
|
|
||||||
- `state.js`,
|
|
||||||
- `torrents.js`,
|
|
||||||
- `modals.js`,
|
|
||||||
- `smartQueue.js`,
|
|
||||||
- `planner.js`,
|
|
||||||
- `poller.js`,
|
|
||||||
- `rss.js`,
|
|
||||||
- `charts.js`.
|
|
||||||
- Add request validation for API endpoints instead of repeated manual `request.json` parsing.
|
|
||||||
- Add typed application exceptions:
|
|
||||||
- `RtorrentError`,
|
|
||||||
- `RtorrentTimeout`,
|
|
||||||
- `ProfileAccessError`,
|
|
||||||
- `ValidationError`,
|
|
||||||
- `UnsafePathError`,
|
|
||||||
- `BackgroundJobError`.
|
|
||||||
- Reduce broad `except Exception` blocks and map expected errors to clear API responses.
|
|
||||||
- Add structured logging with request ID, profile ID and operation timing.
|
|
||||||
- Add endpoint timing metrics for API calls and rTorrent operations.
|
|
||||||
- Add a deeper healthcheck endpoint covering:
|
|
||||||
- database readability/writability,
|
|
||||||
- active profile state,
|
|
||||||
- rTorrent connection,
|
|
||||||
- background queue state,
|
|
||||||
- poller state.
|
|
||||||
|
|
||||||
### Phase 2 - Tests and safety
|
|
||||||
|
|
||||||
- Add unit tests for torrent parsing.
|
|
||||||
- Add unit tests for Smart Queue decisions.
|
|
||||||
- Add unit tests for Planner schedule windows.
|
|
||||||
- Add unit tests for quiet hours.
|
|
||||||
- Add unit tests for Planner speed limit selection.
|
|
||||||
- Add unit tests for disk protection and CPU protection.
|
|
||||||
- Add unit tests for RSS rule matching.
|
|
||||||
- Add unit tests for ratio rules.
|
|
||||||
- Add unit tests for profile validation.
|
|
||||||
- Add auth and permission tests.
|
|
||||||
- Add path safety tests for move, remove and ZIP download operations.
|
|
||||||
- Add mocked rTorrent integration tests for Planner pause/resume and speed limit application.
|
|
||||||
- Add mocked poller tests for adaptive cadence without requiring a live rTorrent instance.
|
|
||||||
- Add visual regression checks for Tools tabs on narrow screens.
|
|
||||||
- Add CI checks for Python compile, JS syntax, linting and tests.
|
|
||||||
|
|
||||||
### Phase 3 - Planner improvements
|
|
||||||
|
|
||||||
- [x] Add named Planner profiles:
|
|
||||||
- night mode,
|
|
||||||
- weekend mode,
|
|
||||||
- low power mode,
|
|
||||||
- unlimited mode.
|
|
||||||
- [x] Keep Planner settings per active rTorrent profile.
|
|
||||||
- [x] Add Planner preview with the currently matched rule and next scheduled change.
|
|
||||||
- [x] Add Planner action history:
|
|
||||||
- paused torrents,
|
|
||||||
- resumed torrents,
|
|
||||||
- speed limit changes,
|
|
||||||
- CPU protection triggers,
|
|
||||||
- disk protection triggers.
|
|
||||||
- [x] Add dry-run mode for Planner actions.
|
|
||||||
- [x] Add optional network/load protection rules.
|
|
||||||
- [x] Add configurable grace period before auto-resume.
|
|
||||||
- [x] Add manual override timeout, including `disable Planner for 1 hour`.
|
|
||||||
- [x] Add clearer status badges in the footer and Tools panel.
|
|
||||||
|
|
||||||
Implementation notes:
|
|
||||||
- Frontend settings live in the Planner tab and are stored through `/api/download-planner`.
|
|
||||||
- Preview/history are exposed through `/api/download-planner/preview`.
|
|
||||||
- Manual override is exposed through `/api/download-planner/override`.
|
|
||||||
- Dry-run mode records intended actions without calling rTorrent mutations.
|
|
||||||
|
|
||||||
### Phase 4 - Adaptive poller improvements
|
|
||||||
|
|
||||||
- [x] Make polling intervals configurable per rTorrent profile.
|
|
||||||
- [x] Add automatic slowdown when rTorrent responses are slow.
|
|
||||||
- [x] Add automatic recovery after repeated rTorrent errors.
|
|
||||||
- [x] Split polling loop configuration by data type:
|
|
||||||
- torrent list,
|
|
||||||
- system stats,
|
|
||||||
- tracker stats,
|
|
||||||
- disk stats,
|
|
||||||
- queue/job stats.
|
|
||||||
- [x] Emit WebSocket torrent updates only when data changed, with heartbeat fallback.
|
|
||||||
- [x] Add per-tick metrics:
|
|
||||||
- duration,
|
|
||||||
- emitted payload size,
|
|
||||||
- rTorrent call count,
|
|
||||||
- skipped emissions,
|
|
||||||
- current adaptive mode.
|
|
||||||
- [x] Add a Poller diagnostics panel.
|
|
||||||
- [x] Add safe fallback mode if adaptive polling is misconfigured.
|
|
||||||
|
|
||||||
Implementation notes:
|
|
||||||
- Frontend settings live in the Poller tab and are stored through `/api/poller/settings`.
|
|
||||||
- Runtime metrics are included in heartbeat/system stats payloads and shown in Poller diagnostics.
|
|
||||||
- Fallback mode clamps unsafe intervals during normalization.
|
|
||||||
|
|
||||||
### Phase 5 - UX and dashboard
|
|
||||||
|
|
||||||
- [x] Add a torrent health dashboard with sections for:
|
|
||||||
- torrents without seeders,
|
|
||||||
- stopped torrents that should be active,
|
|
||||||
- tracker errors,
|
|
||||||
- duplicate torrents,
|
|
||||||
- slowest torrents,
|
|
||||||
- dead torrents,
|
|
||||||
- largest torrents,
|
|
||||||
- torrents below target ratio.
|
|
||||||
- [x] Add Smart Views:
|
|
||||||
- Needs attention,
|
|
||||||
- Large and slow,
|
|
||||||
- Seeding too long,
|
|
||||||
- New from RSS,
|
|
||||||
- No label,
|
|
||||||
- Private trackers.
|
|
||||||
- [x] Add global search across:
|
|
||||||
- torrent name,
|
|
||||||
- hash,
|
|
||||||
- label,
|
|
||||||
- tracker,
|
|
||||||
- path,
|
|
||||||
- ratio group,
|
|
||||||
- error status.
|
|
||||||
- [x] Add a persistent notification center for:
|
|
||||||
- rTorrent errors,
|
|
||||||
- RSS errors,
|
|
||||||
- automation errors,
|
|
||||||
- disk warnings,
|
|
||||||
- Smart Queue decisions,
|
|
||||||
- Planner actions,
|
|
||||||
- port status.
|
|
||||||
- [x] Add a Diagnostics page for profile, rTorrent, poller, database and worker state.
|
|
||||||
|
|
||||||
Implementation notes:
|
|
||||||
- Health and Smart Views are calculated client-side from the live torrent snapshot.
|
|
||||||
- `Seeding too long` uses rTorrent completion timestamp when available and falls back safely when older rTorrent builds do not expose it.
|
|
||||||
- Global search uses torrent name, hash, label, tracker domain, path, ratio group and warning/error text.
|
|
||||||
- App Status now stays application-focused: process, workers, database and cleanup counters.
|
|
||||||
- Diagnostics owns profile, rTorrent connection, poller, planner, database and worker snapshots to avoid duplicated status cards.
|
|
||||||
|
|
||||||
### Phase 6 - RSS and automation
|
|
||||||
|
|
||||||
- Add RSS feed preview before saving a rule.
|
|
||||||
- Add RSS rule testing against the latest feed entries.
|
|
||||||
- Add RSS dry-run mode.
|
|
||||||
- Add duplicate detection by info-hash where possible.
|
|
||||||
- Add RSS rule priorities.
|
|
||||||
- Add RSS history explaining why an item was accepted or rejected.
|
|
||||||
- Add cleanup rules:
|
|
||||||
- remove completed torrents after target ratio,
|
|
||||||
- move completed data to a destination path,
|
|
||||||
- remove dead torrents after a configurable time,
|
|
||||||
- remove torrents with missing data,
|
|
||||||
- stop seeding after a configured upload amount.
|
|
||||||
- Add import/export for automation rules.
|
|
||||||
|
|
||||||
### Phase 7 - File and torrent operations
|
|
||||||
|
|
||||||
- Improve the Files view with extension filters.
|
|
||||||
- Add bulk priority actions by file type, for example `.mkv`, `.srt`, `.nfo`.
|
|
||||||
- Add folder-level priority actions by path pattern.
|
|
||||||
- Add safer selected-file download handling for large selections.
|
|
||||||
- Add missing-file detection.
|
|
||||||
- Add duplicate torrent detection and merge/cleanup suggestions.
|
|
||||||
|
|
||||||
### Phase 8 - Profiles and rTorrent diagnostics
|
|
||||||
|
|
||||||
- Add SCGI connection test before saving a profile.
|
|
||||||
- Add profile diagnostics:
|
|
||||||
- rTorrent version,
|
|
||||||
- base paths,
|
|
||||||
- write permissions,
|
|
||||||
- free disk space,
|
|
||||||
- response time.
|
|
||||||
- Add import/export for profiles.
|
|
||||||
- Show profile status in the profile picker:
|
|
||||||
- online,
|
|
||||||
- offline,
|
|
||||||
- slow,
|
|
||||||
- error.
|
|
||||||
- Add per-profile API and polling limits.
|
|
||||||
|
|
||||||
### Phase 9 - Security and API
|
|
||||||
|
|
||||||
- Require changing default admin credentials on first login when auth is enabled.
|
|
||||||
- Add login rate limiting.
|
|
||||||
- Add optional TOTP/2FA.
|
|
||||||
- Add stronger CSRF protection for state-changing requests.
|
|
||||||
- Set secure session flags when running behind HTTPS.
|
|
||||||
- Add API tokens:
|
|
||||||
- read-only token,
|
|
||||||
- full-access token,
|
|
||||||
- profile-limited token,
|
|
||||||
- token revocation,
|
|
||||||
- token usage log.
|
|
||||||
- Generate or validate OpenAPI schemas from backend request/response models.
|
|
||||||
- Add generated JS API client from OpenAPI.
|
|
||||||
|
|
||||||
### Phase 10 - Database, backup and release quality
|
|
||||||
|
|
||||||
- Add explicit database migrations with version numbers.
|
|
||||||
- Add `PRAGMA busy_timeout` configuration.
|
|
||||||
- Add periodic `VACUUM` or WAL checkpoint maintenance.
|
|
||||||
- Add indexes for history and frequently filtered data.
|
|
||||||
- Add retention limits for RSS history, automation history and Planner history.
|
|
||||||
- Add backup checksums.
|
|
||||||
- Add backup restore validation.
|
|
||||||
- Keep release archives free from `.bak`, cache and local debug files.
|
|
||||||
|
|
||||||
|
|
||||||
## Phase 5 implementation status
|
|
||||||
|
|
||||||
- [x] Torrent health dashboard in Tools.
|
|
||||||
- [x] Smart Views filters, including completion-time based `Seeding too long`.
|
|
||||||
- [x] Global search expanded to hash, label, tracker, path, ratio group and error/status.
|
|
||||||
- [x] Persistent local notification center.
|
|
||||||
- [x] Diagnostics tool panel for profile, rTorrent, poller, database and workers.
|
|
||||||
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
# Authentication configuration
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
pyTorrent supports three authentication modes:
|
|
||||||
|
|
||||||
- `local` - built-in pyTorrent login screen with username and password.
|
|
||||||
- `tinyauth` - external authentication through Tinyauth and a trusted reverse proxy username header.
|
|
||||||
- `proxy` - generic external authentication through a trusted reverse proxy username header.
|
|
||||||
|
|
||||||
When `tinyauth` or `proxy` is used, pyTorrent does not show the local login form. The reverse proxy must authenticate the request first and pass the authenticated username to pyTorrent in the configured header.
|
|
||||||
|
|
||||||
## Environment variables
|
|
||||||
|
|
||||||
```env
|
|
||||||
PYTORRENT_AUTH_ENABLE=true
|
|
||||||
|
|
||||||
# local | tinyauth | proxy
|
|
||||||
PYTORRENT_AUTH_PROVIDER=tinyauth
|
|
||||||
|
|
||||||
# Header that contains the authenticated username.
|
|
||||||
PYTORRENT_AUTH_PROXY_USER_HEADER=Remote-User
|
|
||||||
|
|
||||||
# Create a local pyTorrent user when the external user is missing.
|
|
||||||
PYTORRENT_AUTH_PROXY_AUTO_CREATE=true
|
|
||||||
|
|
||||||
# Role for auto-created external users: user | admin
|
|
||||||
PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=admin
|
|
||||||
|
|
||||||
# Permission for auto-created role=user accounts: none | ro | rw | full
|
|
||||||
# rw is accepted as an alias of full.
|
|
||||||
# Admin users ignore this value and can access all profiles.
|
|
||||||
PYTORRENT_AUTH_PROXY_AUTO_CREATE_PERMISSION=rw
|
|
||||||
|
|
||||||
# Optional: trusted direct-IP/local hosts that should skip pyTorrent auth.
|
|
||||||
# Use this only on private networks, never on public proxy hostnames.
|
|
||||||
PYTORRENT_AUTH_BYPASS_HOSTS=10.11.1.11:8090,10.11.1.11
|
|
||||||
|
|
||||||
# Existing active user used by bypassed requests. Defaults to admin.
|
|
||||||
PYTORRENT_AUTH_BYPASS_USER=admin
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## Reverse proxy origin checks
|
|
||||||
|
|
||||||
pyTorrent blocks unsafe API requests when the browser `Origin`/`Referer` does not match the application origin. Behind HTTPS reverse proxy this requires either correct forwarded headers or an explicit API origin allowlist.
|
|
||||||
|
|
||||||
Recommended variables for reverse proxy mode:
|
|
||||||
|
|
||||||
```env
|
|
||||||
PYTORRENT_PROXY_FIX_ENABLE=true
|
|
||||||
PYTORRENT_SESSION_COOKIE_SECURE=true
|
|
||||||
PYTORRENT_SOCKETIO_CORS_ALLOWED_ORIGINS=https://pytorrent.example.com
|
|
||||||
PYTORRENT_API_ALLOWED_ORIGINS=https://pytorrent.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
`PYTORRENT_API_ALLOWED_ORIGINS` accepts a comma-separated list, for example:
|
|
||||||
|
|
||||||
```env
|
|
||||||
PYTORRENT_API_ALLOWED_ORIGINS=https://pytorrent.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
If `PYTORRENT_API_ALLOWED_ORIGINS` is not set, pyTorrent reuses `PYTORRENT_SOCKETIO_CORS_ALLOWED_ORIGINS` for API origin checks.
|
|
||||||
|
|
||||||
## Local authentication
|
|
||||||
|
|
||||||
Use this when pyTorrent should manage its own login screen and passwords.
|
|
||||||
|
|
||||||
```env
|
|
||||||
PYTORRENT_AUTH_ENABLE=true
|
|
||||||
PYTORRENT_AUTH_PROVIDER=local
|
|
||||||
```
|
|
||||||
|
|
||||||
Password reset example:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pytorrent.cli reset-password admin new_Pass
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tinyauth authentication
|
|
||||||
|
|
||||||
Use this when Tinyauth protects pyTorrent before the request reaches the application.
|
|
||||||
|
|
||||||
```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
|
|
||||||
```
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
|
|
||||||
- Tinyauth authenticates the browser request.
|
|
||||||
- The reverse proxy forwards the authenticated username in `Remote-User`.
|
|
||||||
- pyTorrent reads only that username header.
|
|
||||||
- If the username already exists in pyTorrent, that user is used.
|
|
||||||
- If the username does not exist and `PYTORRENT_AUTH_PROXY_AUTO_CREATE=true`, pyTorrent creates it.
|
|
||||||
- Passwordless external users are synchronized with `PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE` and `PYTORRENT_AUTH_PROXY_AUTO_CREATE_PERMISSION` on login.
|
|
||||||
|
|
||||||
## Example Nginx / Nginx Proxy Manager advanced vhost
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
location / {
|
|
||||||
proxy_pass $forward_scheme://$server:$port;
|
|
||||||
auth_request /tinyauth;
|
|
||||||
error_page 401 = @tinyauth_login;
|
|
||||||
|
|
||||||
auth_request_set $redirection_url $upstream_http_x_tinyauth_location;
|
|
||||||
auth_request_set $auth_user $upstream_http_remote_user;
|
|
||||||
proxy_set_header Remote-User $auth_user;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
location /tinyauth {
|
|
||||||
proxy_pass http://10.11.1.11:3000/api/auth/nginx;
|
|
||||||
proxy_set_header x-forwarded-proto $scheme;
|
|
||||||
proxy_set_header x-forwarded-host $http_host;
|
|
||||||
proxy_set_header x-forwarded-uri $request_uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
location @tinyauth_login {
|
|
||||||
return 302 http://auth.example.com/login?redirect_uri=$scheme://$http_host$request_uri;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `PYTORRENT_AUTH_PROXY_USER_HEADER=Remote-User` when this setup forwards `Remote-User` to pyTorrent.
|
|
||||||
|
|
||||||
## Direct-IP auth bypass
|
|
||||||
|
|
||||||
Use this only when pyTorrent is reachable on a trusted private IP and you want:
|
|
||||||
|
|
||||||
- reverse proxy hostname protected by Tinyauth;
|
|
||||||
- direct private IP access without pyTorrent login.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```env
|
|
||||||
PYTORRENT_AUTH_ENABLE=true
|
|
||||||
PYTORRENT_AUTH_PROVIDER=tinyauth
|
|
||||||
PYTORRENT_AUTH_BYPASS_HOSTS=10.11.1.11:8090,10.11.1.11
|
|
||||||
|
|
||||||
# Existing active user used by bypassed requests. Defaults to admin.
|
|
||||||
PYTORRENT_AUTH_BYPASS_USER=admin
|
|
||||||
```
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
|
|
||||||
- requests with `Host: 10.11.1.11:8090` or `Host: 10.11.1.11` use the built-in default admin user;
|
|
||||||
- requests through the reverse proxy still require the configured auth provider;
|
|
||||||
- `PYTORRENT_AUTH_BYPASS_USER` must point to an existing active user; when unset, pyTorrent uses `admin`;
|
|
||||||
- if the bypass user is `admin`, profile permissions are ignored because admins can access all profiles;
|
|
||||||
- when no active profile is saved for the bypass user, pyTorrent opens the profile picker instead of silently selecting the first profile;
|
|
||||||
- after selecting a profile, the choice is saved in the bypass user's preferences and reused on the next direct-IP visit.
|
|
||||||
|
|
||||||
Do not add public domains to this list.
|
|
||||||
|
|
||||||
## Generic reverse proxy authentication
|
|
||||||
|
|
||||||
Use this when another proxy authenticates users and sends a username header.
|
|
||||||
|
|
||||||
```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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Auto-created user permissions
|
|
||||||
|
|
||||||
`PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=admin`:
|
|
||||||
|
|
||||||
- user is created as admin;
|
|
||||||
- profile permissions are not needed;
|
|
||||||
- all profiles are visible and writable.
|
|
||||||
|
|
||||||
`PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=user`:
|
|
||||||
|
|
||||||
- `none` - creates the user without profile access;
|
|
||||||
- `ro` - grants read-only access to all profiles;
|
|
||||||
- `rw` - grants read-write access to all profiles;
|
|
||||||
- `full` - same as `rw`.
|
|
||||||
|
|
||||||
## Connection badge behind Tinyauth
|
|
||||||
|
|
||||||
The top-right badge shows Socket.IO connectivity, not REST API health.
|
|
||||||
|
|
||||||
If the application loads data through REST API but the badge stays `offline`, the most common cause is that the Socket.IO handshake or follow-up events are not authenticated with the same external identity header. pyTorrent resolves external auth during Socket.IO connect/events as well as normal REST requests.
|
|
||||||
|
|
||||||
For Tinyauth, make sure the same location that proxies pyTorrent also forwards `Remote-User` to all paths, including `/socket.io/`:
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
auth_request_set $auth_user $upstream_http_remote_user;
|
|
||||||
proxy_set_header Remote-User $auth_user;
|
|
||||||
```
|
|
||||||
|
|
||||||
No separate badge-disable option is needed. The badge should become `online` when Socket.IO connects correctly.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
If the user is created but profiles are missing:
|
|
||||||
|
|
||||||
1. Check the created user's role in pyTorrent user management.
|
|
||||||
2. For admin access, use:
|
|
||||||
|
|
||||||
```env
|
|
||||||
PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=admin
|
|
||||||
```
|
|
||||||
|
|
||||||
3. For non-admin read-write access, use:
|
|
||||||
|
|
||||||
```env
|
|
||||||
PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=user
|
|
||||||
PYTORRENT_AUTH_PROXY_AUTO_CREATE_PERMISSION=rw
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Delete the wrongly auto-created external user or log in again. Passwordless external users are synchronized on login by the current config.
|
|
||||||
|
|
||||||
If login fails completely, verify that the configured header reaches pyTorrent:
|
|
||||||
|
|
||||||
```env
|
|
||||||
PYTORRENT_AUTH_PROXY_USER_HEADER=Remote-User
|
|
||||||
```
|
|
||||||
|
|
||||||
The configured header must contain a non-empty username.
|
|
||||||
## External provider logout
|
|
||||||
|
|
||||||
When `PYTORRENT_AUTH_PROVIDER=tinyauth` or `PYTORRENT_AUTH_PROVIDER=proxy` is used, pyTorrent does not render an active logout action. The authenticated session is owned by the upstream provider, so logging out must be handled by that provider, for example through the Tinyauth logout endpoint or its own UI.
|
|
||||||
|
|
||||||
The `/logout` route becomes a safe no-op redirect to the main page for external auth providers. Local authentication keeps the original pyTorrent logout behavior.
|
|
||||||
@@ -13,7 +13,7 @@ Group=pytorrent
|
|||||||
WorkingDirectory=/opt/pyTorrent
|
WorkingDirectory=/opt/pyTorrent
|
||||||
Environment="PYTHONUNBUFFERED=1"
|
Environment="PYTHONUNBUFFERED=1"
|
||||||
EnvironmentFile=/opt/pyTorrent/.env
|
EnvironmentFile=/opt/pyTorrent/.env
|
||||||
ExecStart=/opt/pyTorrent/venv/bin/gunicorn -c /opt/pyTorrent/gunicorn.conf.py --worker-class gthread --workers 1 --threads 32 --bind ${PYTORRENT_HOST}:${PYTORRENT_PORT} wsgi:app
|
ExecStart=/opt/pyTorrent/.venv/bin/gunicorn -c /opt/pyTorrent/gunicorn.conf.py --worker-class gthread --workers 1 --threads 32 --bind ${PYTORRENT_HOST}:${PYTORRENT_PORT} wsgi:app
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
KillSignal=SIGINT
|
KillSignal=SIGINT
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 438 KiB |
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
python3 -m venv venv
|
python3 -m venv .venv
|
||||||
. venv/bin/activate
|
. .venv/bin/activate
|
||||||
pip install --upgrade pip
|
pip install --upgrade pip
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
cp -n .env.example .env || true
|
cp -n .env.example .env || true
|
||||||
@@ -11,4 +11,4 @@ mkdir -p data
|
|||||||
chmod 755 data
|
chmod 755 data
|
||||||
./scripts/download_geoip.sh data/GeoLite2-City.mmdb
|
./scripts/download_geoip.sh data/GeoLite2-City.mmdb
|
||||||
python -c "from pytorrent.db import init_db; init_db(); print(\"SQLite initialized\")"
|
python -c "from pytorrent.db import init_db; init_db(); print(\"SQLite initialized\")"
|
||||||
echo "Run: . venv/bin/activate && python app.py"
|
echo "Run: . .venv/bin/activate && python app.py"
|
||||||
|
|||||||
+1
-1
@@ -45,7 +45,7 @@ def make_zip(repo_path: Path, output_zip: Path) -> None:
|
|||||||
|
|
||||||
zf.write(abs_path, arcname=rel_path)
|
zf.write(abs_path, arcname=rel_path)
|
||||||
|
|
||||||
print(f"Utworzono archiwum: {output_zip}")
|
print(f"Created: {output_zip}")
|
||||||
print(f"Added files: {len(files)}")
|
print(f"Added files: {len(files)}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -124,10 +124,8 @@ def create_app() -> Flask:
|
|||||||
|
|
||||||
from .routes.main import bp as main_bp
|
from .routes.main import bp as main_bp
|
||||||
from .routes.api import bp as api_bp
|
from .routes.api import bp as api_bp
|
||||||
from .routes.planner import bp as planner_api_bp
|
|
||||||
app.register_blueprint(main_bp)
|
app.register_blueprint(main_bp)
|
||||||
app.register_blueprint(api_bp)
|
app.register_blueprint(api_bp)
|
||||||
app.register_blueprint(planner_api_bp)
|
|
||||||
register_error_pages(app)
|
register_error_pages(app)
|
||||||
init_db()
|
init_db()
|
||||||
from .services.speed_peaks import load_cache
|
from .services.speed_peaks import load_cache
|
||||||
@@ -143,6 +141,8 @@ def create_app() -> Flask:
|
|||||||
register_socketio_handlers(socketio)
|
register_socketio_handlers(socketio)
|
||||||
from .services.startup_config import schedule_startup_config_apply
|
from .services.startup_config import schedule_startup_config_apply
|
||||||
schedule_startup_config_apply(socketio)
|
schedule_startup_config_apply(socketio)
|
||||||
|
from .services.background_automations import start_scheduler as start_background_automation_scheduler
|
||||||
|
start_background_automation_scheduler(socketio)
|
||||||
from .services.rss import start_scheduler as start_rss_scheduler
|
from .services.rss import start_scheduler as start_rss_scheduler
|
||||||
from .services.ratio_rules import start_scheduler as start_ratio_scheduler
|
from .services.ratio_rules import start_scheduler as start_ratio_scheduler
|
||||||
from .services.download_planner import start_scheduler as start_download_planner_scheduler
|
from .services.download_planner import start_scheduler as start_download_planner_scheduler
|
||||||
@@ -151,4 +151,6 @@ def create_app() -> Flask:
|
|||||||
start_ratio_scheduler(socketio)
|
start_ratio_scheduler(socketio)
|
||||||
start_download_planner_scheduler(socketio)
|
start_download_planner_scheduler(socketio)
|
||||||
start_backup_scheduler()
|
start_backup_scheduler()
|
||||||
|
from .services.background_cache_warmup import start_scheduler as start_cache_warmup_scheduler
|
||||||
|
start_cache_warmup_scheduler(socketio)
|
||||||
return app
|
return app
|
||||||
|
|||||||
+1
-3
@@ -1,10 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import getpass
|
import getpass
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from .db import connect, init_db, utcnow
|
from .db import connect, init_db, utcnow
|
||||||
from .services.auth import password_hash
|
from .services.auth import password_hash
|
||||||
from .services import tracker_cache
|
from .services import tracker_cache
|
||||||
@@ -106,7 +104,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
token.set_defaults(func=_cmd_revoke_api_token)
|
token.set_defaults(func=_cmd_revoke_api_token)
|
||||||
|
|
||||||
icon = sub.add_parser("tracker-favicon", help="Download or refresh a tracker favicon cache file")
|
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("--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.add_argument("--debug", action="store_true", help="Print cache diagnostics on success or failure")
|
||||||
icon.set_defaults(func=_cmd_tracker_favicon)
|
icon.set_defaults(func=_cmd_tracker_favicon)
|
||||||
|
|||||||
+1
-2
@@ -1,5 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -108,5 +107,5 @@ LOG_ENABLE = _env_bool("PYTORRENT_LOG_ENABLE", True)
|
|||||||
LOG_DIR = Path(os.getenv("PYTORRENT_LOG_DIR", "data/logs"))
|
LOG_DIR = Path(os.getenv("PYTORRENT_LOG_DIR", "data/logs"))
|
||||||
if not LOG_DIR.is_absolute():
|
if not LOG_DIR.is_absolute():
|
||||||
LOG_DIR = BASE_DIR / LOG_DIR
|
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")
|
SMART_QUEUE_STALLED_LABEL = os.getenv("PYTORRENT_SMART_QUEUE_STALLED_LABEL", "Stalled")
|
||||||
|
|||||||
+77
-235
@@ -1,9 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from .config import DB_PATH
|
from .config import DB_PATH
|
||||||
|
from .migrations import run_database_migrations
|
||||||
|
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
@@ -84,6 +84,8 @@ CREATE TABLE IF NOT EXISTS profile_preferences (
|
|||||||
port_check_enabled INTEGER DEFAULT 0,
|
port_check_enabled INTEGER DEFAULT 0,
|
||||||
tracker_favicons_enabled INTEGER DEFAULT 0,
|
tracker_favicons_enabled INTEGER DEFAULT 0,
|
||||||
reverse_dns_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,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
PRIMARY KEY(user_id, profile_id),
|
PRIMARY KEY(user_id, profile_id),
|
||||||
@@ -111,6 +113,25 @@ CREATE TABLE IF NOT EXISTS rtorrent_profiles (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_rtorrent_profiles_user_default_name ON rtorrent_profiles(user_id, is_default, name COLLATE NOCASE);
|
CREATE INDEX IF NOT EXISTS idx_rtorrent_profiles_user_default_name ON rtorrent_profiles(user_id, is_default, name COLLATE NOCASE);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS profile_runtime_stats (
|
||||||
|
profile_id INTEGER PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
torrent_count INTEGER DEFAULT 0,
|
||||||
|
total_size_bytes INTEGER DEFAULT 0,
|
||||||
|
completed_bytes INTEGER DEFAULT 0,
|
||||||
|
downloaded_bytes INTEGER DEFAULT 0,
|
||||||
|
uploaded_bytes INTEGER DEFAULT 0,
|
||||||
|
active_count INTEGER DEFAULT 0,
|
||||||
|
seeding_count INTEGER DEFAULT 0,
|
||||||
|
downloading_count INTEGER DEFAULT 0,
|
||||||
|
stopped_count INTEGER DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id),
|
||||||
|
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profile_runtime_stats_user ON profile_runtime_stats(user_id, profile_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS jobs (
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id INTEGER NOT NULL,
|
user_id INTEGER NOT NULL,
|
||||||
@@ -137,8 +158,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 INDEX IF NOT EXISTS idx_jobs_profile_created ON jobs(profile_id, created_at);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS disk_monitor_preferences (
|
CREATE TABLE IF NOT EXISTS disk_monitor_preferences (
|
||||||
|
profile_id INTEGER PRIMARY KEY,
|
||||||
user_id INTEGER NOT NULL,
|
user_id INTEGER NOT NULL,
|
||||||
profile_id INTEGER NOT NULL,
|
|
||||||
paths_json TEXT,
|
paths_json TEXT,
|
||||||
mode TEXT DEFAULT 'default',
|
mode TEXT DEFAULT 'default',
|
||||||
selected_path TEXT,
|
selected_path TEXT,
|
||||||
@@ -146,10 +167,10 @@ CREATE TABLE IF NOT EXISTS disk_monitor_preferences (
|
|||||||
stop_threshold INTEGER DEFAULT 98,
|
stop_threshold INTEGER DEFAULT 98,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
PRIMARY KEY(user_id, profile_id),
|
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id),
|
FOREIGN KEY(user_id) REFERENCES users(id),
|
||||||
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(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 (
|
CREATE TABLE IF NOT EXISTS labels (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -263,6 +284,8 @@ 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_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_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_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 (
|
CREATE TABLE IF NOT EXISTS app_backups (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -273,6 +296,8 @@ CREATE TABLE IF NOT EXISTS app_backups (
|
|||||||
payload_json TEXT NOT NULL,
|
payload_json TEXT NOT NULL,
|
||||||
created_at 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 (
|
CREATE TABLE IF NOT EXISTS smart_queue_settings (
|
||||||
profile_id INTEGER NOT NULL,
|
profile_id INTEGER NOT NULL,
|
||||||
@@ -365,6 +390,15 @@ CREATE TABLE IF NOT EXISTS traffic_history (
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_traffic_history_profile_created ON traffic_history(profile_id, created_at);
|
CREATE INDEX IF NOT EXISTS idx_traffic_history_profile_created ON traffic_history(profile_id, created_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS profile_speed_limits (
|
||||||
|
profile_id INTEGER PRIMARY KEY,
|
||||||
|
down_limit INTEGER DEFAULT 0,
|
||||||
|
up_limit INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS transfer_speed_peaks (
|
CREATE TABLE IF NOT EXISTS transfer_speed_peaks (
|
||||||
profile_id INTEGER PRIMARY KEY,
|
profile_id INTEGER PRIMARY KEY,
|
||||||
session_started_at TEXT NOT NULL,
|
session_started_at TEXT NOT NULL,
|
||||||
@@ -450,6 +484,7 @@ CREATE TABLE IF NOT EXISTS download_plan_settings (
|
|||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
PRIMARY KEY(user_id, profile_id)
|
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 (
|
CREATE TABLE IF NOT EXISTS download_plan_paused (
|
||||||
profile_id INTEGER NOT NULL,
|
profile_id INTEGER NOT NULL,
|
||||||
@@ -504,10 +539,24 @@ CREATE TABLE IF NOT EXISTS operation_log_settings (
|
|||||||
retention_mode TEXT DEFAULT 'days',
|
retention_mode TEXT DEFAULT 'days',
|
||||||
retention_days INTEGER DEFAULT 30,
|
retention_days INTEGER DEFAULT 30,
|
||||||
retention_lines INTEGER DEFAULT 5000,
|
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,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
PRIMARY KEY(user_id, profile_id)
|
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 (
|
CREATE TABLE IF NOT EXISTS tracker_favicon_cache (
|
||||||
domain TEXT PRIMARY KEY,
|
domain TEXT PRIMARY KEY,
|
||||||
source_url TEXT,
|
source_url TEXT,
|
||||||
@@ -519,219 +568,31 @@ CREATE TABLE IF NOT EXISTS tracker_favicon_cache (
|
|||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
MIGRATIONS = [
|
|
||||||
"ALTER TABLE api_tokens ADD COLUMN last_used_at TEXT",
|
|
||||||
"ALTER TABLE users ADD COLUMN email TEXT",
|
|
||||||
"ALTER TABLE users ADD COLUMN display_name TEXT",
|
|
||||||
"ALTER TABLE users ADD COLUMN external_auth_provider TEXT",
|
|
||||||
"ALTER TABLE users ADD COLUMN external_subject 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 compact_torrent_list_enabled INTEGER DEFAULT 0",
|
|
||||||
"ALTER TABLE user_preferences ADD COLUMN torrent_list_font_size INTEGER DEFAULT 13",
|
|
||||||
"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 interface_scale INTEGER DEFAULT 100",
|
|
||||||
"ALTER TABLE user_preferences ADD COLUMN detail_panel_height INTEGER DEFAULT 255",
|
|
||||||
"ALTER TABLE user_preferences ADD COLUMN easter_egg_enabled INTEGER DEFAULT 0",
|
|
||||||
"ALTER TABLE user_preferences ADD COLUMN easter_egg_loading_image_url TEXT DEFAULT ''",
|
|
||||||
"ALTER TABLE user_preferences ADD COLUMN easter_egg_click_image_url TEXT DEFAULT ''",
|
|
||||||
"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 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 surge_refill_enabled INTEGER DEFAULT 0",
|
|
||||||
"ALTER TABLE smart_queue_settings ADD COLUMN surge_refill_interval_minutes INTEGER DEFAULT 1440",
|
|
||||||
"ALTER TABLE smart_queue_settings ADD COLUMN surge_refill_batch_size INTEGER DEFAULT 2000",
|
|
||||||
"ALTER TABLE smart_queue_settings ADD COLUMN last_surge_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 prefer_partial_progress 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, profile_id INTEGER NOT NULL, 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_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 INDEX IF NOT EXISTS idx_rss_history_profile_status ON rss_history(profile_id, status)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_rss_history_profile_status ON rss_history(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_profile_created ON smart_queue_exclusions(profile_id, created_at)",
|
|
||||||
"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_automation_rules_user_profile_enabled ON automation_rules(user_id, profile_id, enabled)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_automation_history_user_profile_created ON automation_history(user_id, profile_id, created_at)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_rtorrent_profiles_user_default_name ON rtorrent_profiles(user_id, is_default, name COLLATE NOCASE)",
|
|
||||||
"CREATE TABLE IF NOT EXISTS operation_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, profile_id INTEGER, event_type TEXT NOT NULL, severity TEXT DEFAULT 'info', source TEXT DEFAULT 'system', torrent_hash TEXT, torrent_name TEXT, action TEXT, message TEXT NOT NULL, details_json TEXT, created_at TEXT NOT NULL)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_operation_logs_profile_created ON operation_logs(profile_id, created_at)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_operation_logs_user_profile_created ON operation_logs(user_id, profile_id, created_at)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_operation_logs_event_type ON operation_logs(event_type, created_at)",
|
|
||||||
"CREATE TABLE IF NOT EXISTS 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, 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))",
|
|
||||||
"ALTER TABLE app_backups ADD COLUMN backup_type TEXT DEFAULT 'app'",
|
|
||||||
'ALTER TABLE app_backups ADD COLUMN profile_id INTEGER',
|
|
||||||
'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 operation_log_settings (user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL DEFAULT 0, retention_mode TEXT DEFAULT 'days', retention_days INTEGER DEFAULT 30, retention_lines INTEGER DEFAULT 5000, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id))",
|
|
||||||
]
|
|
||||||
|
|
||||||
POST_MIGRATION_INDEXES = [
|
def create_schema(conn: sqlite3.Connection) -> None:
|
||||||
"CREATE INDEX IF NOT EXISTS idx_api_tokens_active_user ON api_tokens(revoked_at, user_id)",
|
"""Create the current database schema definition."""
|
||||||
"CREATE INDEX IF NOT EXISTS idx_user_profile_permissions_user ON user_profile_permissions(user_id, profile_id)",
|
conn.executescript(SCHEMA)
|
||||||
"CREATE INDEX IF NOT EXISTS idx_jobs_status_updated ON jobs(status, updated_at)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_jobs_status_started ON jobs(status, started_at)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_jobs_status_heartbeat ON jobs(status, heartbeat_at)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_jobs_user_profile_created ON jobs(user_id, profile_id, created_at)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_jobs_profile_status_active ON jobs(profile_id, status)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_operation_logs_profile_created ON operation_logs(profile_id, created_at)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_operation_logs_user_profile_created ON operation_logs(user_id, profile_id, created_at)",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
PROFILE_ONLY_TABLES = {
|
def seed_default_user(conn: sqlite3.Connection) -> None:
|
||||||
"rss_feeds": {
|
"""Ensure the built-in admin user and default preferences exist."""
|
||||||
"columns": "id INTEGER PRIMARY KEY AUTOINCREMENT, profile_id INTEGER NOT NULL, name TEXT NOT NULL, url TEXT NOT NULL, enabled INTEGER DEFAULT 1, interval_minutes INTEGER DEFAULT 30, last_error TEXT, last_checked_at TEXT, next_check_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL",
|
now = utcnow()
|
||||||
"copy": ["id", "profile_id", "name", "url", "enabled", "interval_minutes", "last_error", "last_checked_at", "next_check_at", "created_at", "updated_at"],
|
conn.execute(
|
||||||
"indexes": ["CREATE INDEX IF NOT EXISTS idx_rss_feeds_profile_enabled_next ON rss_feeds(profile_id, enabled, next_check_at)"],
|
"INSERT OR IGNORE INTO users(id, username, password_hash, role, is_active, created_at, updated_at) VALUES(1, 'default', NULL, 'admin', 1, ?, ?)",
|
||||||
},
|
(now, now),
|
||||||
"rss_rules": {
|
)
|
||||||
"columns": "id INTEGER PRIMARY KEY AUTOINCREMENT, profile_id INTEGER NOT NULL, name TEXT NOT NULL, pattern TEXT NOT NULL, exclude_pattern TEXT, min_size_mb INTEGER DEFAULT 0, max_size_mb INTEGER DEFAULT 0, category TEXT, quality TEXT, season INTEGER, episode INTEGER, save_path TEXT, label TEXT, start INTEGER DEFAULT 1, enabled INTEGER DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL",
|
conn.execute(
|
||||||
"copy": ["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"],
|
"UPDATE users SET role=COALESCE(role, 'admin'), is_active=COALESCE(is_active, 1), updated_at=COALESCE(updated_at, ?) WHERE id=1",
|
||||||
"indexes": ["CREATE INDEX IF NOT EXISTS idx_rss_rules_profile_enabled ON rss_rules(profile_id, enabled)"],
|
(now,),
|
||||||
},
|
)
|
||||||
"rss_history": {
|
pref = conn.execute("SELECT id FROM user_preferences WHERE user_id=1").fetchone()
|
||||||
"columns": "id INTEGER PRIMARY KEY AUTOINCREMENT, profile_id INTEGER NOT NULL, feed_id INTEGER, rule_id INTEGER, title TEXT, link TEXT, status TEXT NOT NULL, message TEXT, created_at TEXT NOT NULL",
|
if not pref:
|
||||||
"copy": ["id", "profile_id", "feed_id", "rule_id", "title", "link", "status", "message", "created_at"],
|
conn.execute(
|
||||||
"indexes": ["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_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')"],
|
"INSERT INTO user_preferences(user_id, theme, created_at, updated_at) VALUES(1, 'dark', ?, ?)",
|
||||||
},
|
(now, now),
|
||||||
"smart_queue_settings": {
|
)
|
||||||
"columns": "profile_id INTEGER NOT NULL, enabled INTEGER DEFAULT 0, max_active_downloads INTEGER DEFAULT 5, stalled_seconds INTEGER DEFAULT 300, min_speed_bytes INTEGER DEFAULT 1024, min_seeds INTEGER DEFAULT 1, min_peers INTEGER DEFAULT 0, ignore_seed_peer INTEGER DEFAULT 0, ignore_speed INTEGER DEFAULT 0, manage_stopped INTEGER DEFAULT 0, cooldown_minutes INTEGER DEFAULT 10, last_run_at TEXT, 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(profile_id)",
|
|
||||||
"copy": ["profile_id", "enabled", "max_active_downloads", "stalled_seconds", "min_speed_bytes", "min_seeds", "min_peers", "ignore_seed_peer", "ignore_speed", "manage_stopped", "cooldown_minutes", "last_run_at", "refill_enabled", "refill_interval_minutes", "last_refill_at", "surge_refill_enabled", "surge_refill_interval_minutes", "surge_refill_batch_size", "last_surge_refill_at", "stop_batch_size", "start_grace_seconds", "protect_active_below_cap", "prefer_partial_progress", "auto_stop_idle", "updated_at"],
|
|
||||||
"indexes": [],
|
|
||||||
},
|
|
||||||
"smart_queue_exclusions": {
|
|
||||||
"columns": "profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, reason TEXT, created_at TEXT NOT NULL, PRIMARY KEY(profile_id, torrent_hash)",
|
|
||||||
"copy": ["profile_id", "torrent_hash", "reason", "created_at"],
|
|
||||||
"indexes": ["CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_profile_created ON smart_queue_exclusions(profile_id, created_at)"],
|
|
||||||
},
|
|
||||||
"smart_queue_history": {
|
|
||||||
"columns": "id INTEGER PRIMARY KEY AUTOINCREMENT, profile_id INTEGER NOT NULL, event TEXT NOT NULL, paused_count INTEGER DEFAULT 0, resumed_count INTEGER DEFAULT 0, checked_count INTEGER DEFAULT 0, details_json TEXT, created_at TEXT NOT NULL",
|
|
||||||
"copy": ["id", "profile_id", "event", "paused_count", "resumed_count", "checked_count", "details_json", "created_at"],
|
|
||||||
"indexes": ["CREATE INDEX IF NOT EXISTS idx_smart_queue_history_profile_created ON smart_queue_history(profile_id, created_at)"],
|
|
||||||
},
|
|
||||||
"rtorrent_config_overrides": {
|
|
||||||
"columns": "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(profile_id, key)",
|
|
||||||
"copy": ["profile_id", "key", "value", "baseline_value", "apply_on_start", "updated_at"],
|
|
||||||
"indexes": ["CREATE INDEX IF NOT EXISTS idx_rtorrent_config_overrides_profile ON rtorrent_config_overrides(profile_id, apply_on_start)"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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 sqlite3.OperationalError:
|
|
||||||
return set()
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_profile_only_tables(conn) -> None:
|
|
||||||
"""Move operational settings from user scope to profile scope on existing databases."""
|
|
||||||
for table, spec in PROFILE_ONLY_TABLES.items():
|
|
||||||
columns = _table_columns(conn, table)
|
|
||||||
if not columns or "user_id" not in columns:
|
|
||||||
for index_sql in spec["indexes"]:
|
|
||||||
try:
|
|
||||||
conn.execute(index_sql)
|
|
||||||
except sqlite3.OperationalError:
|
|
||||||
pass
|
|
||||||
continue
|
|
||||||
tmp = f"{table}_profile_scope_tmp"
|
|
||||||
conn.execute("PRAGMA foreign_keys = OFF")
|
|
||||||
conn.execute(f"DROP TABLE IF EXISTS {tmp}")
|
|
||||||
conn.execute(f"CREATE TABLE {tmp} ({spec['columns']})")
|
|
||||||
copy_cols = [col for col in spec["copy"] if col in columns]
|
|
||||||
if copy_cols:
|
|
||||||
col_sql = ",".join(copy_cols)
|
|
||||||
if table in {"smart_queue_settings", "smart_queue_exclusions", "rtorrent_config_overrides"}:
|
|
||||||
conn.execute(f"INSERT OR REPLACE INTO {tmp}({col_sql}) SELECT {col_sql} FROM {table} WHERE profile_id IS NOT NULL")
|
|
||||||
else:
|
|
||||||
conn.execute(f"INSERT INTO {tmp}({col_sql}) SELECT {col_sql} FROM {table} WHERE profile_id IS NOT NULL")
|
|
||||||
conn.execute(f"DROP TABLE {table}")
|
|
||||||
conn.execute(f"ALTER TABLE {tmp} RENAME TO {table}")
|
|
||||||
for index_sql in spec["indexes"]:
|
|
||||||
conn.execute(index_sql)
|
|
||||||
conn.execute("PRAGMA foreign_keys = ON")
|
|
||||||
|
|
||||||
def utcnow() -> str:
|
def utcnow() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
@@ -756,37 +617,18 @@ def connect():
|
|||||||
|
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
|
"""Initialize SQLite, applying the current schema and idempotent migrations."""
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
try:
|
try:
|
||||||
conn.execute("PRAGMA journal_mode = WAL")
|
conn.execute("PRAGMA journal_mode = WAL")
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
pass
|
pass
|
||||||
conn.executescript(SCHEMA)
|
create_schema(conn)
|
||||||
for sql in MIGRATIONS:
|
run_database_migrations(conn)
|
||||||
try:
|
seed_default_user(conn)
|
||||||
conn.execute(sql)
|
|
||||||
except sqlite3.OperationalError:
|
|
||||||
pass
|
|
||||||
for sql in POST_MIGRATION_INDEXES:
|
|
||||||
try:
|
|
||||||
conn.execute(sql)
|
|
||||||
except sqlite3.OperationalError:
|
|
||||||
pass
|
|
||||||
_normalize_profile_only_tables(conn)
|
|
||||||
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),
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
from .services.auth import ensure_admin_user
|
from .services.auth import ensure_admin_user
|
||||||
|
|
||||||
ensure_admin_user()
|
ensure_admin_user()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from logging.handlers import TimedRotatingFileHandler
|
from logging.handlers import TimedRotatingFileHandler
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from flask import Flask, g, request
|
from flask import Flask, g, request
|
||||||
|
|
||||||
from .config import LOG_DIR, LOG_ENABLE, LOG_RETENTION_HOURS
|
from .config import LOG_DIR, LOG_ENABLE, LOG_RETENTION_HOURS
|
||||||
|
|
||||||
_CONFIGURED = False
|
_CONFIGURED = False
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import sqlite3
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
Migration = Callable[[sqlite3.Connection], bool]
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def _row_value(row: sqlite3.Row | dict[str, object] | tuple[object, ...], key: str, index: int) -> object:
|
||||||
|
try:
|
||||||
|
return row[key] # type: ignore[index]
|
||||||
|
except (KeyError, IndexError, TypeError):
|
||||||
|
return row[index] # type: ignore[index]
|
||||||
|
|
||||||
|
|
||||||
|
def _column_names(conn: sqlite3.Connection, table: str) -> set[str]:
|
||||||
|
return {str(_row_value(row, "name", 1)) for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||||
|
|
||||||
|
|
||||||
|
def _primary_key_columns(conn: sqlite3.Connection, table: str) -> list[str]:
|
||||||
|
columns = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
||||||
|
pk_columns = sorted(
|
||||||
|
(
|
||||||
|
(int(_row_value(row, "pk", 5) or 0), str(_row_value(row, "name", 1)))
|
||||||
|
for row in columns
|
||||||
|
if int(_row_value(row, "pk", 5) or 0)
|
||||||
|
),
|
||||||
|
key=lambda item: item[0],
|
||||||
|
)
|
||||||
|
return [name for _, name in pk_columns]
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_disk_monitor_preferences_to_profile_scope(conn: sqlite3.Connection) -> bool:
|
||||||
|
if _primary_key_columns(conn, "disk_monitor_preferences") == ["profile_id"]:
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_disk_monitor_preferences_owner ON disk_monitor_preferences(user_id)")
|
||||||
|
return False
|
||||||
|
|
||||||
|
now = _utcnow()
|
||||||
|
conn.execute("DROP INDEX IF EXISTS idx_disk_monitor_preferences_owner")
|
||||||
|
conn.execute("DROP TABLE IF EXISTS disk_monitor_preferences_new")
|
||||||
|
conn.execute("DROP TABLE IF EXISTS disk_monitor_preferences_old_user_profile")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE disk_monitor_preferences_new (
|
||||||
|
profile_id INTEGER PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
paths_json TEXT,
|
||||||
|
mode TEXT DEFAULT 'default',
|
||||||
|
selected_path TEXT,
|
||||||
|
stop_enabled INTEGER DEFAULT 0,
|
||||||
|
stop_threshold INTEGER DEFAULT 98,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id),
|
||||||
|
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO disk_monitor_preferences_new(
|
||||||
|
profile_id, user_id, paths_json, mode, selected_path, stop_enabled, stop_threshold, created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT profile_id, user_id, paths_json, mode, selected_path, stop_enabled, stop_threshold,
|
||||||
|
COALESCE(created_at, ?), COALESCE(updated_at, ?)
|
||||||
|
FROM (
|
||||||
|
SELECT d.*,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY profile_id
|
||||||
|
ORDER BY COALESCE(updated_at, created_at, '') DESC, user_id ASC
|
||||||
|
) AS rn
|
||||||
|
FROM disk_monitor_preferences d
|
||||||
|
WHERE profile_id IS NOT NULL
|
||||||
|
)
|
||||||
|
WHERE rn = 1
|
||||||
|
""", (now, now))
|
||||||
|
conn.execute("ALTER TABLE disk_monitor_preferences RENAME TO disk_monitor_preferences_old_user_profile")
|
||||||
|
conn.execute("ALTER TABLE disk_monitor_preferences_new RENAME TO disk_monitor_preferences")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_disk_monitor_preferences_owner ON disk_monitor_preferences(user_id)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_profile_preferences_sidebar_columns(conn: sqlite3.Connection) -> bool:
|
||||||
|
columns = _column_names(conn, "profile_preferences")
|
||||||
|
changed = False
|
||||||
|
if "sidebar_labels_expanded" not in columns:
|
||||||
|
conn.execute("ALTER TABLE profile_preferences ADD COLUMN sidebar_labels_expanded INTEGER DEFAULT 0")
|
||||||
|
changed = True
|
||||||
|
if "sidebar_shortcuts_expanded" not in columns:
|
||||||
|
conn.execute("ALTER TABLE profile_preferences ADD COLUMN sidebar_shortcuts_expanded INTEGER DEFAULT 0")
|
||||||
|
changed = True
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_operation_log_split_retention(conn: sqlite3.Connection) -> bool:
|
||||||
|
columns = _column_names(conn, "operation_log_settings")
|
||||||
|
changed = False
|
||||||
|
additions = {
|
||||||
|
"retention_interval_hours": "INTEGER DEFAULT 24",
|
||||||
|
"job_retention_mode": "TEXT DEFAULT 'days'",
|
||||||
|
"job_retention_days": "INTEGER DEFAULT 7",
|
||||||
|
"job_retention_lines": "INTEGER DEFAULT 2000",
|
||||||
|
"job_retention_interval_hours": "INTEGER DEFAULT 24",
|
||||||
|
"job_last_retention_run_at": "TEXT",
|
||||||
|
"job_last_retention_deleted": "INTEGER DEFAULT 0",
|
||||||
|
"operation_retention_mode": "TEXT DEFAULT 'days'",
|
||||||
|
"operation_retention_days": "INTEGER DEFAULT 30",
|
||||||
|
"operation_retention_lines": "INTEGER DEFAULT 5000",
|
||||||
|
"operation_retention_interval_hours": "INTEGER DEFAULT 24",
|
||||||
|
"operation_last_retention_run_at": "TEXT",
|
||||||
|
"operation_last_retention_deleted": "INTEGER DEFAULT 0",
|
||||||
|
}
|
||||||
|
for name, ddl in additions.items():
|
||||||
|
if name not in columns:
|
||||||
|
conn.execute(f"ALTER TABLE operation_log_settings ADD COLUMN {name} {ddl}")
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
conn.execute("""
|
||||||
|
UPDATE operation_log_settings
|
||||||
|
SET operation_retention_mode=COALESCE(operation_retention_mode, retention_mode, 'days'),
|
||||||
|
operation_retention_days=COALESCE(operation_retention_days, retention_days, 30),
|
||||||
|
operation_retention_lines=COALESCE(operation_retention_lines, retention_lines, 5000),
|
||||||
|
operation_retention_interval_hours=COALESCE(operation_retention_interval_hours, retention_interval_hours, 24),
|
||||||
|
job_retention_mode=COALESCE(job_retention_mode, 'days'),
|
||||||
|
job_retention_days=COALESCE(job_retention_days, 7),
|
||||||
|
job_retention_lines=COALESCE(job_retention_lines, 2000),
|
||||||
|
job_retention_interval_hours=COALESCE(job_retention_interval_hours, retention_interval_hours, 24),
|
||||||
|
updated_at=COALESCE(updated_at, ?)
|
||||||
|
""", (_utcnow(),))
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_profile_speed_limits_table(conn: sqlite3.Connection) -> bool:
|
||||||
|
existing = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='profile_speed_limits'").fetchone()
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS profile_speed_limits (
|
||||||
|
profile_id INTEGER PRIMARY KEY,
|
||||||
|
down_limit INTEGER DEFAULT 0,
|
||||||
|
up_limit INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
return existing is None
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_profile_runtime_stats_table(conn: sqlite3.Connection) -> bool:
|
||||||
|
existing = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='profile_runtime_stats'").fetchone()
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS profile_runtime_stats (
|
||||||
|
profile_id INTEGER PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
torrent_count INTEGER DEFAULT 0,
|
||||||
|
total_size_bytes INTEGER DEFAULT 0,
|
||||||
|
completed_bytes INTEGER DEFAULT 0,
|
||||||
|
downloaded_bytes INTEGER DEFAULT 0,
|
||||||
|
uploaded_bytes INTEGER DEFAULT 0,
|
||||||
|
active_count INTEGER DEFAULT 0,
|
||||||
|
seeding_count INTEGER DEFAULT 0,
|
||||||
|
downloading_count INTEGER DEFAULT 0,
|
||||||
|
stopped_count INTEGER DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id),
|
||||||
|
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_profile_runtime_stats_user ON profile_runtime_stats(user_id, profile_id)")
|
||||||
|
return existing is None
|
||||||
|
|
||||||
|
|
||||||
|
MIGRATIONS: tuple[Migration, ...] = (
|
||||||
|
migrate_disk_monitor_preferences_to_profile_scope,
|
||||||
|
migrate_profile_preferences_sidebar_columns,
|
||||||
|
migrate_operation_log_split_retention,
|
||||||
|
migrate_profile_speed_limits_table,
|
||||||
|
migrate_profile_runtime_stats_table,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_database_migrations(conn: sqlite3.Connection) -> int:
|
||||||
|
"""Run idempotent database migrations and return how many changed the schema/data."""
|
||||||
|
applied = 0
|
||||||
|
for migration in MIGRATIONS:
|
||||||
|
if migration(conn):
|
||||||
|
applied += 1
|
||||||
|
return applied
|
||||||
+2514
-322
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from importlib import import_module
|
||||||
|
|
||||||
|
API_ROUTE_MODULES = (
|
||||||
|
"torrents",
|
||||||
|
"profiles",
|
||||||
|
"rss",
|
||||||
|
"automations",
|
||||||
|
"smart_queue",
|
||||||
|
"system",
|
||||||
|
"backup",
|
||||||
|
"operation_logs",
|
||||||
|
"planner",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_api_route_modules() -> None:
|
||||||
|
"""Import API route modules so their shared blueprint decorators are registered."""
|
||||||
|
for module_name in API_ROUTE_MODULES:
|
||||||
|
import_module(f"{__name__}.{module_name}")
|
||||||
+129
-225
@@ -1,5 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
@@ -19,11 +18,10 @@ import threading
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
from flask import Blueprint, jsonify, request, abort, send_file, redirect, Response, stream_with_context, url_for
|
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 ..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 ..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.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
|
from ..services import auth, preferences, rtorrent, torrent_stats, speed_peaks, tracker_cache, rss as rss_service, ratio_rules, backup as backup_service, download_planner, operation_logs, poller_control, database_maintenance
|
||||||
from ..services.torrent_cache import torrent_cache
|
from ..services.torrent_cache import torrent_cache
|
||||||
from ..services.torrent_summary import cached_summary
|
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
|
from ..services.workers import enqueue, list_jobs, cancel_job, retry_job, force_job, clear_jobs, emergency_clear_jobs
|
||||||
@@ -34,11 +32,93 @@ bp = Blueprint("api", __name__, url_prefix="/api")
|
|||||||
|
|
||||||
MOVE_BULK_MAX_HASHES = 100
|
MOVE_BULK_MAX_HASHES = 100
|
||||||
|
|
||||||
|
|
||||||
from .auth_api import register_auth_routes
|
from .auth_api import register_auth_routes
|
||||||
register_auth_routes(bp)
|
register_auth_routes(bp)
|
||||||
|
|
||||||
|
|
||||||
|
def _request_profile_selector() -> tuple[int | None, str]:
|
||||||
|
"""Return the optional rTorrent profile selector supplied by external API clients."""
|
||||||
|
payload = {}
|
||||||
|
if request.method in {"POST", "PUT", "PATCH", "DELETE"}:
|
||||||
|
try:
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
except Exception:
|
||||||
|
payload = {}
|
||||||
|
|
||||||
|
profile_id = (
|
||||||
|
request.args.get("profile_id")
|
||||||
|
or request.form.get("profile_id")
|
||||||
|
or payload.get("rtorrent_profile_id")
|
||||||
|
or request.headers.get("X-PyTorrent-Profile-Id")
|
||||||
|
)
|
||||||
|
profile_name = (
|
||||||
|
request.args.get("profile_name")
|
||||||
|
or request.form.get("profile_name")
|
||||||
|
or payload.get("rtorrent_profile_name")
|
||||||
|
or request.headers.get("X-PyTorrent-Profile-Name")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return (int(profile_id), "") if profile_id not in (None, "") else (None, str(profile_name or "").strip())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError("profile_id must be an integer")
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_by_name(profile_name: str, user_id: int | None = None):
|
||||||
|
name = str(profile_name or "").strip()
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
user_id = user_id or default_user_id()
|
||||||
|
visible = auth.visible_profile_ids(user_id)
|
||||||
|
with connect() as conn:
|
||||||
|
if visible is None:
|
||||||
|
return conn.execute(
|
||||||
|
"SELECT * FROM rtorrent_profiles WHERE lower(name)=lower(?) ORDER BY is_default DESC, id LIMIT 1",
|
||||||
|
(name,),
|
||||||
|
).fetchone()
|
||||||
|
if not visible:
|
||||||
|
return None
|
||||||
|
placeholders = ",".join("?" for _ in visible)
|
||||||
|
return conn.execute(
|
||||||
|
f"SELECT * FROM rtorrent_profiles WHERE id IN ({placeholders}) AND lower(name)=lower(?) ORDER BY is_default DESC, id LIMIT 1",
|
||||||
|
(*tuple(visible), name),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def request_profile(require_write: bool = False):
|
||||||
|
"""Resolve API profile context from profile_id/profile_name, then active profile for compatibility."""
|
||||||
|
try:
|
||||||
|
profile_id, profile_name = _request_profile_selector()
|
||||||
|
except ValueError:
|
||||||
|
raise
|
||||||
|
user_id = default_user_id()
|
||||||
|
profile = None
|
||||||
|
if profile_id:
|
||||||
|
profile = preferences.get_profile(int(profile_id), user_id)
|
||||||
|
elif profile_name:
|
||||||
|
profile = _profile_by_name(profile_name, user_id)
|
||||||
|
else:
|
||||||
|
profile = preferences.active_profile(user_id)
|
||||||
|
if not profile and auth.can_access_profile(1, user_id):
|
||||||
|
profile = preferences.get_profile(1, user_id)
|
||||||
|
if not profile and (profile_id or profile_name):
|
||||||
|
abort(404)
|
||||||
|
if not profile:
|
||||||
|
return None
|
||||||
|
pid = int(profile["id"])
|
||||||
|
if require_write and not auth.can_write_profile(pid, user_id):
|
||||||
|
abort(403)
|
||||||
|
if not require_write and not auth.can_access_profile(pid, user_id):
|
||||||
|
abort(403)
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
def request_profile_id(require_write: bool = False) -> int | None:
|
||||||
|
profile = request_profile(require_write=require_write)
|
||||||
|
return int(profile["id"]) if profile else None
|
||||||
|
|
||||||
|
|
||||||
def _job_profile_id(job_id: str) -> int | None:
|
def _job_profile_id(job_id: str) -> int | None:
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
row = conn.execute("SELECT profile_id FROM jobs WHERE id=?", (job_id,)).fetchone()
|
row = conn.execute("SELECT profile_id FROM jobs WHERE id=?", (job_id,)).fetchone()
|
||||||
@@ -51,196 +131,7 @@ def ok(payload=None):
|
|||||||
return jsonify(data)
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
|
from ..services.port_check import port_check_status
|
||||||
PORT_CHECK_CACHE_SECONDS = 6 * 60 * 60
|
|
||||||
|
|
||||||
|
|
||||||
def _app_setting_get(key: str):
|
|
||||||
with connect() as conn:
|
|
||||||
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (key,)).fetchone()
|
|
||||||
return row.get("value") if row else None
|
|
||||||
|
|
||||||
|
|
||||||
def _app_setting_set(key: str, value: str):
|
|
||||||
with connect() as conn:
|
|
||||||
conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (key, value))
|
|
||||||
|
|
||||||
|
|
||||||
def _iso_from_epoch(value) -> str | None:
|
|
||||||
try:
|
|
||||||
return datetime.fromtimestamp(float(value), timezone.utc).isoformat(timespec="seconds")
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _public_ip(profile: dict | None = None, force: bool = False) -> str:
|
|
||||||
if profile and bool(profile.get("is_remote")):
|
|
||||||
return rtorrent.remote_public_ip(profile, force=force)
|
|
||||||
req = urllib.request.Request("https://api.ipify.org", headers={"User-Agent": "pyTorrent/port-check"})
|
|
||||||
with urllib.request.urlopen(req, timeout=8) as res:
|
|
||||||
return res.read(64).decode("utf-8", "replace").strip()
|
|
||||||
|
|
||||||
|
|
||||||
MAX_PORT_CHECK_CANDIDATES = 256
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_port_candidates(value: str, limit: int = MAX_PORT_CHECK_CANDIDATES) -> tuple[list[int], bool]:
|
|
||||||
"""Return valid incoming port candidates from rTorrent network.port_range.
|
|
||||||
|
|
||||||
Note: rTorrent may keep a range/list and pick a random port on start.
|
|
||||||
The old checker used only the first number, which produced false "closed"
|
|
||||||
results when another configured port was actually active.
|
|
||||||
"""
|
|
||||||
ports: list[int] = []
|
|
||||||
seen: set[int] = set()
|
|
||||||
truncated = False
|
|
||||||
|
|
||||||
def add(port: int) -> None:
|
|
||||||
nonlocal truncated
|
|
||||||
if not 1 <= port <= 65535 or port in seen:
|
|
||||||
return
|
|
||||||
if len(ports) >= limit:
|
|
||||||
truncated = True
|
|
||||||
return
|
|
||||||
seen.add(port)
|
|
||||||
ports.append(port)
|
|
||||||
|
|
||||||
for start, end in re.findall(r"(\d{1,5})\s*-\s*(\d{1,5})", value or ""):
|
|
||||||
a, b = int(start), int(end)
|
|
||||||
if a > b:
|
|
||||||
a, b = b, a
|
|
||||||
for port in range(a, b + 1):
|
|
||||||
add(port)
|
|
||||||
if truncated:
|
|
||||||
break
|
|
||||||
|
|
||||||
without_ranges = re.sub(r"\d{1,5}\s*-\s*\d{1,5}", " ", value or "")
|
|
||||||
for item in re.findall(r"\d{1,5}", without_ranges):
|
|
||||||
add(int(item))
|
|
||||||
|
|
||||||
return ports, truncated
|
|
||||||
|
|
||||||
|
|
||||||
def _incoming_ports(profile: dict) -> dict:
|
|
||||||
try:
|
|
||||||
raw_value = str(rtorrent.client_for(profile).call("network.port_range") or "")
|
|
||||||
except Exception:
|
|
||||||
raw_value = ""
|
|
||||||
ports, truncated = _parse_port_candidates(raw_value)
|
|
||||||
return {"ports": ports, "raw": raw_value, "truncated": truncated}
|
|
||||||
|
|
||||||
|
|
||||||
def _yougetsignal_check(public_ip: str, port: int) -> dict:
|
|
||||||
body = urllib.parse.urlencode({"remoteAddress": public_ip, "portNumber": str(port)}).encode("utf-8")
|
|
||||||
req = urllib.request.Request(
|
|
||||||
"https://ports.yougetsignal.com/check-port.php",
|
|
||||||
data=body,
|
|
||||||
headers={
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
||||||
"User-Agent": "pyTorrent/port-check",
|
|
||||||
"Accept": "text/html,application/json,*/*",
|
|
||||||
},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(req, timeout=12) as res:
|
|
||||||
text = res.read(8192).decode("utf-8", "replace")
|
|
||||||
low = text.lower()
|
|
||||||
if "is open" in low:
|
|
||||||
return {"status": "open", "source": "yougetsignal", "raw": text[:500]}
|
|
||||||
if "is closed" in low:
|
|
||||||
return {"status": "closed", "source": "yougetsignal", "raw": text[:500]}
|
|
||||||
return {"status": "unknown", "source": "yougetsignal", "raw": text[:500]}
|
|
||||||
|
|
||||||
|
|
||||||
def _local_port_fallback(public_ip: str, port: int) -> dict:
|
|
||||||
try:
|
|
||||||
with socket.create_connection((public_ip, port), timeout=3):
|
|
||||||
return {"status": "open", "source": "local-fallback"}
|
|
||||||
except Exception as exc:
|
|
||||||
return {"status": "unknown", "source": "local-fallback", "error": f"Local fallback inconclusive: {exc}"}
|
|
||||||
|
|
||||||
|
|
||||||
def _check_ports(public_ip: str, ports: list[int], checker) -> dict:
|
|
||||||
checked: list[int] = []
|
|
||||||
first_closed: dict | None = None
|
|
||||||
last_result: dict = {"status": "unknown"}
|
|
||||||
|
|
||||||
for port in ports:
|
|
||||||
checked.append(port)
|
|
||||||
current = checker(public_ip, port)
|
|
||||||
last_result = current
|
|
||||||
if current.get("status") == "open":
|
|
||||||
current.update({"port": port, "open_port": port, "checked_ports": checked})
|
|
||||||
return current
|
|
||||||
if current.get("status") == "closed" and first_closed is None:
|
|
||||||
first_closed = current
|
|
||||||
|
|
||||||
result = first_closed or last_result
|
|
||||||
result.update({"port": ports[0] if ports else None, "open_port": None, "checked_ports": checked})
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def port_check_status(force: bool = False) -> dict:
|
|
||||||
profile = preferences.active_profile()
|
|
||||||
prefs = preferences.get_preferences()
|
|
||||||
enabled = bool((prefs or {}).get("port_check_enabled"))
|
|
||||||
if not profile:
|
|
||||||
return {"status": "unknown", "enabled": enabled, "error": "No profile"}
|
|
||||||
|
|
||||||
port_info = _incoming_ports(profile)
|
|
||||||
ports = port_info["ports"]
|
|
||||||
if not ports:
|
|
||||||
return {"status": "unknown", "enabled": enabled, "error": "Cannot read rTorrent network.port_range"}
|
|
||||||
|
|
||||||
ports_key = ",".join(str(port) for port in ports)
|
|
||||||
cache_key = f"port_check:{profile['id']}:{ports_key}:{int(bool(port_info['truncated']))}"
|
|
||||||
if not force:
|
|
||||||
cached = _app_setting_get(cache_key)
|
|
||||||
if cached:
|
|
||||||
try:
|
|
||||||
data = json.loads(cached)
|
|
||||||
if time.time() - float(data.get("checked_at_epoch") or 0) < PORT_CHECK_CACHE_SECONDS:
|
|
||||||
data["cached"] = True
|
|
||||||
data["enabled"] = enabled
|
|
||||||
if not data.get("checked_at"):
|
|
||||||
data["checked_at"] = _iso_from_epoch(data.get("checked_at_epoch"))
|
|
||||||
return data
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
checked_at_epoch = time.time()
|
|
||||||
result = {
|
|
||||||
"status": "unknown",
|
|
||||||
"enabled": enabled,
|
|
||||||
"port": ports[0],
|
|
||||||
"ports": ports,
|
|
||||||
"port_range": port_info["raw"],
|
|
||||||
"ports_truncated": port_info["truncated"],
|
|
||||||
"checked_at_epoch": checked_at_epoch,
|
|
||||||
"checked_at": _iso_from_epoch(checked_at_epoch),
|
|
||||||
"cached": False,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
public_ip = _public_ip(profile, force=force)
|
|
||||||
result["public_ip"] = public_ip
|
|
||||||
result["remote"] = bool(profile.get("is_remote"))
|
|
||||||
result.update(_check_ports(public_ip, ports, _yougetsignal_check))
|
|
||||||
except Exception as exc:
|
|
||||||
result["error"] = f"YouGetSignal failed: {exc}"
|
|
||||||
try:
|
|
||||||
public_ip = result.get("public_ip") or _public_ip(profile, force=force)
|
|
||||||
result["public_ip"] = public_ip
|
|
||||||
result["remote"] = bool(profile.get("is_remote"))
|
|
||||||
result.update(_check_ports(public_ip, ports, _local_port_fallback))
|
|
||||||
except Exception as fallback_exc:
|
|
||||||
result["fallback_error"] = str(fallback_exc)
|
|
||||||
result["source"] = "none"
|
|
||||||
_app_setting_set(cache_key, json.dumps(result))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_len(callable_obj) -> int | None:
|
def _safe_len(callable_obj) -> int | None:
|
||||||
@@ -249,30 +140,37 @@ def _safe_len(callable_obj) -> int | None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _table_count(table: str, where: str = "", params: tuple = ()) -> int:
|
def _table_count(table: str, where: str = "", params: tuple = (), conn=None) -> int:
|
||||||
with connect() as conn:
|
"""Count rows with one SQL statement; schema-created tables do not need a sqlite_master pre-check."""
|
||||||
exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone()
|
try:
|
||||||
if not exists:
|
if conn is None:
|
||||||
return 0
|
with connect() as owned_conn:
|
||||||
row = conn.execute(f"SELECT COUNT(*) AS n FROM {table} {where}", params).fetchone()
|
row = owned_conn.execute(f"SELECT COUNT(*) AS n FROM {table} {where}", params).fetchone()
|
||||||
return int((row or {}).get("n") or 0)
|
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:
|
def _db_size() -> dict:
|
||||||
try:
|
try:
|
||||||
size = DB_PATH.stat().st_size if DB_PATH.exists() else 0
|
return database_maintenance.database_status()
|
||||||
return {"path": str(DB_PATH), "size": size, "size_h": rtorrent.human_size(size)}
|
|
||||||
except Exception as exc:
|
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 = preferences.active_profile() if profile_id is None else {"id": profile_id}
|
||||||
profile_id = int((profile or {}).get("id") or 0)
|
profile_id = int((profile or {}).get("id") or 0)
|
||||||
if not profile_id:
|
if not profile_id:
|
||||||
return {"profile_id": 0, "profile_rows": 0, "runtime_items": 0}
|
return {"profile_id": 0, "profile_rows": 0, "runtime_items": 0}
|
||||||
tracker_rows = _table_count("tracker_summary_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,))
|
stats_rows = _table_count("torrent_stats_cache", "WHERE profile_id=?", (profile_id,), conn=conn)
|
||||||
runtime_items = 0
|
runtime_items = 0
|
||||||
try:
|
try:
|
||||||
runtime_items += len(torrent_cache.snapshot(profile_id))
|
runtime_items += len(torrent_cache.snapshot(profile_id))
|
||||||
@@ -284,21 +182,28 @@ def _active_profile_cache_summary(profile_id: int | None = None) -> dict:
|
|||||||
def cleanup_summary() -> dict:
|
def cleanup_summary() -> dict:
|
||||||
active_profile = preferences.active_profile()
|
active_profile = preferences.active_profile()
|
||||||
profile_id = int((active_profile or {}).get("id") or 0)
|
profile_id = int((active_profile or {}).get("id") or 0)
|
||||||
operation_logs_total = _table_count(
|
with connect() as conn:
|
||||||
"operation_logs",
|
operation_logs_total = _table_count(
|
||||||
"WHERE profile_id=? OR profile_id IS NULL",
|
"operation_logs",
|
||||||
(profile_id,),
|
"WHERE profile_id=? OR profile_id IS NULL",
|
||||||
) if profile_id else _table_count("operation_logs")
|
(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)
|
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 {}
|
poller_runtime = poller_control.snapshot(profile_id) if profile_id else {}
|
||||||
return {
|
return {
|
||||||
"jobs_total": _table_count("jobs"),
|
"jobs_total": jobs_total,
|
||||||
"jobs_clearable": _table_count("jobs", "WHERE status NOT IN ('pending', 'running')"),
|
"jobs_clearable": jobs_clearable,
|
||||||
"smart_queue_history_total": _table_count("smart_queue_history"),
|
"smart_queue_history_total": smart_queue_history_total,
|
||||||
"operation_logs_total": operation_logs_total,
|
"operation_logs_total": operation_logs_total,
|
||||||
"automation_history_total": _table_count("automation_history"),
|
"automation_history_total": automation_history_total,
|
||||||
"planner_history_total": download_planner.history_count(profile_id) if profile_id else 0,
|
"planner_history_total": download_planner.history_count(profile_id) if profile_id else 0,
|
||||||
"cache": _active_profile_cache_summary(profile_id if profile_id else None),
|
"cache": cache_summary,
|
||||||
"poller_runtime": poller_runtime,
|
"poller_runtime": poller_runtime,
|
||||||
"retention_days": {
|
"retention_days": {
|
||||||
"jobs": JOBS_RETENTION_DAYS,
|
"jobs": JOBS_RETENTION_DAYS,
|
||||||
@@ -312,6 +217,7 @@ def cleanup_summary() -> dict:
|
|||||||
"operation_logs": operation_logs.retention_label(operation_log_retention),
|
"operation_logs": operation_logs.retention_label(operation_log_retention),
|
||||||
},
|
},
|
||||||
"database": _db_size(),
|
"database": _db_size(),
|
||||||
|
"admin": is_admin(current_user()),
|
||||||
}
|
}
|
||||||
|
|
||||||
def active_default_download_path(profile: dict | None) -> str:
|
def active_default_download_path(profile: dict | None) -> str:
|
||||||
@@ -355,17 +261,20 @@ def enrich_bulk_payload(profile: dict, action_name: str, data: dict) -> dict:
|
|||||||
payload["job_context"]["move_data"] = bool(payload.get("move_data"))
|
payload["job_context"]["move_data"] = bool(payload.get("move_data"))
|
||||||
if action_name == "remove":
|
if action_name == "remove":
|
||||||
payload["job_context"]["remove_data"] = bool(payload.get("remove_data"))
|
payload["job_context"]["remove_data"] = bool(payload.get("remove_data"))
|
||||||
|
if action_name == "profile_transfer":
|
||||||
|
payload["job_context"]["target_profile_id"] = int(payload.get("target_profile_id") or 0)
|
||||||
|
payload["job_context"]["target_path"] = str(payload.get("target_path") or payload.get("path") or "")
|
||||||
|
payload["job_context"]["move_data"] = bool(payload.get("move_data"))
|
||||||
|
payload["job_context"]["move_data_downgraded"] = bool(payload.get("move_data_downgraded"))
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def _chunk_hashes(hashes: list[str], size: int = MOVE_BULK_MAX_HASHES) -> list[list[str]]:
|
def _chunk_hashes(hashes: list[str], size: int = MOVE_BULK_MAX_HASHES) -> list[list[str]]:
|
||||||
# Note: Splits very large torrent selections into predictable chunks so each queued job stays small and recoverable.
|
|
||||||
safe_size = max(1, int(size or MOVE_BULK_MAX_HASHES))
|
safe_size = max(1, int(size or MOVE_BULK_MAX_HASHES))
|
||||||
return [hashes[index:index + safe_size] for index in range(0, len(hashes), safe_size)]
|
return [hashes[index:index + safe_size] for index in range(0, len(hashes), safe_size)]
|
||||||
|
|
||||||
|
|
||||||
def enqueue_bulk_parts(profile: dict, action_name: str, data: dict) -> list[dict]:
|
def enqueue_bulk_parts(profile: dict, action_name: str, data: dict) -> list[dict]:
|
||||||
# Note: One shared helper splits large move/remove operations into small ordered parts without changing other actions.
|
|
||||||
base_payload = enrich_bulk_payload(profile, action_name, data)
|
base_payload = enrich_bulk_payload(profile, action_name, data)
|
||||||
hashes = base_payload.get("hashes") or []
|
hashes = base_payload.get("hashes") or []
|
||||||
chunks = _chunk_hashes(hashes)
|
chunks = _chunk_hashes(hashes)
|
||||||
@@ -395,17 +304,14 @@ def enqueue_bulk_parts(profile: dict, action_name: str, data: dict) -> list[dict
|
|||||||
|
|
||||||
|
|
||||||
def enqueue_move_bulk_parts(profile: dict, data: dict) -> list[dict]:
|
def enqueue_move_bulk_parts(profile: dict, data: dict) -> list[dict]:
|
||||||
# Note: Keep the old public move helper while using the same partitioning logic.
|
|
||||||
return enqueue_bulk_parts(profile, "move", data)
|
return enqueue_bulk_parts(profile, "move", data)
|
||||||
|
|
||||||
|
|
||||||
def enqueue_remove_bulk_parts(profile: dict, data: dict) -> list[dict]:
|
def enqueue_remove_bulk_parts(profile: dict, data: dict) -> list[dict]:
|
||||||
# Note: Remove/rm uses the same partitioning as move, which lowers rTorrent load.
|
|
||||||
return enqueue_bulk_parts(profile, "remove", data)
|
return enqueue_bulk_parts(profile, "remove", data)
|
||||||
|
|
||||||
|
|
||||||
def _user_disk_status(profile: dict) -> dict:
|
def _user_disk_status(profile: dict) -> dict:
|
||||||
# Note: Disk usage is user-preference aware, so it is read separately from the shared Socket.IO poller.
|
|
||||||
prefs = preferences.get_disk_monitor_preferences(profile.get("id") if profile else None)
|
prefs = preferences.get_disk_monitor_preferences(profile.get("id") if profile else None)
|
||||||
try:
|
try:
|
||||||
paths = json.loads((prefs or {}).get("disk_monitor_paths_json") or "[]") if prefs else []
|
paths = json.loads((prefs or {}).get("disk_monitor_paths_json") or "[]") if prefs else []
|
||||||
@@ -419,6 +325,4 @@ def _user_disk_status(profile: dict) -> dict:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Note: Route modules import shared helpers with wildcard imports; include private helper names intentionally.
|
|
||||||
__all__ = [name for name in globals() if not name.startswith('__')]
|
__all__ = [name for name in globals() if not name.startswith('__')]
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ._shared import bp
|
from ._shared import bp
|
||||||
|
from . import load_api_route_modules
|
||||||
|
|
||||||
# Note: Route modules are imported for their decorators; this keeps the public API unchanged.
|
load_api_route_modules()
|
||||||
from . import torrents as _torrents_routes
|
|
||||||
from . import profiles as _profiles_routes
|
|
||||||
from . import rss as _rss_routes
|
|
||||||
from . import automations as _automations_routes
|
|
||||||
from . import smart_queue as _smart_queue_routes
|
|
||||||
from . import system as _system_routes
|
|
||||||
from . import backup as _backup_routes
|
|
||||||
from . import operation_logs as _operation_logs_routes
|
|
||||||
|
|
||||||
__all__ = ["bp"]
|
__all__ = ["bp"]
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from flask import abort, jsonify, request
|
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, provider as auth_provider, uses_external_provider, external_auth_summary, 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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,89 +1,97 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ._shared import *
|
from ._shared import *
|
||||||
|
|
||||||
|
|
||||||
|
def _automation_user_id() -> int:
|
||||||
|
return int(default_user_id() or 0)
|
||||||
|
|
||||||
|
|
||||||
@bp.get('/automations')
|
@bp.get('/automations')
|
||||||
def automations_get():
|
def automations_get():
|
||||||
from ..services import automation_rules
|
from ..services import automation_rules
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return ok({'rules': [], 'history': [], 'error': 'No profile'})
|
return ok({'rules': [], 'history': [], 'error': 'No profile'})
|
||||||
try:
|
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:
|
except Exception as exc:
|
||||||
return jsonify({'ok': False, 'error': str(exc), 'rules': [], 'history': []}), 500
|
return jsonify({'ok': False, 'error': str(exc), 'rules': [], 'history': []}), 500
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get('/automations/export')
|
@bp.get('/automations/export')
|
||||||
def automations_export():
|
def automations_export():
|
||||||
from ..services import automation_rules
|
from ..services import automation_rules
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||||
try:
|
try:
|
||||||
# Note: JSON export is profile-scoped and excludes execution history/cooldown state.
|
data = automation_rules.export_rules(profile['id'], user_id=_automation_user_id())
|
||||||
data = automation_rules.export_rules(profile['id'])
|
|
||||||
return ok({'export': data, 'count': len(data.get('rules') or [])})
|
return ok({'export': data, 'count': len(data.get('rules') or [])})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return jsonify({'ok': False, 'error': str(exc)}), 400
|
return jsonify({'ok': False, 'error': str(exc)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post('/automations/import')
|
@bp.post('/automations/import')
|
||||||
def automations_import():
|
def automations_import():
|
||||||
from ..services import automation_rules
|
from ..services import automation_rules
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||||
try:
|
try:
|
||||||
payload = request.get_json(silent=True) or {}
|
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
|
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.
|
user_id = _automation_user_id()
|
||||||
imported = automation_rules.import_rules(profile['id'], payload, replace=replace)
|
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'])})
|
return ok({'imported': len(imported), 'rules': automation_rules.list_rules(profile['id'], user_id=user_id)})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return jsonify({'ok': False, 'error': str(exc)}), 400
|
return jsonify({'ok': False, 'error': str(exc)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post('/automations')
|
@bp.post('/automations')
|
||||||
def automations_save():
|
def automations_save():
|
||||||
from ..services import automation_rules
|
from ..services import automation_rules
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||||
try:
|
try:
|
||||||
rule = automation_rules.save_rule(profile['id'], request.get_json(silent=True) or {})
|
user_id = _automation_user_id()
|
||||||
return ok({'rule': rule, 'rules': automation_rules.list_rules(profile['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:
|
except Exception as exc:
|
||||||
return jsonify({'ok': False, 'error': str(exc)}), 400
|
return jsonify({'ok': False, 'error': str(exc)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@bp.delete('/automations/<int:rule_id>')
|
@bp.delete('/automations/<int:rule_id>')
|
||||||
def automations_delete(rule_id: int):
|
def automations_delete(rule_id: int):
|
||||||
from ..services import automation_rules
|
from ..services import automation_rules
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||||
try:
|
try:
|
||||||
automation_rules.delete_rule(rule_id, profile['id'])
|
user_id = _automation_user_id()
|
||||||
return ok({'rules': automation_rules.list_rules(profile['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:
|
except Exception as exc:
|
||||||
return jsonify({'ok': False, 'error': str(exc)}), 400
|
return jsonify({'ok': False, 'error': str(exc)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post('/automations/<int:rule_id>/run')
|
@bp.post('/automations/<int:rule_id>/run')
|
||||||
def automations_run_rule(rule_id: int):
|
def automations_run_rule(rule_id: int):
|
||||||
from ..services import automation_rules
|
from ..services import automation_rules
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||||
try:
|
try:
|
||||||
# Note: Single-rule run ignores disabled state and cooldown for manual troubleshooting.
|
user_id = _automation_user_id()
|
||||||
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'])})
|
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:
|
except Exception as exc:
|
||||||
return jsonify({'ok': False, 'error': str(exc)}), 500
|
return jsonify({'ok': False, 'error': str(exc)}), 500
|
||||||
|
|
||||||
@@ -91,26 +99,29 @@ def automations_run_rule(rule_id: int):
|
|||||||
@bp.post('/automations/check')
|
@bp.post('/automations/check')
|
||||||
def automations_check():
|
def automations_check():
|
||||||
from ..services import automation_rules
|
from ..services import automation_rules
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||||
try:
|
try:
|
||||||
# Note: Force check ignores disabled state and cooldown, allowing a one-off manual automation pass.
|
user_id = _automation_user_id()
|
||||||
return ok({'result': automation_rules.check(profile, force=True), 'rules': automation_rules.list_rules(profile['id']), 'history': automation_rules.list_history(profile['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:
|
except Exception as exc:
|
||||||
return jsonify({'ok': False, 'error': str(exc)}), 500
|
return jsonify({'ok': False, 'error': str(exc)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@bp.delete('/automations/history')
|
@bp.delete('/automations/history')
|
||||||
def automations_history_clear():
|
def automations_history_clear():
|
||||||
from ..services import automation_rules
|
from ..services import automation_rules
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||||
try:
|
try:
|
||||||
# Note: Clear only automation execution logs; rules and cooldown state stay unchanged.
|
user_id = _automation_user_id()
|
||||||
deleted = automation_rules.clear_history(profile['id'])
|
deleted = automation_rules.clear_history(profile['id'], user_id=user_id)
|
||||||
return ok({'deleted': deleted, 'history': automation_rules.list_history(profile['id']), 'cleanup': cleanup_summary()})
|
return ok({'deleted': deleted, 'history': automation_rules.list_history(profile['id'], user_id=user_id), 'cleanup': cleanup_summary()})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return jsonify({'ok': False, 'error': str(exc)}), 500
|
return jsonify({'ok': False, 'error': str(exc)}), 500
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ._shared import *
|
from ._shared import *
|
||||||
from ..services import auth
|
from ..services import auth
|
||||||
|
|
||||||
|
|
||||||
def _active_profile_id() -> int | None:
|
def _active_profile_id(require_write: bool = False) -> int | None:
|
||||||
profile = preferences.active_profile()
|
profile = request_profile(require_write=require_write)
|
||||||
return int(profile["id"]) if profile else None
|
return int(profile["id"]) if profile else None
|
||||||
|
|
||||||
|
|
||||||
@@ -27,7 +26,7 @@ def backup_list():
|
|||||||
@bp.post("/backup/profile")
|
@bp.post("/backup/profile")
|
||||||
def backup_create_profile():
|
def backup_create_profile():
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
pid = _active_profile_id()
|
pid = _active_profile_id(require_write=True)
|
||||||
if not pid:
|
if not pid:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
try:
|
try:
|
||||||
@@ -53,7 +52,6 @@ def backup_create_app():
|
|||||||
|
|
||||||
@bp.post("/backup")
|
@bp.post("/backup")
|
||||||
def backup_create():
|
def backup_create():
|
||||||
# Note: Legacy endpoint now creates a profile backup so non-admin users cannot capture other users' settings.
|
|
||||||
return backup_create_profile()
|
return backup_create_profile()
|
||||||
|
|
||||||
|
|
||||||
@@ -84,7 +82,7 @@ def profile_backup_settings_get():
|
|||||||
@bp.post("/backup/profile/settings")
|
@bp.post("/backup/profile/settings")
|
||||||
def profile_backup_settings_save():
|
def profile_backup_settings_save():
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
pid = _active_profile_id()
|
pid = _active_profile_id(require_write=True)
|
||||||
if not pid:
|
if not pid:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
try:
|
try:
|
||||||
@@ -104,7 +102,7 @@ def backup_preview(backup_id: int):
|
|||||||
@bp.post("/backup/<int:backup_id>/restore")
|
@bp.post("/backup/<int:backup_id>/restore")
|
||||||
def backup_restore(backup_id: int):
|
def backup_restore(backup_id: int):
|
||||||
try:
|
try:
|
||||||
pid = _active_profile_id()
|
pid = _active_profile_id(require_write=True)
|
||||||
return ok({"result": backup_service.restore_backup(backup_id, default_user_id(), profile_id=pid)})
|
return ok({"result": backup_service.restore_backup(backup_id, default_user_id(), profile_id=pid)})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400
|
return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400
|
||||||
|
|||||||
@@ -10,10 +10,8 @@ import zipfile
|
|||||||
from flask import Blueprint, render_template, Response, request, redirect, url_for, abort, send_file, stream_with_context
|
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.preferences import get_preferences, list_profiles, active_profile, get_profile, BOOTSTRAP_THEMES, FONT_FAMILIES
|
||||||
from ..services import auth, pdf_preview_links, rtorrent
|
from ..services import auth, pdf_preview_links, rtorrent
|
||||||
from ..config import PYTORRENT_TMP_DIR
|
from ..config import PYTORRENT_TMP_DIR, SMART_QUEUE_LABEL, SMART_QUEUE_STALLED_LABEL
|
||||||
from ..services.frontend_assets import asset_path
|
from ..services.frontend_assets import asset_path
|
||||||
|
|
||||||
# for favicon
|
|
||||||
from flask import current_app, send_from_directory
|
from flask import current_app, send_from_directory
|
||||||
|
|
||||||
bp = Blueprint("main", __name__)
|
bp = Blueprint("main", __name__)
|
||||||
@@ -24,8 +22,6 @@ def _asset_url(key: str) -> str:
|
|||||||
return path if path.startswith("http") else url_for("static", filename=path)
|
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:
|
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 = Path(download_name or "download.bin").name or "download.bin"
|
||||||
safe_disposition = "inline" if disposition == "inline" else "attachment"
|
safe_disposition = "inline" if disposition == "inline" else "attachment"
|
||||||
@@ -218,6 +214,8 @@ def index():
|
|||||||
auth_provider=auth.provider(),
|
auth_provider=auth.provider(),
|
||||||
external_auth=auth.uses_external_provider(),
|
external_auth=auth.uses_external_provider(),
|
||||||
current_user=auth.current_user(),
|
current_user=auth.current_user(),
|
||||||
|
smart_queue_label=SMART_QUEUE_LABEL,
|
||||||
|
smart_queue_stalled_label=SMART_QUEUE_STALLED_LABEL,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ._shared import *
|
from ._shared import *
|
||||||
from ..services import operation_logs
|
from ..services import operation_logs
|
||||||
|
|
||||||
|
|
||||||
def _active_profile_or_400():
|
def _active_profile_or_400():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return None
|
return None
|
||||||
return profile
|
return profile
|
||||||
@@ -16,7 +15,6 @@ def operation_logs_list():
|
|||||||
profile = _active_profile_or_400()
|
profile = _active_profile_or_400()
|
||||||
if not profile:
|
if not profile:
|
||||||
return ok({"logs": [], "total": 0, "stats": {}, "settings": operation_logs.get_settings(0), "error": "No profile"})
|
return ok({"logs": [], "total": 0, "stats": {}, "settings": operation_logs.get_settings(0), "error": "No profile"})
|
||||||
operation_logs.apply_retention(int(profile["id"]))
|
|
||||||
data = operation_logs.list_logs(
|
data = operation_logs.list_logs(
|
||||||
int(profile["id"]),
|
int(profile["id"]),
|
||||||
limit=int(request.args.get("limit") or 200),
|
limit=int(request.args.get("limit") or 200),
|
||||||
@@ -25,19 +23,32 @@ def operation_logs_list():
|
|||||||
q=str(request.args.get("q") 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"},
|
hide_jobs=str(request.args.get("hide_jobs") or "").lower() in {"1", "true", "yes", "on"},
|
||||||
)
|
)
|
||||||
data["stats"] = operation_logs.stats(int(profile["id"]))
|
data["settings"] = operation_logs.get_settings(int(profile["id"]))
|
||||||
data["settings"] = data["stats"].get("settings")
|
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)
|
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")
|
@bp.post("/operation-logs/settings")
|
||||||
def operation_logs_settings_save():
|
def operation_logs_settings_save():
|
||||||
profile = _active_profile_or_400()
|
profile = _active_profile_or_400()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
settings = operation_logs.save_settings(int(profile["id"]), request.get_json(silent=True) or {})
|
try:
|
||||||
result = operation_logs.apply_retention(int(profile["id"]))
|
settings = operation_logs.save_settings(int(profile["id"]), request.get_json(silent=True) or {})
|
||||||
return ok({"settings": settings, "retention": result})
|
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")
|
@bp.post("/operation-logs/clear")
|
||||||
@@ -54,4 +65,5 @@ def operation_logs_apply_retention():
|
|||||||
profile = _active_profile_or_400()
|
profile = _active_profile_or_400()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
return ok(operation_logs.apply_retention(int(profile["id"])))
|
category = str((request.get_json(silent=True) or {}).get("category") or "all").strip().lower()
|
||||||
|
return ok(operation_logs.apply_retention(int(profile["id"]), category=category))
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request
|
from flask import jsonify, request
|
||||||
|
|
||||||
from ..services import preferences, download_planner, poller_control
|
from ._shared import bp, request_profile
|
||||||
|
from ..services import download_planner, poller_control
|
||||||
from ..services.auth import current_user_id
|
from ..services.auth import current_user_id
|
||||||
|
|
||||||
bp = Blueprint("planner_api", __name__, url_prefix="/api")
|
|
||||||
|
|
||||||
|
|
||||||
def ok(payload=None):
|
def ok(payload=None):
|
||||||
data = {"ok": True}
|
data = {"ok": True}
|
||||||
if payload:
|
if payload:
|
||||||
@@ -16,7 +14,7 @@ def ok(payload=None):
|
|||||||
|
|
||||||
|
|
||||||
def _profile_or_error():
|
def _profile_or_error():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return None, (jsonify({"ok": False, "error": "No profile"}), 400)
|
return None, (jsonify({"ok": False, "error": "No profile"}), 400)
|
||||||
return profile, None
|
return profile, None
|
||||||
@@ -32,6 +30,7 @@ def download_planner_get():
|
|||||||
|
|
||||||
@bp.post("/download-planner")
|
@bp.post("/download-planner")
|
||||||
def download_planner_save():
|
def download_planner_save():
|
||||||
|
# Note: Planner settings are saved through one canonical endpoint to keep the frontend/backend contract explicit.
|
||||||
profile, error = _profile_or_error()
|
profile, error = _profile_or_error()
|
||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
@@ -95,7 +94,8 @@ def poller_settings_get():
|
|||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
pid = int(profile["id"])
|
pid = int(profile["id"])
|
||||||
return ok({"settings": poller_control.get_settings(pid), "runtime": poller_control.snapshot(pid)})
|
settings = poller_control.get_settings(pid)
|
||||||
|
return ok({"settings": settings, "runtime": poller_control.snapshot(pid, settings)})
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/poller/settings")
|
@bp.post("/poller/settings")
|
||||||
|
|||||||
+104
-24
@@ -1,11 +1,29 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ._shared import *
|
from ._shared import *
|
||||||
from ..services.rtorrent.diagnostics import profile_diagnostics
|
from ..services.rtorrent.diagnostics import profile_diagnostics
|
||||||
|
from ..services import auth
|
||||||
|
from ..utils import human_size
|
||||||
|
|
||||||
@bp.get("/profiles")
|
@bp.get("/profiles")
|
||||||
def profiles_list():
|
def profiles_list():
|
||||||
return ok({"profiles": preferences.list_profiles(), "active": preferences.active_profile()})
|
profiles = []
|
||||||
|
for row in preferences.list_profiles():
|
||||||
|
item = dict(row)
|
||||||
|
# Note: Frontend actions can hide write-only operations without trusting this flag; backend still enforces permissions.
|
||||||
|
item["can_write"] = auth.can_write_profile(int(item.get("id") or 0), auth.current_user_id() or default_user_id())
|
||||||
|
stats = preferences.get_profile_runtime_stats(int(item.get("id") or 0))
|
||||||
|
if stats:
|
||||||
|
stats["total_size_h"] = human_size(stats.get("total_size_bytes"))
|
||||||
|
stats["completed_h"] = human_size(stats.get("completed_bytes"))
|
||||||
|
stats["downloaded_h"] = human_size(stats.get("downloaded_bytes"))
|
||||||
|
stats["uploaded_h"] = human_size(stats.get("uploaded_bytes"))
|
||||||
|
item["runtime_stats"] = stats
|
||||||
|
settings = backup_service.get_auto_backup_settings(default_user_id(), "profile", int(item.get("id") or 0))
|
||||||
|
item["profile_backup_enabled"] = bool(settings.get("enabled"))
|
||||||
|
item["profile_backup_interval_hours"] = settings.get("interval_hours")
|
||||||
|
item["profile_backup_retention_days"] = settings.get("retention_days")
|
||||||
|
profiles.append(item)
|
||||||
|
return ok({"profiles": profiles, "active": preferences.active_profile()})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -17,7 +35,6 @@ def profiles_create():
|
|||||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@bp.put("/profiles/<int:profile_id>")
|
@bp.put("/profiles/<int:profile_id>")
|
||||||
def profiles_update(profile_id: int):
|
def profiles_update(profile_id: int):
|
||||||
try:
|
try:
|
||||||
@@ -37,7 +54,17 @@ def profiles_delete(profile_id: int):
|
|||||||
@bp.post("/profiles/<int:profile_id>/activate")
|
@bp.post("/profiles/<int:profile_id>/activate")
|
||||||
def profiles_activate(profile_id: int):
|
def profiles_activate(profile_id: int):
|
||||||
try:
|
try:
|
||||||
return ok({"profile": preferences.activate_profile(profile_id)})
|
profile = preferences.activate_profile(profile_id)
|
||||||
|
stats_error = ""
|
||||||
|
try:
|
||||||
|
# Note: Profile overview metrics are cached only on user-initiated profile switch, not on every profile list render.
|
||||||
|
preferences.save_profile_runtime_stats(profile, rtorrent.list_torrents(profile), user_id=auth.current_user_id() or default_user_id())
|
||||||
|
except Exception as exc:
|
||||||
|
stats_error = str(exc)
|
||||||
|
response = {"profile": profile}
|
||||||
|
if stats_error:
|
||||||
|
response["stats_error"] = stats_error
|
||||||
|
return ok(response)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return jsonify({"ok": False, "error": str(exc)}), 404
|
return jsonify({"ok": False, "error": str(exc)}), 404
|
||||||
|
|
||||||
@@ -88,93 +115,146 @@ def profiles_import():
|
|||||||
|
|
||||||
@bp.get("/preferences")
|
@bp.get("/preferences")
|
||||||
def prefs_get():
|
def prefs_get():
|
||||||
return ok({"preferences": preferences.get_preferences()})
|
return ok({"preferences": preferences.get_preferences(profile_id=request_profile_id())})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/preferences")
|
@bp.post("/preferences")
|
||||||
def prefs_save():
|
def prefs_save():
|
||||||
return ok({"preferences": preferences.save_preferences(request.json or {})})
|
return ok({"preferences": preferences.save_preferences(request.json or {}, profile_id=request_profile_id(require_write=True))})
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/preferences/table-columns/recommended")
|
@bp.post("/preferences/table-columns/recommended")
|
||||||
def prefs_table_columns_recommended():
|
def prefs_table_columns_recommended():
|
||||||
# Note: Applies the backend-owned recommended desktop and mobile column layout.
|
# Note: Applies the backend-owned recommended desktop and mobile column layout.
|
||||||
return ok({"preferences": preferences.apply_recommended_table_columns()})
|
return ok({"preferences": preferences.apply_recommended_table_columns(profile_id=request_profile_id(require_write=True))})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/labels")
|
@bp.get("/labels")
|
||||||
def labels_list():
|
def labels_list():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
pid = profile["id"] if profile else None
|
pid = profile["id"] if profile else None
|
||||||
|
if not pid:
|
||||||
|
return ok({"labels": []})
|
||||||
with connect() as conn:
|
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})
|
return ok({"labels": rows})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/labels")
|
@bp.post("/labels")
|
||||||
def labels_save():
|
def labels_save():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
name = str(data.get("name") or "").strip()
|
name = str(data.get("name") or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
return jsonify({"ok": False, "error": "Missing label name"}), 400
|
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()
|
now = utcnow()
|
||||||
with connect() as conn:
|
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()
|
return labels_list()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@bp.delete("/labels/<int:label_id>")
|
@bp.delete("/labels/<int:label_id>")
|
||||||
def labels_delete(label_id: int):
|
def labels_delete(label_id: int):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
pid = profile["id"] if profile else None
|
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:
|
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()
|
return labels_list()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/ratio-groups")
|
@bp.get("/ratio-groups")
|
||||||
def ratio_groups_list():
|
def ratio_groups_list():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
pid = profile["id"] if profile else None
|
pid = profile["id"] if profile else None
|
||||||
with connect() as conn:
|
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()
|
rows = conn.execute(
|
||||||
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 []
|
"""
|
||||||
|
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})
|
return ok({"groups": rows, "history": history})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/ratio-groups")
|
@bp.post("/ratio-groups")
|
||||||
def ratio_groups_save():
|
def ratio_groups_save():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
name = str(data.get("name") or "").strip()
|
name = str(data.get("name") or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
return jsonify({"ok": False, "error": "Missing group name"}), 400
|
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()
|
now = utcnow()
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
conn.execute(
|
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()
|
||||||
"""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 = (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)
|
||||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
if existing:
|
||||||
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""",
|
conn.execute(
|
||||||
(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),
|
"""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()
|
return ratio_groups_list()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@bp.delete("/ratio-groups/<int:group_id>")
|
||||||
|
def ratio_groups_delete(group_id: int):
|
||||||
|
profile = request_profile()
|
||||||
|
if not profile:
|
||||||
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
|
if not auth.can_write_profile(int(profile["id"]), default_user_id()):
|
||||||
|
return jsonify({"ok": False, "error": "No write access to profile"}), 403
|
||||||
|
with connect() as conn:
|
||||||
|
# Note: Deleting a ratio group removes only the group definition and its assignment links; history stays as an audit trail.
|
||||||
|
deleted = conn.execute("DELETE FROM ratio_groups WHERE id=? AND profile_id=?", (int(group_id), int(profile["id"]))).rowcount
|
||||||
|
conn.execute("DELETE FROM ratio_assignments WHERE group_id=? AND profile_id=?", (int(group_id), int(profile["id"])))
|
||||||
|
if not deleted:
|
||||||
|
return jsonify({"ok": False, "error": "Ratio group not found"}), 404
|
||||||
|
return ratio_groups_list()
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/ratio-groups/check")
|
@bp.post("/ratio-groups/check")
|
||||||
def ratio_groups_check():
|
def ratio_groups_check():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
return ok({"result": ratio_rules.check(profile, default_user_id())})
|
return ok({"result": ratio_rules.check(profile, default_user_id())})
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ._shared import *
|
from ._shared import *
|
||||||
|
|
||||||
|
|
||||||
def _active_profile_or_400():
|
def _active_profile_or_400():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return None
|
return None
|
||||||
return profile
|
return profile
|
||||||
@@ -117,7 +115,7 @@ def rss_rule_test():
|
|||||||
|
|
||||||
@bp.post("/rss/check")
|
@bp.post("/rss/check")
|
||||||
def rss_check():
|
def rss_check():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
return ok(rss_service.check(profile, only_due=False))
|
return ok(rss_service.check(profile, only_due=False))
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ._shared import *
|
from ._shared import *
|
||||||
|
|
||||||
|
|
||||||
@bp.get('/smart-queue')
|
@bp.get('/smart-queue')
|
||||||
def smart_queue_get():
|
def smart_queue_get():
|
||||||
from ..services import smart_queue
|
from ..services import smart_queue
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return ok({'settings': {}, 'exclusions': [], 'error': 'No profile'})
|
return ok({'settings': {}, 'exclusions': [], 'error': 'No profile'})
|
||||||
try:
|
try:
|
||||||
@@ -19,11 +19,10 @@ def smart_queue_get():
|
|||||||
return jsonify({'ok': False, 'error': str(exc), 'settings': {}, 'exclusions': []})
|
return jsonify({'ok': False, 'error': str(exc), 'settings': {}, 'exclusions': []})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post('/smart-queue')
|
@bp.post('/smart-queue')
|
||||||
def smart_queue_save():
|
def smart_queue_save():
|
||||||
from ..services import smart_queue
|
from ..services import smart_queue
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return ok({'settings': {}, 'error': 'No profile'})
|
return ok({'settings': {}, 'error': 'No profile'})
|
||||||
try:
|
try:
|
||||||
@@ -37,7 +36,7 @@ def smart_queue_save():
|
|||||||
|
|
||||||
@bp.post('/smart-queue/check')
|
@bp.post('/smart-queue/check')
|
||||||
def smart_queue_check():
|
def smart_queue_check():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return ok({'result': {'ok': False, 'error': 'No profile'}})
|
return ok({'result': {'ok': False, 'error': 'No profile'}})
|
||||||
if str(request.args.get('sync') or '').lower() in {'1', 'true', 'yes'}:
|
if str(request.args.get('sync') or '').lower() in {'1', 'true', 'yes'}:
|
||||||
@@ -66,7 +65,7 @@ def smart_queue_check():
|
|||||||
@bp.post('/smart-queue/exclusion')
|
@bp.post('/smart-queue/exclusion')
|
||||||
def smart_queue_exclusion():
|
def smart_queue_exclusion():
|
||||||
from ..services import smart_queue
|
from ..services import smart_queue
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
@@ -79,7 +78,7 @@ def smart_queue_exclusion():
|
|||||||
@bp.delete('/smart-queue/history')
|
@bp.delete('/smart-queue/history')
|
||||||
def smart_queue_history_clear():
|
def smart_queue_history_clear():
|
||||||
from ..services import smart_queue
|
from ..services import smart_queue
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||||
try:
|
try:
|
||||||
|
|||||||
+115
-23
@@ -1,12 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ._shared import *
|
from ._shared import *
|
||||||
|
import posixpath
|
||||||
from ..services import operation_logs
|
from ..services import operation_logs
|
||||||
from ..services.frontend_assets import static_hash
|
from ..services.frontend_assets import static_hash
|
||||||
|
|
||||||
@bp.get("/system/disk")
|
@bp.get("/system/disk")
|
||||||
def system_disk():
|
def system_disk():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"})
|
return jsonify({"ok": False, "error": "No profile"})
|
||||||
try:
|
try:
|
||||||
@@ -18,7 +18,7 @@ def system_disk():
|
|||||||
|
|
||||||
@bp.get("/system/status")
|
@bp.get("/system/status")
|
||||||
def system_status():
|
def system_status():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"})
|
return jsonify({"ok": False, "error": "No profile"})
|
||||||
try:
|
try:
|
||||||
@@ -26,7 +26,6 @@ def system_status():
|
|||||||
status["disk"] = _user_disk_status(profile)
|
status["disk"] = _user_disk_status(profile)
|
||||||
if bool(profile.get("is_remote")):
|
if bool(profile.get("is_remote")):
|
||||||
try:
|
try:
|
||||||
# Note: Remote profiles must report CPU/RAM from the rTorrent host, not hide the footer stats.
|
|
||||||
usage = rtorrent.remote_system_usage(profile)
|
usage = rtorrent.remote_system_usage(profile)
|
||||||
status.update(usage)
|
status.update(usage)
|
||||||
status["usage_available"] = True
|
status["usage_available"] = True
|
||||||
@@ -39,7 +38,6 @@ def system_status():
|
|||||||
status["ram"] = psutil.virtual_memory().percent
|
status["ram"] = psutil.virtual_memory().percent
|
||||||
status["usage_source"] = "local"
|
status["usage_source"] = "local"
|
||||||
status["usage_available"] = True
|
status["usage_available"] = True
|
||||||
# Note: REST status returns the latest records without waiting for the next Socket.IO message.
|
|
||||||
status["speed_peaks"] = speed_peaks.record(profile["id"], status.get("down_rate", 0), status.get("up_rate", 0))
|
status["speed_peaks"] = speed_peaks.record(profile["id"], status.get("down_rate", 0), status.get("up_rate", 0))
|
||||||
return ok({"status": status})
|
return ok({"status": status})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -79,13 +77,14 @@ def health_check_nagios():
|
|||||||
@bp.get("/app/status")
|
@bp.get("/app/status")
|
||||||
def app_status():
|
def app_status():
|
||||||
started = time.perf_counter()
|
started = time.perf_counter()
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
proc = psutil.Process(os.getpid())
|
proc = psutil.Process(os.getpid())
|
||||||
try:
|
try:
|
||||||
jobs = list_jobs(10, 0)
|
jobs = list_jobs(10, 0)
|
||||||
jobs_total = jobs.get("total", 0)
|
jobs_total = jobs.get("total", 0)
|
||||||
except Exception:
|
except Exception:
|
||||||
jobs_total = 0
|
jobs_total = 0
|
||||||
|
include_cleanup = str(request.args.get("cleanup") or "").lower() in {"1", "true", "yes", "on"}
|
||||||
status = {
|
status = {
|
||||||
"pytorrent": {
|
"pytorrent": {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
@@ -103,10 +102,11 @@ def app_status():
|
|||||||
"open_files": _safe_len(proc.open_files) if hasattr(proc, "open_files") else None,
|
"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,
|
"connections": _safe_len(lambda: proc.net_connections(kind="inet")) if hasattr(proc, "net_connections") else None,
|
||||||
},
|
},
|
||||||
"cleanup": cleanup_summary(),
|
|
||||||
"profile": profile,
|
"profile": profile,
|
||||||
"scgi": None,
|
"scgi": None,
|
||||||
}
|
}
|
||||||
|
if include_cleanup:
|
||||||
|
status["cleanup"] = cleanup_summary()
|
||||||
if profile:
|
if profile:
|
||||||
try:
|
try:
|
||||||
status["scgi"] = rtorrent.scgi_diagnostics(profile)
|
status["scgi"] = rtorrent.scgi_diagnostics(profile)
|
||||||
@@ -117,11 +117,22 @@ def app_status():
|
|||||||
status["speed_peaks"] = speed_peaks.current(profile["id"])
|
status["speed_peaks"] = speed_peaks.current(profile["id"])
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
status["speed_peaks"] = {"error": str(exc)}
|
status["speed_peaks"] = {"error": str(exc)}
|
||||||
|
try:
|
||||||
|
# Note: App status carries poller settings and runtime so the panel still renders when the separate poller endpoint is unavailable.
|
||||||
|
poller_settings = poller_control.get_settings(int(profile["id"]))
|
||||||
|
status["poller"] = {"settings": poller_settings, "runtime": poller_control.snapshot(int(profile["id"]), poller_settings)}
|
||||||
|
except Exception as exc:
|
||||||
|
status["poller"] = {"settings": {}, "runtime": {}, "error": str(exc)}
|
||||||
try:
|
try:
|
||||||
prefs = preferences.get_preferences()
|
prefs = preferences.get_preferences()
|
||||||
status["port_check"] = {"status": "disabled", "enabled": False} if not bool((prefs or {}).get("port_check_enabled")) else port_check_status(force=False)
|
status["port_check"] = {"status": "disabled", "enabled": False} if not bool((prefs or {}).get("port_check_enabled")) else port_check_status(force=False)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
status["port_check"] = {"status": "error", "error": str(exc)}
|
status["port_check"] = {"status": "error", "error": str(exc)}
|
||||||
|
try:
|
||||||
|
from ..services import background_cache_warmup
|
||||||
|
status["background_cache_warmup"] = background_cache_warmup.status()
|
||||||
|
except Exception as exc:
|
||||||
|
status["background_cache_warmup"] = {"started": False, "error": str(exc)}
|
||||||
status["api_ms"] = round((time.perf_counter() - started) * 1000, 2)
|
status["api_ms"] = round((time.perf_counter() - started) * 1000, 2)
|
||||||
return ok({"status": status})
|
return ok({"status": status})
|
||||||
|
|
||||||
@@ -170,7 +181,7 @@ def cleanup_status():
|
|||||||
|
|
||||||
@bp.post("/cleanup/cache")
|
@bp.post("/cleanup/cache")
|
||||||
def cleanup_profile_cache():
|
def cleanup_profile_cache():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
profile_id = int(profile["id"])
|
profile_id = int(profile["id"])
|
||||||
@@ -203,10 +214,21 @@ def cleanup_jobs():
|
|||||||
return ok({"deleted": deleted, "cleanup": cleanup_summary()})
|
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")
|
@bp.post("/cleanup/smart-queue")
|
||||||
def cleanup_smart_queue():
|
def cleanup_smart_queue():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
profile_id = int(profile["id"])
|
profile_id = int(profile["id"])
|
||||||
@@ -224,7 +246,7 @@ def cleanup_smart_queue():
|
|||||||
|
|
||||||
@bp.post("/cleanup/operation-logs")
|
@bp.post("/cleanup/operation-logs")
|
||||||
def cleanup_operation_logs():
|
def cleanup_operation_logs():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
# Note: Operation log cleanup removes only profile-scoped log entries; torrents, jobs and settings stay intact.
|
# Note: Operation log cleanup removes only profile-scoped log entries; torrents, jobs and settings stay intact.
|
||||||
@@ -235,7 +257,7 @@ def cleanup_operation_logs():
|
|||||||
|
|
||||||
@bp.post("/cleanup/planner")
|
@bp.post("/cleanup/planner")
|
||||||
def cleanup_planner():
|
def cleanup_planner():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
# Note: Planner cleanup removes only the active profile action history, not saved Planner settings.
|
# Note: Planner cleanup removes only the active profile action history, not saved Planner settings.
|
||||||
@@ -245,7 +267,7 @@ def cleanup_planner():
|
|||||||
|
|
||||||
@bp.post("/cleanup/automations")
|
@bp.post("/cleanup/automations")
|
||||||
def cleanup_automations():
|
def cleanup_automations():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
profile_id = int(profile["id"])
|
profile_id = int(profile["id"])
|
||||||
@@ -254,7 +276,7 @@ def cleanup_automations():
|
|||||||
if not exists:
|
if not exists:
|
||||||
deleted = 0
|
deleted = 0
|
||||||
else:
|
else:
|
||||||
# Note: Cleanup panel removes only active profile automation logs, not saved automation rules.
|
# 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,))
|
cur = conn.execute("DELETE FROM automation_history WHERE profile_id=?", (profile_id,))
|
||||||
deleted = int(cur.rowcount or 0)
|
deleted = int(cur.rowcount or 0)
|
||||||
return ok({"deleted": deleted, "cleanup": cleanup_summary()})
|
return ok({"deleted": deleted, "cleanup": cleanup_summary()})
|
||||||
@@ -265,7 +287,7 @@ def cleanup_automations():
|
|||||||
|
|
||||||
@bp.post("/cleanup/poller-diagnostics")
|
@bp.post("/cleanup/poller-diagnostics")
|
||||||
def cleanup_poller_diagnostics():
|
def cleanup_poller_diagnostics():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
profile_id = int(profile["id"])
|
profile_id = int(profile["id"])
|
||||||
@@ -276,7 +298,7 @@ def cleanup_poller_diagnostics():
|
|||||||
@bp.post("/cleanup/all")
|
@bp.post("/cleanup/all")
|
||||||
def cleanup_all():
|
def cleanup_all():
|
||||||
deleted_jobs = clear_jobs()
|
deleted_jobs = clear_jobs()
|
||||||
active_profile = preferences.active_profile()
|
active_profile = request_profile()
|
||||||
active_profile_id = int(active_profile["id"]) if active_profile else 0
|
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_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
|
deleted_planner = download_planner.clear_history(active_profile_id) if active_profile_id else 0
|
||||||
@@ -291,6 +313,7 @@ def cleanup_all():
|
|||||||
if not exists_auto:
|
if not exists_auto:
|
||||||
deleted_auto = 0
|
deleted_auto = 0
|
||||||
else:
|
else:
|
||||||
|
# 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,))
|
cur = conn.execute("DELETE FROM automation_history WHERE profile_id=?", (active_profile_id,))
|
||||||
deleted_auto = int(cur.rowcount or 0)
|
deleted_auto = int(cur.rowcount or 0)
|
||||||
return ok({"deleted": {"jobs": deleted_jobs, "smart_queue_history": deleted_smart, "operation_logs": deleted_logs, "planner_history": deleted_planner, "automation_history": deleted_auto}, "cleanup": cleanup_summary()})
|
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()})
|
||||||
@@ -323,9 +346,47 @@ def jobs_retry(job_id: str):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_path_contains(base: str, candidate: str) -> bool:
|
||||||
|
base = posixpath.normpath(str(base or "").rstrip("/") or "/")
|
||||||
|
candidate = posixpath.normpath(str(candidate or "").rstrip("/") or "/")
|
||||||
|
return candidate == base or candidate.startswith(base.rstrip("/") + "/")
|
||||||
|
|
||||||
|
|
||||||
|
def _path_has_cached_torrents(profile_id: int, path: str) -> bool:
|
||||||
|
# Note: The cache check prevents renaming folders that are currently known as torrent locations.
|
||||||
|
if not str(path or "").strip():
|
||||||
|
return False
|
||||||
|
return any(_remote_path_contains(path, item.get("path") or "") for item in torrent_cache.snapshot(profile_id))
|
||||||
|
|
||||||
|
|
||||||
|
def _annotate_path_directories(profile: dict, payload: dict) -> dict:
|
||||||
|
dirs = payload.get("dirs") or []
|
||||||
|
for item in dirs:
|
||||||
|
item_path = item.get("path") or ""
|
||||||
|
has_torrents = _path_has_cached_torrents(int(profile.get("id") or 0), item_path)
|
||||||
|
is_empty = bool(item.get("empty"))
|
||||||
|
item["has_torrents"] = has_torrents
|
||||||
|
item["can_rename"] = is_empty and not has_torrents
|
||||||
|
# Note: The picker exposes a short reason so disabled rename buttons explain the safety rule.
|
||||||
|
item["rename_reason"] = "Rename folder" if item["can_rename"] else ("Folder contains a known torrent path" if has_torrents else "Only empty folders can be renamed")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _path_profile_from_request(*, require_write_access: bool = False):
|
||||||
|
profile_id = 0
|
||||||
|
try:
|
||||||
|
profile_id = int((request.args.get("profile_id") if request.method == "GET" else (request.get_json(silent=True) or {}).get("profile_id")) or 0)
|
||||||
|
except Exception:
|
||||||
|
profile_id = 0
|
||||||
|
profile = preferences.get_profile(profile_id, auth.current_user_id() or default_user_id()) if profile_id else request_profile()
|
||||||
|
if profile and require_write_access:
|
||||||
|
require_profile_write(profile.get("id"))
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/path/default")
|
@bp.get("/path/default")
|
||||||
def path_default():
|
def path_default():
|
||||||
profile = preferences.active_profile()
|
profile = _path_profile_from_request()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
try:
|
try:
|
||||||
@@ -337,12 +398,43 @@ def path_default():
|
|||||||
|
|
||||||
@bp.get("/path/browse")
|
@bp.get("/path/browse")
|
||||||
def path_browse():
|
def path_browse():
|
||||||
profile = preferences.active_profile()
|
profile = _path_profile_from_request()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
base = request.args.get("path") or ""
|
base = request.args.get("path") or ""
|
||||||
try:
|
try:
|
||||||
return ok(rtorrent.browse_path(profile, base))
|
return ok(_annotate_path_directories(profile, rtorrent.browse_path(profile, base)))
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/path/directories")
|
||||||
|
def path_directory_create():
|
||||||
|
profile = _path_profile_from_request(require_write_access=True)
|
||||||
|
if not profile:
|
||||||
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
try:
|
||||||
|
# Note: This endpoint only creates an empty directory and does not alter any torrent state.
|
||||||
|
result = rtorrent.create_directory(profile, data.get("parent") or "", data.get("name") or "")
|
||||||
|
return ok({"directory": result})
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/path/directories/rename")
|
||||||
|
def path_directory_rename():
|
||||||
|
profile = _path_profile_from_request(require_write_access=True)
|
||||||
|
if not profile:
|
||||||
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
path = str(data.get("path") or "").strip()
|
||||||
|
if _path_has_cached_torrents(int(profile.get("id") or 0), path):
|
||||||
|
return jsonify({"ok": False, "error": "Directory contains a known torrent path"}), 400
|
||||||
|
try:
|
||||||
|
# Note: The service also verifies that the remote directory is empty before renaming.
|
||||||
|
result = rtorrent.rename_empty_directory(profile, path, data.get("new_name") or "")
|
||||||
|
return ok({"directory": result})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||||
|
|
||||||
@@ -350,7 +442,7 @@ def path_browse():
|
|||||||
|
|
||||||
@bp.get('/rtorrent-config')
|
@bp.get('/rtorrent-config')
|
||||||
def rtorrent_config_get():
|
def rtorrent_config_get():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||||
try:
|
try:
|
||||||
@@ -361,7 +453,7 @@ def rtorrent_config_get():
|
|||||||
|
|
||||||
@bp.post('/rtorrent-config')
|
@bp.post('/rtorrent-config')
|
||||||
def rtorrent_config_save():
|
def rtorrent_config_save():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||||
try:
|
try:
|
||||||
@@ -378,7 +470,7 @@ def rtorrent_config_save():
|
|||||||
|
|
||||||
@bp.post('/rtorrent-config/reset')
|
@bp.post('/rtorrent-config/reset')
|
||||||
def rtorrent_config_reset():
|
def rtorrent_config_reset():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||||
try:
|
try:
|
||||||
@@ -389,7 +481,7 @@ def rtorrent_config_reset():
|
|||||||
|
|
||||||
@bp.post('/rtorrent-config/generate')
|
@bp.post('/rtorrent-config/generate')
|
||||||
def rtorrent_config_generate():
|
def rtorrent_config_generate():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||||
try:
|
try:
|
||||||
@@ -402,7 +494,7 @@ def rtorrent_config_generate():
|
|||||||
@bp.get('/traffic/history')
|
@bp.get('/traffic/history')
|
||||||
def traffic_history_get():
|
def traffic_history_get():
|
||||||
from ..services import traffic_history
|
from ..services import traffic_history
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return ok({'history': {'range': request.args.get('range') or '7d', 'bucket': 'day', 'rows': []}})
|
return ok({'history': {'range': request.args.get('range') or '7d', 'bucket': 'day', 'rows': []}})
|
||||||
range_name = request.args.get('range') or '7d'
|
range_name = request.args.get('range') or '7d'
|
||||||
|
|||||||
+174
-33
@@ -1,12 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ._shared import *
|
from ._shared import *
|
||||||
|
import json
|
||||||
|
import posixpath
|
||||||
|
from ..services import profile_speed_limits
|
||||||
from ..services import pdf_preview_links, torrent_creator
|
from ..services import pdf_preview_links, torrent_creator
|
||||||
from ..services.reverse_dns import attach_reverse_dns
|
from ..services.reverse_dns import attach_reverse_dns
|
||||||
|
|
||||||
@bp.get("/torrents")
|
@bp.get("/torrents")
|
||||||
def torrents():
|
def torrents():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return ok({"torrents": [], "summary": cached_summary(0, []), "error": "No rTorrent profile"})
|
return ok({"torrents": [], "summary": cached_summary(0, []), "error": "No rTorrent profile"})
|
||||||
rows = torrent_cache.snapshot(profile["id"])
|
rows = torrent_cache.snapshot(profile["id"])
|
||||||
@@ -19,10 +21,9 @@ def torrents():
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/trackers/summary")
|
@bp.get("/trackers/summary")
|
||||||
def trackers_summary():
|
def trackers_summary():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return ok({"summary": {"hashes": {}, "trackers": [], "errors": [], "scanned": 0, "pending": 0}, "error": "No profile"})
|
return ok({"summary": {"hashes": {}, "trackers": [], "errors": [], "scanned": 0, "pending": 0}, "error": "No profile"})
|
||||||
try:
|
try:
|
||||||
@@ -77,7 +78,7 @@ def tracker_favicon_query():
|
|||||||
|
|
||||||
@bp.get("/torrent-stats")
|
@bp.get("/torrent-stats")
|
||||||
def torrent_stats_get():
|
def torrent_stats_get():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return ok({"stats": {}, "error": "No profile"})
|
return ok({"stats": {}, "error": "No profile"})
|
||||||
force = str(request.args.get("force") or "").lower() in {"1", "true", "yes"}
|
force = str(request.args.get("force") or "").lower() in {"1", "true", "yes"}
|
||||||
@@ -91,7 +92,7 @@ def torrent_stats_get():
|
|||||||
|
|
||||||
@bp.get("/torrents/<torrent_hash>/files")
|
@bp.get("/torrents/<torrent_hash>/files")
|
||||||
def torrent_files(torrent_hash: str):
|
def torrent_files(torrent_hash: str):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
return ok({"files": rtorrent.torrent_files(profile, torrent_hash)})
|
return ok({"files": rtorrent.torrent_files(profile, torrent_hash)})
|
||||||
@@ -100,7 +101,7 @@ def torrent_files(torrent_hash: str):
|
|||||||
|
|
||||||
@bp.get("/torrents/<torrent_hash>/files/<int:file_index>/mediainfo")
|
@bp.get("/torrents/<torrent_hash>/files/<int:file_index>/mediainfo")
|
||||||
def torrent_file_media_info(torrent_hash: str, file_index: int):
|
def torrent_file_media_info(torrent_hash: str, file_index: int):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
try:
|
try:
|
||||||
@@ -123,7 +124,7 @@ def torrent_file_media_info(torrent_hash: str, file_index: int):
|
|||||||
|
|
||||||
@bp.post("/torrents/<torrent_hash>/files/priority")
|
@bp.post("/torrents/<torrent_hash>/files/priority")
|
||||||
def torrent_file_priority(torrent_hash: str):
|
def torrent_file_priority(torrent_hash: str):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
@@ -138,7 +139,7 @@ def torrent_file_priority(torrent_hash: str):
|
|||||||
|
|
||||||
@bp.get("/torrents/<torrent_hash>/files/tree")
|
@bp.get("/torrents/<torrent_hash>/files/tree")
|
||||||
def torrent_file_tree(torrent_hash: str):
|
def torrent_file_tree(torrent_hash: str):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
return ok({"tree": rtorrent.torrent_file_tree(profile, torrent_hash)})
|
return ok({"tree": rtorrent.torrent_file_tree(profile, torrent_hash)})
|
||||||
@@ -147,7 +148,7 @@ def torrent_file_tree(torrent_hash: str):
|
|||||||
|
|
||||||
@bp.post("/torrents/<torrent_hash>/files/folder-priority")
|
@bp.post("/torrents/<torrent_hash>/files/folder-priority")
|
||||||
def torrent_folder_priority(torrent_hash: str):
|
def torrent_folder_priority(torrent_hash: str):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
@@ -213,7 +214,7 @@ def _send_staged_file(profile: dict, path: str, download_name: str, local: bool
|
|||||||
|
|
||||||
@bp.post("/torrents/<torrent_hash>/files/<int:file_index>/download-link")
|
@bp.post("/torrents/<torrent_hash>/files/<int:file_index>/download-link")
|
||||||
def torrent_file_download_link(torrent_hash: str, file_index: int):
|
def torrent_file_download_link(torrent_hash: str, file_index: int):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
try:
|
try:
|
||||||
@@ -237,7 +238,7 @@ def torrent_file_download_link_from_body(torrent_hash: str):
|
|||||||
|
|
||||||
@bp.post("/torrents/<torrent_hash>/files/download.zip/link")
|
@bp.post("/torrents/<torrent_hash>/files/download.zip/link")
|
||||||
def torrent_files_download_zip_link(torrent_hash: str):
|
def torrent_files_download_zip_link(torrent_hash: str):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
@@ -253,7 +254,7 @@ def torrent_files_download_zip_link(torrent_hash: str):
|
|||||||
|
|
||||||
@bp.get("/torrents/<torrent_hash>/torrent-file/link")
|
@bp.get("/torrents/<torrent_hash>/torrent-file/link")
|
||||||
def torrent_file_export_link(torrent_hash: str):
|
def torrent_file_export_link(torrent_hash: str):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
try:
|
try:
|
||||||
@@ -266,7 +267,7 @@ def torrent_file_export_link(torrent_hash: str):
|
|||||||
|
|
||||||
@bp.post("/torrents/torrent-files.zip/link")
|
@bp.post("/torrents/torrent-files.zip/link")
|
||||||
def torrent_files_export_zip_link():
|
def torrent_files_export_zip_link():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
@@ -283,7 +284,7 @@ def torrent_files_export_zip_link():
|
|||||||
|
|
||||||
@bp.get("/torrents/<torrent_hash>/files/<int:file_index>/download")
|
@bp.get("/torrents/<torrent_hash>/files/<int:file_index>/download")
|
||||||
def torrent_file_download(torrent_hash: str, file_index: int):
|
def torrent_file_download(torrent_hash: str, file_index: int):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
try:
|
try:
|
||||||
@@ -376,7 +377,7 @@ def _stream_torrent_files_zip(profile: dict, items: list[dict]):
|
|||||||
|
|
||||||
@bp.post("/torrents/<torrent_hash>/files/download.zip")
|
@bp.post("/torrents/<torrent_hash>/files/download.zip")
|
||||||
def torrent_files_download_zip(torrent_hash: str):
|
def torrent_files_download_zip(torrent_hash: str):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
@@ -392,7 +393,7 @@ def torrent_files_download_zip(torrent_hash: str):
|
|||||||
|
|
||||||
@bp.get("/torrents/<torrent_hash>/torrent-file")
|
@bp.get("/torrents/<torrent_hash>/torrent-file")
|
||||||
def torrent_file_export(torrent_hash: str):
|
def torrent_file_export(torrent_hash: str):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
try:
|
try:
|
||||||
@@ -405,7 +406,7 @@ def torrent_file_export(torrent_hash: str):
|
|||||||
|
|
||||||
@bp.post("/torrents/torrent-files.zip")
|
@bp.post("/torrents/torrent-files.zip")
|
||||||
def torrent_files_export_zip():
|
def torrent_files_export_zip():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
@@ -454,7 +455,7 @@ def torrent_files_export_zip():
|
|||||||
|
|
||||||
@bp.get("/torrents/<torrent_hash>/chunks")
|
@bp.get("/torrents/<torrent_hash>/chunks")
|
||||||
def torrent_chunks(torrent_hash: str):
|
def torrent_chunks(torrent_hash: str):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
try:
|
try:
|
||||||
@@ -466,7 +467,7 @@ def torrent_chunks(torrent_hash: str):
|
|||||||
|
|
||||||
@bp.post("/torrents/<torrent_hash>/chunks/<action_name>")
|
@bp.post("/torrents/<torrent_hash>/chunks/<action_name>")
|
||||||
def torrent_chunk_action(torrent_hash: str, action_name: str):
|
def torrent_chunk_action(torrent_hash: str, action_name: str):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
try:
|
try:
|
||||||
@@ -479,7 +480,7 @@ def torrent_chunk_action(torrent_hash: str, action_name: str):
|
|||||||
|
|
||||||
@bp.get("/torrents/<torrent_hash>/peers")
|
@bp.get("/torrents/<torrent_hash>/peers")
|
||||||
def torrent_peers(torrent_hash: str):
|
def torrent_peers(torrent_hash: str):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
peers = rtorrent.torrent_peers(profile, torrent_hash)
|
peers = rtorrent.torrent_peers(profile, torrent_hash)
|
||||||
@@ -495,7 +496,7 @@ def torrent_peers(torrent_hash: str):
|
|||||||
|
|
||||||
@bp.get("/torrents/<torrent_hash>/trackers")
|
@bp.get("/torrents/<torrent_hash>/trackers")
|
||||||
def torrent_trackers(torrent_hash: str):
|
def torrent_trackers(torrent_hash: str):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
return ok({"trackers": rtorrent.torrent_trackers(profile, torrent_hash)})
|
return ok({"trackers": rtorrent.torrent_trackers(profile, torrent_hash)})
|
||||||
@@ -504,7 +505,7 @@ def torrent_trackers(torrent_hash: str):
|
|||||||
|
|
||||||
@bp.post("/torrents/<torrent_hash>/trackers/<action_name>")
|
@bp.post("/torrents/<torrent_hash>/trackers/<action_name>")
|
||||||
def torrent_tracker_action(torrent_hash: str, action_name: str):
|
def torrent_tracker_action(torrent_hash: str, action_name: str):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
try:
|
try:
|
||||||
@@ -515,17 +516,153 @@ def torrent_tracker_action(torrent_hash: str, action_name: str):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_remote_transfer_path(path: str) -> str:
|
||||||
|
clean = posixpath.normpath(str(path or "").strip())
|
||||||
|
if not clean or clean in {".", "/"} or not clean.startswith("/") or "\x00" in clean:
|
||||||
|
raise ValueError("Unsafe target path")
|
||||||
|
return clean
|
||||||
|
|
||||||
|
|
||||||
|
def _path_inside_root(path: str, root: str) -> bool:
|
||||||
|
path = _clean_remote_transfer_path(path)
|
||||||
|
root = _clean_remote_transfer_path(root)
|
||||||
|
return path == root or path.startswith(root.rstrip("/") + "/")
|
||||||
|
|
||||||
|
|
||||||
|
def _target_profile_allowed_roots(target_profile: dict, user_id: int) -> list[str]:
|
||||||
|
roots = []
|
||||||
|
try:
|
||||||
|
roots.append(_clean_remote_transfer_path(rtorrent.default_download_path(target_profile)))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
prefs = preferences.get_disk_monitor_preferences(int(target_profile.get("id") or 0), user_id=user_id)
|
||||||
|
for item in json.loads((prefs or {}).get("disk_monitor_paths_json") or "[]"):
|
||||||
|
try:
|
||||||
|
roots.append(_clean_remote_transfer_path(str(item or "")))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
selected = str((prefs or {}).get("disk_monitor_selected_path") or "").strip()
|
||||||
|
if selected:
|
||||||
|
roots.append(_clean_remote_transfer_path(selected))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
seen = []
|
||||||
|
for root in roots:
|
||||||
|
if root not in seen:
|
||||||
|
seen.append(root)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_transfer_payload(source_profile: dict, data: dict, *, require_hashes: bool = True) -> dict:
|
||||||
|
user_id = auth.current_user_id() or default_user_id()
|
||||||
|
source_id = int(source_profile.get("id") or 0)
|
||||||
|
if not auth.can_write_profile(source_id, user_id):
|
||||||
|
raise PermissionError("No write access to source profile")
|
||||||
|
hashes = [str(h).strip() for h in (data.get("hashes") or []) if str(h).strip()]
|
||||||
|
if require_hashes and not hashes:
|
||||||
|
raise ValueError("No torrents selected")
|
||||||
|
target_id = int(data.get("target_profile_id") or 0)
|
||||||
|
if not target_id or target_id == source_id:
|
||||||
|
raise ValueError("Choose a different target profile")
|
||||||
|
if not auth.can_write_profile(target_id, user_id):
|
||||||
|
raise PermissionError("No write access to target profile")
|
||||||
|
target_profile = preferences.get_profile(target_id, user_id)
|
||||||
|
if not target_profile:
|
||||||
|
raise ValueError("Target profile does not exist")
|
||||||
|
|
||||||
|
roots = _target_profile_allowed_roots(target_profile, user_id)
|
||||||
|
default_target_path = roots[0] if roots else _clean_remote_transfer_path(rtorrent.default_download_path(target_profile))
|
||||||
|
requested_target_path = str(data.get("target_path") or data.get("path") or "").strip()
|
||||||
|
target_path = _clean_remote_transfer_path(requested_target_path or default_target_path)
|
||||||
|
inside_allowed_root = bool(roots and any(_path_inside_root(target_path, root) for root in roots))
|
||||||
|
if not inside_allowed_root:
|
||||||
|
# Note: A chosen target path must stay inside the target profile roots even for metadata-only transfers.
|
||||||
|
if requested_target_path:
|
||||||
|
raise ValueError("Target path is outside the target profile download roots")
|
||||||
|
target_path = default_target_path
|
||||||
|
inside_allowed_root = bool(roots and any(_path_inside_root(target_path, root) for root in roots))
|
||||||
|
|
||||||
|
requested_move_data = bool(data.get("move_data"))
|
||||||
|
move_data = requested_move_data
|
||||||
|
write_check = {"ok": False, "message": "not requested"}
|
||||||
|
downgrade_reason = ""
|
||||||
|
if requested_move_data:
|
||||||
|
if not inside_allowed_root:
|
||||||
|
move_data = False
|
||||||
|
downgrade_reason = "Target path is outside the target profile download roots"
|
||||||
|
write_check = {"ok": False, "message": downgrade_reason, "path": target_path}
|
||||||
|
else:
|
||||||
|
# Note: Data moves are allowed only when the source rTorrent OS user can write to the target profile path.
|
||||||
|
write_check = rtorrent.remote_can_write_directory(source_profile, target_path)
|
||||||
|
move_data = bool(write_check.get("ok"))
|
||||||
|
if not move_data:
|
||||||
|
downgrade_reason = str(write_check.get("message") or write_check.get("error") or "Target path is not writable by the source rTorrent user")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"hashes": hashes,
|
||||||
|
"target_profile_id": target_id,
|
||||||
|
"target_path": target_path,
|
||||||
|
"path": target_path,
|
||||||
|
"move_data": move_data,
|
||||||
|
"move_data_requested": requested_move_data,
|
||||||
|
"move_data_downgraded": bool(requested_move_data and not move_data),
|
||||||
|
"move_data_downgrade_reason": downgrade_reason,
|
||||||
|
"target_allowed_roots": roots,
|
||||||
|
"target_write_check": write_check,
|
||||||
|
"label_mode": str(data.get("label_mode") or "none").strip(),
|
||||||
|
"label_value": str(data.get("label_value") or "").strip(),
|
||||||
|
"post_action": str(data.get("post_action") or "current").strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_profile_transfer_payload(source_profile: dict, data: dict) -> dict:
|
||||||
|
return _profile_transfer_payload(source_profile, data, require_hashes=True)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/torrents/profile_transfer/validate")
|
||||||
|
def profile_transfer_validate():
|
||||||
|
profile = request_profile()
|
||||||
|
if not profile:
|
||||||
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
|
try:
|
||||||
|
payload = _profile_transfer_payload(profile, request.get_json(silent=True) or {}, require_hashes=False)
|
||||||
|
target_profile = preferences.get_profile(int(payload["target_profile_id"]), auth.current_user_id() or default_user_id())
|
||||||
|
return ok({
|
||||||
|
"target_profile_id": payload["target_profile_id"],
|
||||||
|
"target_path": payload["target_path"],
|
||||||
|
"move_data_requested": payload["move_data_requested"],
|
||||||
|
"move_data_allowed": bool(payload["move_data"]),
|
||||||
|
"move_data_downgraded": bool(payload["move_data_downgraded"]),
|
||||||
|
"move_data_downgrade_reason": payload.get("move_data_downgrade_reason") or "",
|
||||||
|
"target_write_check": payload.get("target_write_check") or {},
|
||||||
|
"disk": rtorrent.disk_usage_for_paths(target_profile, [payload["target_path"]], mode="selected", selected_path=payload["target_path"]),
|
||||||
|
"target_allowed_roots": payload.get("target_allowed_roots") or [],
|
||||||
|
})
|
||||||
|
except PermissionError as exc:
|
||||||
|
return jsonify({"ok": False, "error": str(exc)}), 403
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||||
|
|
||||||
@bp.post("/torrents/<action_name>")
|
@bp.post("/torrents/<action_name>")
|
||||||
def torrent_action(action_name: str):
|
def torrent_action(action_name: str):
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
allowed = {"start", "pause", "unpause", "stop", "resume", "recheck", "reannounce", "remove", "move", "set_label", "set_ratio_group"}
|
allowed = {"start", "pause", "unpause", "stop", "resume", "recheck", "reannounce", "remove", "move", "profile_transfer", "set_label", "set_ratio_group"}
|
||||||
if action_name not in allowed:
|
if action_name not in allowed:
|
||||||
return jsonify({"ok": False, "error": "Unknown action"}), 400
|
return jsonify({"ok": False, "error": "Unknown action"}), 400
|
||||||
if action_name in {"move", "remove"}:
|
if action_name == "profile_transfer":
|
||||||
# Note: Large move/remove requests are split into ordered bulk parts; smaller requests keep the old single-job response shape.
|
try:
|
||||||
|
data = _validated_profile_transfer_payload(profile, data)
|
||||||
|
except PermissionError as exc:
|
||||||
|
return jsonify({"ok": False, "error": str(exc)}), 403
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||||
|
if action_name in {"move", "remove", "profile_transfer"}:
|
||||||
|
# Note: Large move/remove/profile-transfer requests are split into ordered bulk parts; smaller requests keep the old single-job response shape.
|
||||||
jobs = enqueue_bulk_parts(profile, action_name, data)
|
jobs = enqueue_bulk_parts(profile, action_name, data)
|
||||||
first_job_id = jobs[0]["job_id"] if jobs else None
|
first_job_id = jobs[0]["job_id"] if jobs else None
|
||||||
total_hashes = sum(int(job.get("hash_count") or 0) for job in jobs)
|
total_hashes = sum(int(job.get("hash_count") or 0) for job in jobs)
|
||||||
@@ -537,6 +674,8 @@ def torrent_action(action_name: str):
|
|||||||
"bulk": total_hashes > 1,
|
"bulk": total_hashes > 1,
|
||||||
"bulk_parts": len(jobs),
|
"bulk_parts": len(jobs),
|
||||||
"chunk_size": MOVE_BULK_MAX_HASHES,
|
"chunk_size": MOVE_BULK_MAX_HASHES,
|
||||||
|
"transfer_move_data_downgraded": bool(data.get("move_data_downgraded")),
|
||||||
|
"transfer_move_data_downgrade_reason": str(data.get("move_data_downgrade_reason") or ""),
|
||||||
})
|
})
|
||||||
payload = enrich_bulk_payload(profile, action_name, data)
|
payload = enrich_bulk_payload(profile, action_name, data)
|
||||||
job_id = enqueue(action_name, profile["id"], payload)
|
job_id = enqueue(action_name, profile["id"], payload)
|
||||||
@@ -546,7 +685,7 @@ def torrent_action(action_name: str):
|
|||||||
|
|
||||||
@bp.post("/torrents/create")
|
@bp.post("/torrents/create")
|
||||||
def torrent_create():
|
def torrent_create():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
form = request.form if request.content_type and request.content_type.startswith("multipart/form-data") else (request.get_json(silent=True) or {})
|
form = request.form if request.content_type and request.content_type.startswith("multipart/form-data") else (request.get_json(silent=True) or {})
|
||||||
@@ -576,7 +715,7 @@ def torrent_create():
|
|||||||
|
|
||||||
@bp.post("/torrents/add")
|
@bp.post("/torrents/add")
|
||||||
def torrent_add():
|
def torrent_add():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
job_ids = []
|
job_ids = []
|
||||||
@@ -633,7 +772,7 @@ def torrent_add():
|
|||||||
|
|
||||||
@bp.post("/torrents/preview")
|
@bp.post("/torrents/preview")
|
||||||
def torrent_preview():
|
def torrent_preview():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
existing_hashes = set()
|
existing_hashes = set()
|
||||||
if profile:
|
if profile:
|
||||||
try:
|
try:
|
||||||
@@ -663,12 +802,14 @@ def torrent_preview():
|
|||||||
|
|
||||||
@bp.post("/speed/limits")
|
@bp.post("/speed/limits")
|
||||||
def speed_limits():
|
def speed_limits():
|
||||||
profile = preferences.active_profile()
|
profile = request_profile()
|
||||||
if not profile:
|
if not profile:
|
||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
job_id = enqueue("set_limits", profile["id"], {"down": data.get("down"), "up": data.get("up")})
|
limits = profile_speed_limits.save_limits(profile["id"], data.get("down"), data.get("up"))
|
||||||
return ok({"job_id": job_id})
|
# Note: Manual speed limits are stored once per rTorrent profile, so every user opening this profile sees and applies the same values.
|
||||||
|
job_id = enqueue("set_limits", profile["id"], {"down": limits["down"], "up": limits["up"]})
|
||||||
|
return ok({"job_id": job_id, "limits": limits})
|
||||||
|
|
||||||
|
|
||||||
def _user_disk_status(profile: dict) -> dict:
|
def _user_disk_status(profile: dict) -> dict:
|
||||||
|
|||||||
+37
-12
@@ -1,11 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import Any
|
from typing import Any
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from flask import abort, g, has_request_context, 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 werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
@@ -39,8 +36,6 @@ RTORRENT_WRITE_PREFIXES = (
|
|||||||
)
|
)
|
||||||
RTORRENT_CONFIG_PREFIXES = ("/api/rtorrent-config",)
|
RTORRENT_CONFIG_PREFIXES = ("/api/rtorrent-config",)
|
||||||
ADMIN_PREFIXES = ("/api/auth/users", "/api/profiles")
|
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 = (
|
PROFILE_READ_PREFIXES = (
|
||||||
"/api/torrents",
|
"/api/torrents",
|
||||||
"/api/torrent-stats",
|
"/api/torrent-stats",
|
||||||
@@ -101,7 +96,6 @@ def _host_matches_bypass(host: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def auth_bypassed_request() -> bool:
|
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():
|
if not enabled() or not AUTH_BYPASS_HOSTS or not has_request_context():
|
||||||
return False
|
return False
|
||||||
return _host_matches_bypass(request.host)
|
return _host_matches_bypass(request.host)
|
||||||
@@ -115,7 +109,6 @@ def bypass_user_id() -> int:
|
|||||||
row = conn.execute("SELECT id FROM users WHERE username=? AND is_active=1", (username,)).fetchone()
|
row = conn.execute("SELECT id FROM users WHERE username=? AND is_active=1", (username,)).fetchone()
|
||||||
if row:
|
if row:
|
||||||
return int(row["id"])
|
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()
|
row = conn.execute("SELECT id FROM users WHERE username='admin' AND is_active=1").fetchone()
|
||||||
if row:
|
if row:
|
||||||
return int(row["id"])
|
return int(row["id"])
|
||||||
@@ -126,7 +119,6 @@ def current_user_id() -> int:
|
|||||||
if not enabled():
|
if not enabled():
|
||||||
return default_user_id()
|
return default_user_id()
|
||||||
if not has_request_context():
|
if not has_request_context():
|
||||||
# Note: Background jobs and schedulers do not have Flask request/session state.
|
|
||||||
return 0
|
return 0
|
||||||
if auth_bypassed_request():
|
if auth_bypassed_request():
|
||||||
return bypass_user_id()
|
return bypass_user_id()
|
||||||
@@ -728,12 +720,45 @@ def install_guards(app) -> None:
|
|||||||
def _request_profile_id() -> int | None:
|
def _request_profile_id() -> int | None:
|
||||||
if request.view_args and request.view_args.get("profile_id"):
|
if request.view_args and request.view_args.get("profile_id"):
|
||||||
return int(request.view_args["profile_id"])
|
return int(request.view_args["profile_id"])
|
||||||
|
payload = {}
|
||||||
try:
|
try:
|
||||||
payload = request.get_json(silent=True) or {}
|
payload = request.get_json(silent=True) or {}
|
||||||
if payload.get("profile_id"):
|
|
||||||
return int(payload.get("profile_id"))
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
payload = {}
|
||||||
|
raw_id = (
|
||||||
|
request.args.get("profile_id")
|
||||||
|
or request.form.get("profile_id")
|
||||||
|
or payload.get("profile_id")
|
||||||
|
or request.headers.get("X-PyTorrent-Profile-Id")
|
||||||
|
)
|
||||||
|
if raw_id not in (None, ""):
|
||||||
|
try:
|
||||||
|
return int(raw_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
raw_name = (
|
||||||
|
request.args.get("profile_name")
|
||||||
|
or request.form.get("profile_name")
|
||||||
|
or payload.get("profile_name")
|
||||||
|
or request.headers.get("X-PyTorrent-Profile-Name")
|
||||||
|
)
|
||||||
|
if raw_name:
|
||||||
|
from . import preferences
|
||||||
|
visible = visible_profile_ids(current_user_id())
|
||||||
|
with connect() as conn:
|
||||||
|
if visible is None:
|
||||||
|
row = conn.execute("SELECT id FROM rtorrent_profiles WHERE lower(name)=lower(?) ORDER BY is_default DESC, id LIMIT 1", (str(raw_name).strip(),)).fetchone()
|
||||||
|
elif visible:
|
||||||
|
placeholders = ",".join("?" for _ in visible)
|
||||||
|
row = conn.execute(
|
||||||
|
f"SELECT id FROM rtorrent_profiles WHERE id IN ({placeholders}) AND lower(name)=lower(?) ORDER BY is_default DESC, id LIMIT 1",
|
||||||
|
(*tuple(visible), str(raw_name).strip()),
|
||||||
|
).fetchone()
|
||||||
|
else:
|
||||||
|
row = None
|
||||||
|
return int(row["id"]) if row else None
|
||||||
from . import preferences
|
from . import preferences
|
||||||
profile = preferences.active_profile()
|
profile = preferences.active_profile()
|
||||||
return int(profile["id"]) if profile else None
|
if profile:
|
||||||
|
return int(profile["id"])
|
||||||
|
return 1 if can_access_profile(1) else None
|
||||||
|
|||||||
@@ -2,25 +2,53 @@ from __future__ import annotations
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
from ..db import connect, default_user_id, utcnow
|
from ..db import connect, default_user_id, utcnow
|
||||||
from . import rtorrent
|
from . import rtorrent, auth
|
||||||
from .preferences import active_profile
|
from .preferences import active_profile, get_profile, get_disk_monitor_preferences
|
||||||
from .workers import enqueue
|
from .workers import enqueue
|
||||||
|
|
||||||
AUTOMATION_JOB_CHUNK_SIZE = 100
|
AUTOMATION_JOB_CHUNK_SIZE = 100
|
||||||
AUTOMATION_LIGHT_ACTIONS = {'start', 'stop', 'pause', 'resume', 'set_label'}
|
AUTOMATION_LIGHT_ACTIONS = {'start', 'stop', 'pause', 'resume', 'set_label'}
|
||||||
|
_CHECK_LOCKS: dict[tuple[int, int | None], threading.Lock] = {}
|
||||||
|
_CHECK_LOCKS_GUARD = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _check_lock(profile_id: int, rule_id: int | None = None) -> threading.Lock:
|
||||||
|
"""Prevent overlapping automation runs for the same profile or rule."""
|
||||||
|
key = (int(profile_id), int(rule_id) if rule_id is not None else None)
|
||||||
|
with _CHECK_LOCKS_GUARD:
|
||||||
|
if key not in _CHECK_LOCKS:
|
||||||
|
_CHECK_LOCKS[key] = threading.Lock()
|
||||||
|
return _CHECK_LOCKS[key]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_user_id(profile: dict[str, Any] | None = None, user_id: int | None = None) -> int:
|
||||||
|
"""Return a safe user id for rule ownership or background execution."""
|
||||||
|
if user_id:
|
||||||
|
return int(user_id)
|
||||||
|
request_user_id = auth.current_user_id()
|
||||||
|
if request_user_id:
|
||||||
|
return int(request_user_id)
|
||||||
|
if profile and profile.get('user_id'):
|
||||||
|
return int(profile.get('user_id') or 0)
|
||||||
|
return int(default_user_id())
|
||||||
|
|
||||||
|
|
||||||
def _loads(value: str | None, default: Any) -> Any:
|
def _loads(value: str | None, default: Any) -> Any:
|
||||||
try: return json.loads(value or '')
|
try:
|
||||||
except Exception: return default
|
return json.loads(value or '')
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
def _ts(value: str | None) -> float:
|
def _ts(value: str | None) -> float:
|
||||||
if not value: return 0.0
|
if not value:
|
||||||
try: return datetime.fromisoformat(str(value).replace('Z', '+00:00')).timestamp()
|
return 0.0
|
||||||
except Exception: return 0.0
|
try:
|
||||||
|
return datetime.fromisoformat(str(value).replace('Z', '+00:00')).timestamp()
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
def _now_ts() -> float:
|
def _now_ts() -> float:
|
||||||
@@ -31,7 +59,8 @@ def _label_names(value: str | None) -> list[str]:
|
|||||||
seen = []
|
seen = []
|
||||||
for part in str(value or '').replace(';', ',').replace('|', ',').split(','):
|
for part in str(value or '').replace(';', ',').replace('|', ',').split(','):
|
||||||
item = part.strip()
|
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
|
return seen
|
||||||
|
|
||||||
|
|
||||||
@@ -39,7 +68,8 @@ def _label_value(labels: list[str]) -> str:
|
|||||||
out = []
|
out = []
|
||||||
for label in labels:
|
for label in labels:
|
||||||
label = str(label or '').strip()
|
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)
|
return ', '.join(out)
|
||||||
|
|
||||||
|
|
||||||
@@ -47,35 +77,98 @@ def _rule_row(row: dict[str, Any]) -> dict[str, Any]:
|
|||||||
item = dict(row)
|
item = dict(row)
|
||||||
item['conditions'] = _loads(item.pop('conditions_json', '[]'), [])
|
item['conditions'] = _loads(item.pop('conditions_json', '[]'), [])
|
||||||
item['effects'] = _loads(item.pop('effects_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
|
return item
|
||||||
|
|
||||||
|
|
||||||
def list_rules(profile_id: int | None = None, user_id: int | None = None) -> list[dict[str, Any]]:
|
def _require_profile_read(profile_id: int, user_id: int | None = None) -> int:
|
||||||
user_id = user_id or default_user_id()
|
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:
|
if profile_id is None:
|
||||||
profile = active_profile(); profile_id = int(profile['id']) if profile else None
|
return
|
||||||
with connect() as conn:
|
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]
|
rules = [_rule_row(r) for r in rows]
|
||||||
if profile_id is not None:
|
_decorate_rule_state(rules, profile_id)
|
||||||
with connect() as conn:
|
return rules
|
||||||
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
|
def _list_enabled_rules_for_profile(profile_id: int, rule_id: int | None = None, force: bool = False) -> list[dict[str, Any]]:
|
||||||
cooldown = int(rule.get('cooldown_minutes') or 0)
|
params: list[Any] = [profile_id]
|
||||||
remaining = max(0, int((_ts(last) + cooldown * 60) - _now_ts())) if last and cooldown > 0 else 0
|
clauses = ['r.profile_id=?']
|
||||||
# Note: Exposes live cooldown timers for the Automations tab without changing rule behavior.
|
if rule_id is not None:
|
||||||
rule['last_applied_at'] = last
|
clauses.append('r.id=?')
|
||||||
rule['cooldown_remaining_seconds'] = remaining
|
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
|
return rules
|
||||||
|
|
||||||
|
|
||||||
def get_rule(rule_id: int, profile_id: int, user_id: int | None = None) -> dict[str, Any]:
|
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:
|
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()
|
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')
|
if not row:
|
||||||
return _rule_row(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]:
|
def _portable_rule(rule: dict[str, Any]) -> dict[str, Any]:
|
||||||
@@ -89,70 +182,96 @@ def _portable_rule(rule: dict[str, Any]) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def export_rules(profile_id: int, user_id: int | None = None) -> dict[str, Any]:
|
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)]
|
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]]:
|
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 []
|
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:
|
if not isinstance(raw_rules, list) or not raw_rules:
|
||||||
raise ValueError('Import file does not contain automation rules')
|
raise ValueError('Import file does not contain automation rules')
|
||||||
if replace:
|
if replace:
|
||||||
with connect() as conn:
|
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 profile_id=?', (profile_id,))
|
||||||
conn.execute('DELETE FROM automation_rules WHERE user_id=? AND profile_id=?', (user_id, profile_id))
|
|
||||||
conn.execute('DELETE FROM automation_rule_state WHERE profile_id=?', (profile_id,))
|
conn.execute('DELETE FROM automation_rule_state WHERE profile_id=?', (profile_id,))
|
||||||
imported = []
|
imported = []
|
||||||
for raw in raw_rules:
|
for raw in raw_rules:
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
continue
|
continue
|
||||||
rule = _portable_rule(raw)
|
rule = _portable_rule(raw)
|
||||||
rule.pop('id', None)
|
imported.append(save_rule(profile_id, rule, owner_id))
|
||||||
imported.append(save_rule(profile_id, rule, user_id))
|
|
||||||
if not imported:
|
if not imported:
|
||||||
raise ValueError('No valid automation rules found')
|
raise ValueError('No valid automation rules found')
|
||||||
return imported
|
return imported
|
||||||
|
|
||||||
|
|
||||||
def save_rule(profile_id: int, data: dict[str, Any], user_id: int | None = None) -> dict[str, Any]:
|
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'
|
name = str(data.get('name') or 'Automation rule').strip() or 'Automation rule'
|
||||||
conditions = data.get('conditions') or []
|
conditions = data.get('conditions') or []
|
||||||
effects = data.get('effects') 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(conditions, list) or not conditions:
|
||||||
if not isinstance(effects, list) or not effects: raise ValueError('Rule needs at least one effect')
|
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))
|
cooldown = max(0, int(data.get('cooldown_minutes') or 0))
|
||||||
enabled = 1 if data.get('enabled', True) else 0
|
enabled = 1 if data.get('enabled', True) else 0
|
||||||
now = utcnow(); rule_id = int(data.get('id') or 0)
|
now = utcnow()
|
||||||
with connect() as conn:
|
rule_id = int(data.get('id') or 0)
|
||||||
if rule_id:
|
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))
|
existing = get_rule(rule_id, profile_id, actor_id)
|
||||||
else:
|
if not _can_manage_rule(profile_id, existing, actor_id):
|
||||||
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))
|
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)
|
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:
|
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:
|
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))
|
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]]:
|
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:
|
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:
|
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:
|
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 profile_id=?', (profile_id,))
|
||||||
cur = conn.execute('DELETE FROM automation_history WHERE user_id=? AND profile_id=?', (user_id, profile_id))
|
|
||||||
return int(cur.rowcount or 0)
|
return int(cur.rowcount or 0)
|
||||||
|
|
||||||
|
|
||||||
@@ -177,46 +296,47 @@ def _conditions_match(conn, rule: dict[str, Any], profile_id: int, t: dict[str,
|
|||||||
for cond in rule.get('conditions') or []:
|
for cond in rule.get('conditions') or []:
|
||||||
raw_ok = _condition_true(t, cond)
|
raw_ok = _condition_true(t, cond)
|
||||||
negated = bool(cond.get('negate'))
|
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
|
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:
|
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()
|
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.get('condition_since_at') if row else None
|
||||||
since = row['condition_since_at'] if row and row.get('condition_since_at') else now
|
if raw_ok:
|
||||||
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))
|
if not since:
|
||||||
delayed_ok = delayed_ok and (now_ts - _ts(since) >= int(cond.get('minutes') or 0) * 60)
|
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:
|
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:
|
else:
|
||||||
immediate_ok = immediate_ok and ok
|
immediate_ok = immediate_ok and ok
|
||||||
return immediate_ok and delayed_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)
|
cooldown = int(rule.get('cooldown_minutes') or 0)
|
||||||
if cooldown <= 0: return True
|
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()
|
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()
|
||||||
if not row or not row.get('last_applied_at'): return True
|
last = row.get('last_applied_at') if row else None
|
||||||
return _now_ts() - _ts(row['last_applied_at']) >= cooldown * 60
|
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:
|
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))
|
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]]:
|
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))
|
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)]
|
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]:
|
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 = {
|
ctx = {
|
||||||
'source': 'automation',
|
'source': 'automation',
|
||||||
'rule_id': rule.get('id'),
|
'rule_id': rule.get('id'),
|
||||||
'rule_name': str(rule.get('name') or ''),
|
'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,
|
'effect': eff_type,
|
||||||
'bulk': len(hashes) > 1,
|
'bulk': len(hashes) > 1,
|
||||||
'hash_count': len(hashes),
|
'hash_count': len(hashes),
|
||||||
@@ -236,7 +356,6 @@ def _job_context(rule: dict[str, Any], eff_type: str, hashes: list[str], torrent
|
|||||||
|
|
||||||
|
|
||||||
def _enqueue_automation_job(profile: dict[str, Any], rule: dict[str, Any], action_name: str, hashes: list[str], payload: dict[str, Any], torrents_by_hash: dict[str, dict[str, Any]], user_id: int | None = None, context_extra: dict[str, Any] | None = None) -> list[str]:
|
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] = []
|
job_ids: list[str] = []
|
||||||
chunks = [hashes] if action_name in AUTOMATION_LIGHT_ACTIONS else _chunk_hashes(hashes)
|
chunks = [hashes] if action_name in AUTOMATION_LIGHT_ACTIONS else _chunk_hashes(hashes)
|
||||||
for index, chunk in enumerate(chunks, start=1):
|
for index, chunk in enumerate(chunks, start=1):
|
||||||
@@ -250,13 +369,91 @@ def _enqueue_automation_job(profile: dict[str, Any], rule: dict[str, Any], actio
|
|||||||
extra.update({'bulk_label': f'automation-{index}', 'bulk_part': index, 'bulk_parts': len(chunks), 'parent_hash_count': len(hashes)})
|
extra.update({'bulk_label': f'automation-{index}', 'bulk_part': index, 'bulk_parts': len(chunks), 'parent_hash_count': len(hashes)})
|
||||||
if action_name == 'move':
|
if action_name == 'move':
|
||||||
extra.update({'target_path': str(part_payload.get('path') or ''), 'move_data': bool(part_payload.get('move_data'))})
|
extra.update({'target_path': str(part_payload.get('path') or ''), 'move_data': bool(part_payload.get('move_data'))})
|
||||||
|
if action_name == 'profile_transfer':
|
||||||
|
extra.update({'target_profile_id': int(part_payload.get('target_profile_id') or 0), 'target_path': str(part_payload.get('target_path') or ''), 'move_data': bool(part_payload.get('move_data')), 'post_action': str(part_payload.get('post_action') or 'current')})
|
||||||
if action_name == 'remove':
|
if action_name == 'remove':
|
||||||
extra.update({'remove_data': bool(part_payload.get('remove_data'))})
|
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))
|
job_ids.append(enqueue(action_name, int(profile['id']), part_payload, user_id=user_id))
|
||||||
return job_ids
|
return job_ids
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_remote_path(value: str) -> str:
|
||||||
|
path = str(value or '').strip().replace('\\', '/')
|
||||||
|
while '//' in path:
|
||||||
|
path = path.replace('//', '/')
|
||||||
|
if path.endswith('/') and path != '/':
|
||||||
|
path = path.rstrip('/')
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _path_inside_root(path: str, root: str) -> bool:
|
||||||
|
path = _safe_remote_path(path)
|
||||||
|
root = _safe_remote_path(root)
|
||||||
|
return bool(path and root and (path == root or path.startswith(root.rstrip('/') + '/')))
|
||||||
|
|
||||||
|
def _automation_profile_transfer_payload(profile: dict[str, Any], eff: dict[str, Any], user_id: int) -> dict[str, Any]:
|
||||||
|
# Note: Automation profile transfers reuse server-side permission checks; UI values are not trusted.
|
||||||
|
source_id = int(profile.get('id') or 0)
|
||||||
|
if not auth.can_write_profile(source_id, user_id):
|
||||||
|
raise ValueError('Rule owner has no write access to source profile')
|
||||||
|
target_id = int(eff.get('target_profile_id') or 0)
|
||||||
|
if not target_id or target_id == source_id:
|
||||||
|
raise ValueError('Automation target profile is invalid')
|
||||||
|
if not auth.can_write_profile(target_id, user_id):
|
||||||
|
raise ValueError('Rule owner has no write access to target profile')
|
||||||
|
target_profile = get_profile(target_id, user_id)
|
||||||
|
if not target_profile:
|
||||||
|
raise ValueError('Automation target profile does not exist')
|
||||||
|
default_path = _safe_remote_path(rtorrent.default_download_path(target_profile))
|
||||||
|
requested_target_path = _safe_remote_path(str(eff.get('target_path') or eff.get('path') or ''))
|
||||||
|
target_path = requested_target_path or default_path
|
||||||
|
roots = [default_path]
|
||||||
|
try:
|
||||||
|
prefs = get_disk_monitor_preferences(target_id, user_id=user_id)
|
||||||
|
for item in json.loads((prefs or {}).get('disk_monitor_paths_json') or '[]'):
|
||||||
|
clean = _safe_remote_path(str(item or ''))
|
||||||
|
if clean and clean not in roots:
|
||||||
|
roots.append(clean)
|
||||||
|
selected = _safe_remote_path(str((prefs or {}).get('disk_monitor_selected_path') or ''))
|
||||||
|
if selected and selected not in roots:
|
||||||
|
roots.append(selected)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
target_roots = [r for r in roots if r]
|
||||||
|
if not any(_path_inside_root(target_path, root) for root in target_roots):
|
||||||
|
if requested_target_path:
|
||||||
|
raise ValueError('Automation target path is outside the target profile download roots')
|
||||||
|
target_path = default_path
|
||||||
|
requested_move_data = bool(eff.get('move_data'))
|
||||||
|
move_data = False
|
||||||
|
downgrade_reason = ''
|
||||||
|
if requested_move_data:
|
||||||
|
check = rtorrent.remote_can_write_directory(profile, target_path)
|
||||||
|
move_data = bool(check.get('ok'))
|
||||||
|
if not move_data:
|
||||||
|
downgrade_reason = str(check.get('message') or check.get('error') or 'target path is not writable by source rTorrent user')
|
||||||
|
post_action = str(eff.get('post_action') or 'current').strip().lower()
|
||||||
|
if post_action not in {'none', 'current', 'start', 'stop', 'pause', 'check', 'recheck'}:
|
||||||
|
post_action = 'current'
|
||||||
|
label_mode = str(eff.get('label_mode') or 'none').strip().lower()
|
||||||
|
if label_mode not in {'none', 'custom', 'moved_from', 'moved_to'}:
|
||||||
|
label_mode = 'none'
|
||||||
|
return {
|
||||||
|
'target_profile_id': target_id,
|
||||||
|
'target_path': target_path,
|
||||||
|
'path': target_path,
|
||||||
|
'move_data': move_data,
|
||||||
|
'move_data_requested': requested_move_data,
|
||||||
|
'move_data_downgraded': bool(requested_move_data and not move_data),
|
||||||
|
'move_data_downgrade_reason': downgrade_reason,
|
||||||
|
'post_action': post_action,
|
||||||
|
'label_mode': label_mode,
|
||||||
|
'label_value': str(eff.get('label_value') or '').strip(),
|
||||||
|
}
|
||||||
|
|
||||||
def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str, Any]], effects: list[dict[str, Any]], rule: dict[str, Any], user_id: int | None = None) -> list[dict[str, Any]]:
|
def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str, Any]], effects: list[dict[str, Any]], rule: dict[str, Any], user_id: int | None = None) -> list[dict[str, Any]]:
|
||||||
hashes = [str(t.get('hash') or '') for t in torrents if str(t.get('hash') or '')]
|
hashes = [str(t.get('hash') or '') for t in torrents if str(t.get('hash') or '')]
|
||||||
torrents_by_hash = {str(t.get('hash') or ''): t for t in torrents if str(t.get('hash') or '')}
|
torrents_by_hash = {str(t.get('hash') or ''): t for t in torrents if str(t.get('hash') or '')}
|
||||||
@@ -275,10 +472,14 @@ def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str
|
|||||||
}
|
}
|
||||||
job_ids = _enqueue_automation_job(profile, rule, 'move', hashes, payload, torrents_by_hash, user_id, {'effect_type': 'move'})
|
job_ids = _enqueue_automation_job(profile, rule, 'move', hashes, payload, torrents_by_hash, user_id, {'effect_type': 'move'})
|
||||||
applied.append({'type': 'move', 'path': path, 'count': len(hashes), 'target_hashes': hashes, 'move_data': payload['move_data'], 'recheck': payload['recheck'], 'keep_seeding': payload['keep_seeding'], 'job_ids': job_ids})
|
applied.append({'type': 'move', 'path': path, 'count': len(hashes), 'target_hashes': hashes, 'move_data': payload['move_data'], 'recheck': payload['recheck'], 'keep_seeding': payload['keep_seeding'], 'job_ids': job_ids})
|
||||||
|
elif typ == 'profile_transfer':
|
||||||
|
owner_id = int(user_id or rule.get('user_id') or rule.get('owner_user_id') or default_user_id())
|
||||||
|
payload = _automation_profile_transfer_payload(profile, eff, owner_id)
|
||||||
|
job_ids = _enqueue_automation_job(profile, rule, 'profile_transfer', hashes, payload, torrents_by_hash, owner_id, {'effect_type': 'profile_transfer'})
|
||||||
|
applied.append({'type': 'profile_transfer', 'target_profile_id': payload['target_profile_id'], 'target_path': payload['target_path'], 'count': len(hashes), 'target_hashes': hashes, 'move_data': payload['move_data'], 'move_data_requested': payload['move_data_requested'], 'move_data_downgraded': payload['move_data_downgraded'], 'post_action': payload['post_action'], 'label_mode': payload['label_mode'], 'label': payload['label_value'], 'job_ids': job_ids})
|
||||||
elif typ == 'add_label':
|
elif typ == 'add_label':
|
||||||
label = str(eff.get('label') or '').strip()
|
label = str(eff.get('label') or '').strip()
|
||||||
if label:
|
if label:
|
||||||
# Note: Add-label automations are idempotent and queue only torrents that need a changed label value.
|
|
||||||
grouped: dict[str, list[str]] = {}
|
grouped: dict[str, list[str]] = {}
|
||||||
for h in hashes:
|
for h in hashes:
|
||||||
labels = labels_by_hash.get(h, [])
|
labels = labels_by_hash.get(h, [])
|
||||||
@@ -297,7 +498,6 @@ def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str
|
|||||||
elif typ == 'remove_label':
|
elif typ == 'remove_label':
|
||||||
label = str(eff.get('label') or '').strip()
|
label = str(eff.get('label') or '').strip()
|
||||||
if label:
|
if label:
|
||||||
# Note: Remove-label automations are queued only for torrents where the requested label exists.
|
|
||||||
grouped: dict[str, list[str]] = {}
|
grouped: dict[str, list[str]] = {}
|
||||||
for h in hashes:
|
for h in hashes:
|
||||||
labels = labels_by_hash.get(h, [])
|
labels = labels_by_hash.get(h, [])
|
||||||
@@ -315,7 +515,6 @@ def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str
|
|||||||
elif typ == 'set_labels':
|
elif typ == 'set_labels':
|
||||||
value = _label_value(_label_names(eff.get('labels')))
|
value = _label_value(_label_names(eff.get('labels')))
|
||||||
target_labels = _label_names(value)
|
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]
|
target_hashes = [h for h in hashes if labels_by_hash.get(h, []) != target_labels]
|
||||||
for h in target_hashes:
|
for h in target_hashes:
|
||||||
labels_by_hash[h] = list(target_labels)
|
labels_by_hash[h] = list(target_labels)
|
||||||
@@ -323,60 +522,86 @@ def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str
|
|||||||
job_ids = _enqueue_automation_job(profile, rule, 'set_label', target_hashes, {'label': value}, torrents_by_hash, user_id, {'effect_type': 'set_labels', 'labels': value})
|
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})
|
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'}:
|
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})
|
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})
|
applied.append({'type': typ, 'count': len(hashes), 'target_hashes': hashes, 'job_ids': job_ids})
|
||||||
elif typ == 'remove':
|
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'))}
|
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'})
|
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})
|
applied.append({'type': 'remove', 'count': len(hashes), 'target_hashes': hashes, 'remove_data': payload['remove_data'], 'job_ids': job_ids})
|
||||||
return applied
|
return applied
|
||||||
|
|
||||||
|
|
||||||
def check(profile: dict | None = None, user_id: int | None = None, force: bool = False, rule_id: int | None = None) -> dict[str, Any]:
|
def _record_skipped_rule(profile_id: int, rule: dict[str, Any], hashes: list[str], reason: str, now: str) -> dict[str, Any]:
|
||||||
profile = profile or active_profile()
|
action = {'type': 'skipped', 'error': reason, 'count': len(hashes)}
|
||||||
if not profile: return {'ok': False, 'error': 'No active rTorrent profile'}
|
owner_id = int(rule.get('user_id') or rule.get('owner_user_id') or default_user_id())
|
||||||
user_id = user_id or default_user_id(); profile_id = int(profile['id'])
|
torrent_hash = hashes[0] if len(hashes) == 1 else f'batch:{rule["id"]}:{now}:skipped'
|
||||||
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))]
|
torrent_name = '1 torrent' if len(hashes) == 1 else f'{len(hashes)} torrents'
|
||||||
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:
|
with connect() as conn:
|
||||||
for rule in rules:
|
conn.execute(
|
||||||
# Note: This pass only matches rules and updates condition timers; job creation is intentionally delayed until after this DB transaction commits.
|
'INSERT INTO automation_history(user_id,profile_id,rule_id,torrent_hash,torrent_name,rule_name,actions_json,created_at) VALUES(?,?,?,?,?,?,?,?)',
|
||||||
if not force and not _cooldown_ok(conn, rule, profile_id):
|
(owner_id, profile_id, rule['id'], torrent_hash, torrent_name, str(rule.get('name') or ''), json.dumps([action]), now),
|
||||||
continue
|
)
|
||||||
matched = [t for t in torrents if _conditions_match(conn, rule, profile_id, t)]
|
return {'rule_id': rule['id'], 'rule_name': rule.get('name'), 'count': len(hashes), 'actions': [action], 'skipped': True}
|
||||||
if not matched:
|
|
||||||
continue
|
|
||||||
hashes = [str(t.get('hash') or '') for t in matched if str(t.get('hash') or '')]
|
def check(profile: dict | None = None, user_id: int | None = None, force: bool = False, rule_id: int | None = None) -> dict[str, Any]:
|
||||||
if hashes:
|
profile = profile or active_profile(user_id=user_id)
|
||||||
planned.append({'rule': rule, 'matched': matched, 'hashes': hashes})
|
if not profile:
|
||||||
for item in planned:
|
return {'ok': False, 'error': 'No active rTorrent profile'}
|
||||||
rule = item['rule']
|
profile_id = int(profile['id'])
|
||||||
matched = item['matched']
|
if rule_id is not None:
|
||||||
hashes = item['hashes']
|
_require_profile_read(profile_id, user_id)
|
||||||
# Note: Automation jobs are enqueued outside the rule-state transaction, preventing SQLite self-locks when enqueue() writes to jobs.
|
lock = _check_lock(profile_id, rule_id)
|
||||||
try:
|
if not lock.acquire(blocking=False):
|
||||||
actions = _apply_effects_bulk(None, profile, matched, rule.get('effects') or [], rule, user_id)
|
# Note: Browser, manual and background checks can now coexist without duplicate rule application.
|
||||||
except Exception as exc:
|
return {'ok': True, 'checked': 0, 'applied': [], 'batches': [], 'rules': 0, 'skipped': True, 'reason': 'Automation check already running'}
|
||||||
actions = [{'error': str(exc), 'count': len(hashes), 'target_hashes': hashes}]
|
try:
|
||||||
changed_hashes = sorted({h for a in actions for h in (a.get('target_hashes') or [])})
|
rules = _list_enabled_rules_for_profile(profile_id, rule_id=rule_id, force=force)
|
||||||
if not actions or not changed_hashes:
|
if not rules:
|
||||||
# Note: Matching torrents with no real action are not logged and do not restart the cooldown.
|
return {'ok': True, 'checked': 0, 'applied': [], 'batches': [], 'rules': 0}
|
||||||
continue
|
torrents = rtorrent.list_torrents(profile)
|
||||||
history_actions = [{k: v for k, v in a.items() if k != 'target_hashes'} for a in actions]
|
applied = []
|
||||||
matched_by_hash = {str(t.get('hash') or ''): t for t in matched}
|
batches = []
|
||||||
|
now = utcnow()
|
||||||
|
planned: list[dict[str, Any]] = []
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
# Note: State/history writes happen after enqueue succeeds, so failed job creation does not create misleading automation history.
|
for rule in rules:
|
||||||
for h in changed_hashes:
|
if not force and not _cooldown_ok(conn, rule, profile_id):
|
||||||
t = matched_by_hash.get(h, {})
|
continue
|
||||||
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))
|
matched = [t for t in torrents if _conditions_match(conn, rule, profile_id, t)]
|
||||||
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]})
|
if not matched:
|
||||||
_mark_rule_cooldown(conn, rule, profile_id, now)
|
continue
|
||||||
torrent_name = str(matched_by_hash.get(changed_hashes[0], {}).get('name') or '') if len(changed_hashes) == 1 else f'{len(changed_hashes)} torrents'
|
hashes = [str(t.get('hash') or '') for t in matched if str(t.get('hash') or '')]
|
||||||
torrent_hash = changed_hashes[0] if len(changed_hashes) == 1 else f'batch:{rule["id"]}:{now}'
|
if hashes:
|
||||||
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))
|
planned.append({'rule': rule, 'matched': matched, 'hashes': hashes})
|
||||||
batches.append({'rule_id': rule['id'], 'rule_name': rule.get('name'), 'count': len(changed_hashes), 'actions': history_actions})
|
for item in planned:
|
||||||
return {'ok': True, 'checked': len(torrents), 'rules': len(rules), 'applied': applied, 'batches': batches}
|
rule = item['rule']
|
||||||
|
matched = item['matched']
|
||||||
|
hashes = item['hashes']
|
||||||
|
owner_id = int(rule.get('user_id') or rule.get('owner_user_id') or default_user_id())
|
||||||
|
if not auth.can_write_profile(profile_id, owner_id):
|
||||||
|
batch = _record_skipped_rule(profile_id, rule, hashes, 'Rule owner no longer has write access to profile', now)
|
||||||
|
batches.append(batch)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
actions = _apply_effects_bulk(None, profile, matched, rule.get('effects') or [], rule, owner_id)
|
||||||
|
except Exception as exc:
|
||||||
|
actions = [{'error': str(exc), 'count': len(hashes), 'target_hashes': hashes}]
|
||||||
|
changed_hashes = sorted({h for a in actions for h in (a.get('target_hashes') or [])})
|
||||||
|
if not actions or not changed_hashes:
|
||||||
|
continue
|
||||||
|
history_actions = [{k: v for k, v in a.items() if k != 'target_hashes'} for a in actions]
|
||||||
|
matched_by_hash = {str(t.get('hash') or ''): t for t in matched}
|
||||||
|
with connect() as conn:
|
||||||
|
for h in changed_hashes:
|
||||||
|
t = matched_by_hash.get(h, {})
|
||||||
|
conn.execute('INSERT INTO automation_rule_state(rule_id,profile_id,torrent_hash,last_matched_at,last_applied_at,updated_at) VALUES(?,?,?,?,?,?) ON CONFLICT(rule_id,profile_id,torrent_hash) DO UPDATE SET last_matched_at=excluded.last_matched_at, last_applied_at=excluded.last_applied_at, updated_at=excluded.updated_at', (rule['id'], profile_id, h, now, now, now))
|
||||||
|
applied.append({'rule_id': rule['id'], 'rule_name': rule.get('name'), 'owner_user_id': owner_id, 'owner_label': rule.get('owner_label'), 'hash': h, 'name': t.get('name'), 'actions': [{'type': a.get('type', 'error'), 'count': a.get('count', len(changed_hashes))} for a in actions]})
|
||||||
|
_mark_rule_cooldown(conn, rule, profile_id, now)
|
||||||
|
torrent_name = str(matched_by_hash.get(changed_hashes[0], {}).get('name') or '') if len(changed_hashes) == 1 else f'{len(changed_hashes)} torrents'
|
||||||
|
torrent_hash = changed_hashes[0] if len(changed_hashes) == 1 else f'batch:{rule["id"]}:{now}'
|
||||||
|
conn.execute('INSERT INTO automation_history(user_id,profile_id,rule_id,torrent_hash,torrent_name,rule_name,actions_json,created_at) VALUES(?,?,?,?,?,?,?,?)', (owner_id, profile_id, rule['id'], torrent_hash, torrent_name, str(rule.get('name') or ''), json.dumps(history_actions), now))
|
||||||
|
batches.append({'rule_id': rule['id'], 'rule_name': rule.get('name'), 'owner_user_id': owner_id, 'owner_label': rule.get('owner_label'), 'count': len(changed_hashes), 'actions': history_actions})
|
||||||
|
return {'ok': True, 'checked': len(torrents), 'rules': len(rules), 'applied': applied, 'batches': batches}
|
||||||
|
finally:
|
||||||
|
lock.release()
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
from ..db import connect, default_user_id
|
||||||
|
from . import automation_rules, operation_logs, poller_control, rtorrent
|
||||||
|
from .websocket import emit_profile_event
|
||||||
|
|
||||||
|
_started = False
|
||||||
|
_start_lock = threading.Lock()
|
||||||
|
_profile_locks: dict[int, threading.Lock] = {}
|
||||||
|
_profile_locks_lock = threading.Lock()
|
||||||
|
_last_logged_status: dict[int, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_interval() -> float:
|
||||||
|
"""Return the minimum background automation interval from environment settings."""
|
||||||
|
try:
|
||||||
|
return max(5.0, min(3600.0, float(os.environ.get("PYTORRENT_AUTOMATION_BACKGROUND_INTERVAL_SECONDS", "15"))))
|
||||||
|
except Exception:
|
||||||
|
return 15.0
|
||||||
|
|
||||||
|
|
||||||
|
def _profiles() -> list[dict[str, Any]]:
|
||||||
|
"""Read configured profiles without relying on a browser session."""
|
||||||
|
with connect() as conn:
|
||||||
|
return [dict(row) for row in conn.execute("SELECT * FROM rtorrent_profiles ORDER BY id").fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_lock(profile_id: int) -> threading.Lock:
|
||||||
|
"""Keep one automation pass per profile active at a time."""
|
||||||
|
with _profile_locks_lock:
|
||||||
|
if profile_id not in _profile_locks:
|
||||||
|
_profile_locks[profile_id] = threading.Lock()
|
||||||
|
return _profile_locks[profile_id]
|
||||||
|
|
||||||
|
|
||||||
|
def _owner_user_id(profile: dict[str, Any]) -> int:
|
||||||
|
"""Use the profile owner for background checks so rule permissions stay stable."""
|
||||||
|
return int(profile.get("user_id") or default_user_id())
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_interval(profile_id: int) -> float:
|
||||||
|
"""Reuse the existing queue poller cadence instead of adding another UI setting."""
|
||||||
|
settings = poller_control.get_settings(profile_id)
|
||||||
|
return max(_configured_interval(), float(settings.get("queue_stats_interval_seconds") or 15.0))
|
||||||
|
|
||||||
|
|
||||||
|
def _connected(profile: dict[str, Any]) -> tuple[bool, str]:
|
||||||
|
"""Verify rTorrent connectivity before running automation logic."""
|
||||||
|
try:
|
||||||
|
rtorrent.client_for(profile).call("system.client_version")
|
||||||
|
return True, ""
|
||||||
|
except Exception as exc:
|
||||||
|
return False, str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _log_status(profile_id: int, status: str, message: str, *, error: str = "") -> None:
|
||||||
|
"""Log only connectivity state changes to avoid noisy system logs."""
|
||||||
|
if _last_logged_status.get(profile_id) == status:
|
||||||
|
return
|
||||||
|
_last_logged_status[profile_id] = status
|
||||||
|
severity = "warning" if error else "info"
|
||||||
|
operation_logs.record(
|
||||||
|
profile_id,
|
||||||
|
"background_automation_status",
|
||||||
|
message,
|
||||||
|
severity=severity,
|
||||||
|
source="system",
|
||||||
|
action="background_automation",
|
||||||
|
details={"status": status, "error": error},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_profile(socketio, profile: dict[str, Any]) -> None:
|
||||||
|
"""Run one safe background automation pass for a connected profile."""
|
||||||
|
profile_id = int(profile.get("id") or 0)
|
||||||
|
if not profile_id:
|
||||||
|
return
|
||||||
|
lock = _profile_lock(profile_id)
|
||||||
|
if not lock.acquire(blocking=False):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
ok, error = _connected(profile)
|
||||||
|
if not ok:
|
||||||
|
_log_status(profile_id, "disconnected", f"Background automations waiting for rTorrent: {error}", error=error)
|
||||||
|
return
|
||||||
|
_log_status(profile_id, "connected", "Background automations detected a working rTorrent connection")
|
||||||
|
result = automation_rules.check(profile, user_id=_owner_user_id(profile), force=False)
|
||||||
|
if result.get("applied") or result.get("batches"):
|
||||||
|
operation_logs.record(
|
||||||
|
profile_id,
|
||||||
|
"background_automation_run",
|
||||||
|
"Background automations applied matching rules",
|
||||||
|
source="system",
|
||||||
|
action="background_automation",
|
||||||
|
details={"applied": len(result.get("applied") or []), "batches": len(result.get("batches") or []), "result": result},
|
||||||
|
user_id=_owner_user_id(profile),
|
||||||
|
)
|
||||||
|
emit_profile_event(socketio, "automation_update", result, profile_id)
|
||||||
|
except Exception as exc:
|
||||||
|
operation_logs.record(
|
||||||
|
profile_id,
|
||||||
|
"background_automation_error",
|
||||||
|
f"Background automation check failed: {exc}",
|
||||||
|
severity="warning",
|
||||||
|
source="system",
|
||||||
|
action="background_automation",
|
||||||
|
details={"error": str(exc)},
|
||||||
|
user_id=_owner_user_id(profile),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
lock.release()
|
||||||
|
|
||||||
|
|
||||||
|
def start_scheduler(socketio) -> None:
|
||||||
|
"""Start browser-independent automation checks once per application process."""
|
||||||
|
global _started
|
||||||
|
with _start_lock:
|
||||||
|
if _started:
|
||||||
|
return
|
||||||
|
_started = True
|
||||||
|
|
||||||
|
def runner() -> None:
|
||||||
|
last_run: dict[int, float] = {}
|
||||||
|
while True:
|
||||||
|
started = time.monotonic()
|
||||||
|
next_sleep = _configured_interval()
|
||||||
|
for profile in _profiles():
|
||||||
|
profile_id = int(profile.get("id") or 0)
|
||||||
|
if not profile_id:
|
||||||
|
continue
|
||||||
|
interval = _profile_interval(profile_id)
|
||||||
|
elapsed = started - float(last_run.get(profile_id) or 0.0)
|
||||||
|
if elapsed < interval:
|
||||||
|
next_sleep = min(next_sleep, max(1.0, interval - elapsed))
|
||||||
|
continue
|
||||||
|
last_run[profile_id] = started
|
||||||
|
_run_profile(socketio, profile)
|
||||||
|
next_sleep = min(next_sleep, interval)
|
||||||
|
socketio.sleep(max(1.0, next_sleep))
|
||||||
|
|
||||||
|
socketio.start_background_task(runner)
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
from ..db import connect, default_user_id
|
||||||
|
from . import port_check, preferences, rtorrent, tracker_cache
|
||||||
|
from .torrent_cache import torrent_cache
|
||||||
|
|
||||||
|
STARTUP_DELAY_SECONDS = 60
|
||||||
|
DEFAULT_TRACKER_INTERVAL_SECONDS = 15 * 60
|
||||||
|
DEFAULT_PORT_INTERVAL_SECONDS = port_check.PORT_CHECK_CACHE_SECONDS
|
||||||
|
FAVICON_BATCH_SIZE = 20
|
||||||
|
|
||||||
|
_started = False
|
||||||
|
_start_lock = threading.Lock()
|
||||||
|
_status_lock = threading.Lock()
|
||||||
|
_status: dict[str, Any] = {
|
||||||
|
"started": False,
|
||||||
|
"tracker_warmup": {},
|
||||||
|
"port_check": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _setting_float(name: str, default: float, minimum: float, maximum: float) -> float:
|
||||||
|
"""Read a bounded worker interval from the environment."""
|
||||||
|
# Note: Defaults keep the worker light while still making UI-independent caches fresh after startup.
|
||||||
|
try:
|
||||||
|
value = float(os.environ.get(name, str(default)))
|
||||||
|
except Exception:
|
||||||
|
value = default
|
||||||
|
return max(minimum, min(maximum, value))
|
||||||
|
|
||||||
|
|
||||||
|
def _profiles() -> list[dict[str, Any]]:
|
||||||
|
"""Read every rTorrent profile directly from the database."""
|
||||||
|
# Note: The worker cannot rely on active browser session state, so it iterates real configured profiles.
|
||||||
|
with connect() as conn:
|
||||||
|
return [dict(row) for row in conn.execute("SELECT * FROM rtorrent_profiles ORDER BY id").fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def _owner_user_id(profile: dict[str, Any]) -> int:
|
||||||
|
"""Return the profile owner used for profile-scoped preferences."""
|
||||||
|
return int(profile.get("user_id") or default_user_id())
|
||||||
|
|
||||||
|
|
||||||
|
def _connected(profile: dict[str, Any]) -> tuple[bool, str]:
|
||||||
|
"""Check rTorrent connectivity without changing user state."""
|
||||||
|
try:
|
||||||
|
rtorrent.client_for(profile).call("system.client_version")
|
||||||
|
return True, ""
|
||||||
|
except Exception as exc:
|
||||||
|
return False, str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _remember(section: str, profile_id: int, payload: dict[str, Any]) -> None:
|
||||||
|
"""Store lightweight in-memory diagnostics for app/status."""
|
||||||
|
# Note: Cache warmups are not user operations, so they stay out of operation logs by default.
|
||||||
|
with _status_lock:
|
||||||
|
data = dict(_status.get(section) or {})
|
||||||
|
data[str(profile_id)] = {**payload, "updated_at_epoch": time.time()}
|
||||||
|
_status[section] = data
|
||||||
|
|
||||||
|
|
||||||
|
def status() -> dict[str, Any]:
|
||||||
|
"""Return current worker diagnostics for system status endpoints."""
|
||||||
|
with _status_lock:
|
||||||
|
return {
|
||||||
|
"started": bool(_status.get("started")),
|
||||||
|
"startup_delay_seconds": STARTUP_DELAY_SECONDS,
|
||||||
|
"tracker_warmup": dict(_status.get("tracker_warmup") or {}),
|
||||||
|
"port_check": dict(_status.get("port_check") or {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _tracker_domains_from_rows(rows: list[dict[str, Any]], summary: dict[str, Any], profile_id: int) -> list[str]:
|
||||||
|
"""Build a bounded tracker domain list from fresh summary data and cached rows."""
|
||||||
|
domains = [str(item.get("domain") or "") for item in summary.get("trackers") or []]
|
||||||
|
if not domains:
|
||||||
|
domains = tracker_cache.cached_domains_for_profile(profile_id, limit=200)
|
||||||
|
return domains
|
||||||
|
|
||||||
|
|
||||||
|
def _warm_tracker_profile(profile: dict[str, Any]) -> None:
|
||||||
|
"""Warm tracker summary cache and optional favicon cache for one profile."""
|
||||||
|
# Note: This mirrors the sidebar warmup, but runs from the backend scheduler instead of waiting for the filter panel.
|
||||||
|
profile_id = int(profile.get("id") or 0)
|
||||||
|
if not profile_id:
|
||||||
|
return
|
||||||
|
ok, error = _connected(profile)
|
||||||
|
if not ok:
|
||||||
|
_remember("tracker_warmup", profile_id, {"ok": False, "skipped": True, "reason": "rtorrent_disconnected", "error": error})
|
||||||
|
return
|
||||||
|
|
||||||
|
owner_id = _owner_user_id(profile)
|
||||||
|
prefs = preferences.get_preferences(owner_id, profile_id)
|
||||||
|
rows = torrent_cache.snapshot(profile_id)
|
||||||
|
if not rows:
|
||||||
|
torrent_cache.refresh(profile)
|
||||||
|
rows = torrent_cache.snapshot(profile_id)
|
||||||
|
hashes = [str(row.get("hash") or "") for row in rows if row.get("hash")]
|
||||||
|
if not hashes:
|
||||||
|
_remember("tracker_warmup", profile_id, {"ok": True, "skipped": True, "reason": "no_torrents"})
|
||||||
|
return
|
||||||
|
|
||||||
|
loader = lambda h: rtorrent.torrent_trackers(profile, h)
|
||||||
|
summary = tracker_cache.summary(profile, hashes, loader, scan_limit=tracker_cache.TRACKER_SCAN_LIMIT, include_favicons=False)
|
||||||
|
warming = False
|
||||||
|
if int(summary.get("pending") or 0) > 0:
|
||||||
|
warming = tracker_cache.warm_summary_cache(profile, hashes, loader, batch_size=tracker_cache.TRACKER_SCAN_LIMIT)
|
||||||
|
|
||||||
|
favicon_result = {"checked": 0, "cached": 0, "errors": []}
|
||||||
|
if bool((prefs or {}).get("tracker_favicons_enabled")):
|
||||||
|
domains = _tracker_domains_from_rows(rows, summary, profile_id)
|
||||||
|
favicon_result = tracker_cache.warm_favicon_cache(domains, enabled=True, limit=FAVICON_BATCH_SIZE, force=False)
|
||||||
|
|
||||||
|
_remember(
|
||||||
|
"tracker_warmup",
|
||||||
|
profile_id,
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"hashes": len(hashes),
|
||||||
|
"pending": int(summary.get("pending") or 0),
|
||||||
|
"scanned_now": int(summary.get("scanned_now") or 0),
|
||||||
|
"warming": bool(warming),
|
||||||
|
"favicons_enabled": bool((prefs or {}).get("tracker_favicons_enabled")),
|
||||||
|
"favicons": favicon_result,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_port_profile(profile: dict[str, Any]) -> None:
|
||||||
|
"""Refresh incoming-port status when the profile preference enables it."""
|
||||||
|
# Note: force=False respects the existing six-hour cache and avoids unnecessary external checks.
|
||||||
|
profile_id = int(profile.get("id") or 0)
|
||||||
|
if not profile_id:
|
||||||
|
return
|
||||||
|
owner_id = _owner_user_id(profile)
|
||||||
|
prefs = preferences.get_preferences(owner_id, profile_id)
|
||||||
|
if not bool((prefs or {}).get("port_check_enabled")):
|
||||||
|
_remember("port_check", profile_id, {"ok": True, "enabled": False, "skipped": True, "reason": "disabled"})
|
||||||
|
return
|
||||||
|
result = port_check.port_check_status(profile=profile, force=False, user_id=owner_id)
|
||||||
|
_remember(
|
||||||
|
"port_check",
|
||||||
|
profile_id,
|
||||||
|
{
|
||||||
|
"ok": not bool(result.get("error") and result.get("source") == "none"),
|
||||||
|
"enabled": True,
|
||||||
|
"status": result.get("status"),
|
||||||
|
"cached": bool(result.get("cached")),
|
||||||
|
"checked_at": result.get("checked_at"),
|
||||||
|
"error": result.get("error") or result.get("fallback_error") or "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def start_scheduler(socketio=None) -> None:
|
||||||
|
"""Start browser-independent cache warmup and port-check scheduler."""
|
||||||
|
global _started
|
||||||
|
with _start_lock:
|
||||||
|
if _started:
|
||||||
|
return
|
||||||
|
_started = True
|
||||||
|
with _status_lock:
|
||||||
|
_status["started"] = True
|
||||||
|
|
||||||
|
tracker_interval = _setting_float("PYTORRENT_CACHE_WARMUP_INTERVAL_SECONDS", DEFAULT_TRACKER_INTERVAL_SECONDS, 60.0, 24 * 60 * 60.0)
|
||||||
|
port_interval = _setting_float("PYTORRENT_PORT_CHECK_INTERVAL_SECONDS", DEFAULT_PORT_INTERVAL_SECONDS, 60.0, 24 * 60 * 60.0)
|
||||||
|
|
||||||
|
def runner() -> None:
|
||||||
|
time.sleep(STARTUP_DELAY_SECONDS)
|
||||||
|
last_tracker: dict[int, float] = {}
|
||||||
|
last_port: dict[int, float] = {}
|
||||||
|
while True:
|
||||||
|
now = time.monotonic()
|
||||||
|
next_sleep = 60.0
|
||||||
|
for profile in _profiles():
|
||||||
|
profile_id = int(profile.get("id") or 0)
|
||||||
|
if not profile_id:
|
||||||
|
continue
|
||||||
|
if now - float(last_tracker.get(profile_id) or 0.0) >= tracker_interval:
|
||||||
|
last_tracker[profile_id] = now
|
||||||
|
try:
|
||||||
|
_warm_tracker_profile(profile)
|
||||||
|
except Exception as exc:
|
||||||
|
_remember("tracker_warmup", profile_id, {"ok": False, "error": str(exc)})
|
||||||
|
if now - float(last_port.get(profile_id) or 0.0) >= port_interval:
|
||||||
|
last_port[profile_id] = now
|
||||||
|
try:
|
||||||
|
_check_port_profile(profile)
|
||||||
|
except Exception as exc:
|
||||||
|
_remember("port_check", profile_id, {"ok": False, "error": str(exc)})
|
||||||
|
next_sleep = min(
|
||||||
|
next_sleep,
|
||||||
|
max(1.0, tracker_interval - (time.monotonic() - float(last_tracker.get(profile_id) or 0.0))),
|
||||||
|
max(1.0, port_interval - (time.monotonic() - float(last_port.get(profile_id) or 0.0))),
|
||||||
|
)
|
||||||
|
sleep_for = max(5.0, min(60.0, next_sleep))
|
||||||
|
if socketio:
|
||||||
|
socketio.sleep(sleep_for)
|
||||||
|
else:
|
||||||
|
time.sleep(sleep_for)
|
||||||
|
|
||||||
|
if socketio:
|
||||||
|
socketio.start_background_task(runner)
|
||||||
|
else:
|
||||||
|
threading.Thread(target=runner, daemon=True, name="pytorrent-cache-warmup-scheduler").start()
|
||||||
+151
-42
@@ -1,5 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -15,27 +14,46 @@ APP_BACKUP_TABLES = [
|
|||||||
"rtorrent_config_overrides", "poller_settings", "app_settings", "download_plan_settings",
|
"rtorrent_config_overrides", "poller_settings", "app_settings", "download_plan_settings",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Note: Profile backups contain active profile data. User-specific preferences remain scoped to the current user.
|
# Note: Profile backups contain profile behavior plus user-specific view preferences for the user creating the backup.
|
||||||
PROFILE_BACKUP_TABLES = [
|
PROFILE_BACKUP_TABLES = [
|
||||||
"rtorrent_profiles", "profile_preferences", "disk_monitor_preferences", "labels", "ratio_groups",
|
"rtorrent_profiles", "profile_preferences", "disk_monitor_preferences", "labels", "ratio_groups",
|
||||||
"rss_feeds", "rss_rules", "smart_queue_settings", "smart_queue_exclusions",
|
"rss_feeds", "rss_rules", "smart_queue_settings", "smart_queue_exclusions",
|
||||||
"automation_rules", "rtorrent_config_overrides", "poller_settings", "download_plan_settings",
|
"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 = {
|
PROFILE_TABLE_FILTERS = {
|
||||||
"rtorrent_profiles": "id=?",
|
"rtorrent_profiles": "id=?",
|
||||||
"profile_preferences": "user_id=? AND profile_id=?",
|
"profile_preferences": "user_id=? AND profile_id=?",
|
||||||
"disk_monitor_preferences": "user_id=? AND profile_id=?",
|
"disk_monitor_preferences": "profile_id=?",
|
||||||
"labels": "user_id=? AND profile_id=?",
|
"labels": "profile_id=?",
|
||||||
"ratio_groups": "user_id=? AND profile_id=?",
|
"ratio_groups": "profile_id=?",
|
||||||
"rss_feeds": "profile_id=?",
|
"rss_feeds": "profile_id=?",
|
||||||
"rss_rules": "profile_id=?",
|
"rss_rules": "profile_id=?",
|
||||||
"smart_queue_settings": "profile_id=?",
|
"smart_queue_settings": "profile_id=?",
|
||||||
"smart_queue_exclusions": "profile_id=?",
|
"smart_queue_exclusions": "profile_id=?",
|
||||||
"automation_rules": "user_id=? AND profile_id=?",
|
"automation_rules": "profile_id=?",
|
||||||
"rtorrent_config_overrides": "profile_id=?",
|
"rtorrent_config_overrides": "profile_id=?",
|
||||||
"poller_settings": "profile_id=?",
|
"poller_settings": "profile_id=?",
|
||||||
"download_plan_settings": "user_id=? AND profile_id=?",
|
"download_plan_settings": "profile_id=?",
|
||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_AUTO_BACKUP_SETTINGS = {
|
DEFAULT_AUTO_BACKUP_SETTINGS = {
|
||||||
@@ -91,6 +109,41 @@ def _table_rows(conn, table: str, where: str | None = None, params: tuple = ())
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def _store_backup(user_id: int, name: str, backup_type: str, profile_id: int | None, payload: dict) -> dict:
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
@@ -121,17 +174,13 @@ def create_app_backup(name: str, user_id: int | None = None, automatic: bool = F
|
|||||||
|
|
||||||
def create_profile_backup(name: str, profile_id: int, user_id: int | None = None, automatic: bool = False) -> dict:
|
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()
|
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||||
if not auth.can_access_profile(profile_id, user_id):
|
if not auth.can_write_profile(profile_id, user_id):
|
||||||
raise PermissionError("No access to profile")
|
raise PermissionError("No write access to profile")
|
||||||
payload = {"version": 2, "backup_type": "profile", "source_profile_id": int(profile_id), "created_at": utcnow(), "automatic": bool(automatic), "tables": {}}
|
payload = {"version": 2, "backup_type": "profile", "source_profile_id": int(profile_id), "created_at": utcnow(), "automatic": bool(automatic), "tables": {}}
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
for table in PROFILE_BACKUP_TABLES:
|
for table in PROFILE_BACKUP_TABLES:
|
||||||
where = PROFILE_TABLE_FILTERS.get(table)
|
where = PROFILE_TABLE_FILTERS.get(table)
|
||||||
if where == "id=?" or where == "profile_id=?":
|
payload["tables"][table] = _table_rows(conn, table, where, _profile_filter_params(table, user_id, int(profile_id)))
|
||||||
params = (int(profile_id),)
|
|
||||||
else:
|
|
||||||
params = (user_id, int(profile_id))
|
|
||||||
payload["tables"][table] = _table_rows(conn, table, where, params)
|
|
||||||
return _store_backup(user_id, name, "profile", int(profile_id), payload)
|
return _store_backup(user_id, name, "profile", int(profile_id), payload)
|
||||||
|
|
||||||
|
|
||||||
@@ -141,26 +190,39 @@ def create_backup(name: str, user_id: int | None = None, automatic: bool = False
|
|||||||
|
|
||||||
def list_backups(user_id: int | None = None, backup_type: str | None = None, profile_id: int | None = None) -> list[dict]:
|
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()
|
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||||
clauses = ["user_id=?"]
|
clauses: list[str] = []
|
||||||
params: list[object] = [user_id]
|
params: list[object] = []
|
||||||
if backup_type:
|
if backup_type:
|
||||||
clauses.append("COALESCE(backup_type,'app')=?")
|
clauses.append("COALESCE(backup_type,'app')=?")
|
||||||
params.append(backup_type)
|
params.append(backup_type)
|
||||||
if profile_id is not None:
|
if profile_id is not None:
|
||||||
clauses.append("profile_id=?")
|
clauses.append("profile_id=?")
|
||||||
params.append(int(profile_id))
|
params.append(int(profile_id))
|
||||||
|
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
f"SELECT id,name,created_at,payload_json,COALESCE(backup_type,'app') AS backup_type,profile_id FROM app_backups WHERE {' AND '.join(clauses)} ORDER BY id DESC",
|
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),
|
tuple(params),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
result = []
|
result = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
|
if not _backup_row_visible(row, user_id):
|
||||||
|
continue
|
||||||
payload = _loads(row.get("payload_json") or "{}")
|
payload = _loads(row.get("payload_json") or "{}")
|
||||||
tables = payload.get("tables") 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({
|
result.append({
|
||||||
"id": row.get("id"),
|
"id": row.get("id"),
|
||||||
"name": row.get("name"),
|
"name": row.get("name"),
|
||||||
|
"owner_user_id": row.get("user_id"),
|
||||||
|
"owner_name": owner_name,
|
||||||
"created_at": row.get("created_at"),
|
"created_at": row.get("created_at"),
|
||||||
"backup_type": row.get("backup_type") or payload.get("backup_type") or "app",
|
"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"),
|
"profile_id": row.get("profile_id") or payload.get("source_profile_id"),
|
||||||
@@ -169,16 +231,14 @@ def list_backups(user_id: int | None = None, backup_type: str | None = None, pro
|
|||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def payload_for_backup(backup_id: int, user_id: int | None = None, require_write: bool = False) -> dict:
|
||||||
def payload_for_backup(backup_id: int, user_id: int | None = None) -> dict:
|
|
||||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
row = conn.execute("SELECT payload_json FROM app_backups WHERE id=? AND user_id=?", (backup_id, user_id)).fetchone()
|
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:
|
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")
|
raise ValueError("Backup not found")
|
||||||
return json.loads(row["payload_json"] or "{}")
|
return json.loads(row["payload_json"] or "{}")
|
||||||
|
|
||||||
|
|
||||||
def _backup_type(payload: dict) -> str:
|
def _backup_type(payload: dict) -> str:
|
||||||
return str(payload.get("backup_type") or ("profile" if payload.get("source_profile_id") else "app"))
|
return str(payload.get("backup_type") or ("profile" if payload.get("source_profile_id") else "app"))
|
||||||
|
|
||||||
@@ -186,7 +246,7 @@ def _backup_type(payload: dict) -> str:
|
|||||||
def restore_app_backup(backup_id: int, user_id: int | None = None) -> dict:
|
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()
|
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||||
_require_admin(user_id)
|
_require_admin(user_id)
|
||||||
payload = payload_for_backup(backup_id, user_id)
|
payload = payload_for_backup(backup_id, user_id, require_write=True)
|
||||||
if _backup_type(payload) != "app":
|
if _backup_type(payload) != "app":
|
||||||
raise ValueError("This is not an application backup")
|
raise ValueError("This is not an application backup")
|
||||||
tables = payload.get("tables") or {}
|
tables = payload.get("tables") or {}
|
||||||
@@ -212,6 +272,12 @@ def restore_app_backup(backup_id: int, user_id: int | None = None) -> dict:
|
|||||||
return {"restored": restored, "backup_type": "app"}
|
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:
|
def _rewrite_profile_row(table: str, row: dict, user_id: int, target_profile_id: int) -> dict:
|
||||||
clean = dict(row)
|
clean = dict(row)
|
||||||
if table == "rtorrent_profiles":
|
if table == "rtorrent_profiles":
|
||||||
@@ -234,7 +300,7 @@ def restore_profile_backup(backup_id: int, target_profile_id: int, user_id: int
|
|||||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||||
if not auth.can_write_profile(target_profile_id, user_id):
|
if not auth.can_write_profile(target_profile_id, user_id):
|
||||||
raise PermissionError("No write access to profile")
|
raise PermissionError("No write access to profile")
|
||||||
payload = payload_for_backup(backup_id, user_id)
|
payload = payload_for_backup(backup_id, user_id, require_write=True)
|
||||||
if _backup_type(payload) != "profile":
|
if _backup_type(payload) != "profile":
|
||||||
raise ValueError("This is not a profile backup")
|
raise ValueError("This is not a profile backup")
|
||||||
tables = payload.get("tables") or {}
|
tables = payload.get("tables") or {}
|
||||||
@@ -244,11 +310,10 @@ def restore_profile_backup(backup_id: int, target_profile_id: int, user_id: int
|
|||||||
try:
|
try:
|
||||||
for table in PROFILE_BACKUP_TABLES:
|
for table in PROFILE_BACKUP_TABLES:
|
||||||
rows = tables.get(table) or []
|
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)
|
where = PROFILE_TABLE_FILTERS.get(table)
|
||||||
if where == "id=?" or where == "profile_id=?":
|
params = _profile_filter_params(table, user_id, int(target_profile_id))
|
||||||
params = (int(target_profile_id),)
|
|
||||||
else:
|
|
||||||
params = (user_id, int(target_profile_id))
|
|
||||||
conn.execute(f"DELETE FROM {table} WHERE {where}", params)
|
conn.execute(f"DELETE FROM {table} WHERE {where}", params)
|
||||||
if not rows:
|
if not rows:
|
||||||
continue
|
continue
|
||||||
@@ -269,7 +334,7 @@ def restore_profile_backup(backup_id: int, target_profile_id: int, user_id: int
|
|||||||
|
|
||||||
|
|
||||||
def restore_backup(backup_id: int, user_id: int | None = None, profile_id: int | None = None) -> dict:
|
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)
|
payload = payload_for_backup(backup_id, user_id, require_write=True)
|
||||||
if _backup_type(payload) == "profile":
|
if _backup_type(payload) == "profile":
|
||||||
target = profile_id or payload.get("source_profile_id")
|
target = profile_id or payload.get("source_profile_id")
|
||||||
if not target:
|
if not target:
|
||||||
@@ -281,26 +346,30 @@ def restore_backup(backup_id: int, user_id: int | None = None, profile_id: int |
|
|||||||
def delete_backup(backup_id: int, user_id: int | None = None) -> dict:
|
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()
|
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
cur = conn.execute("DELETE FROM app_backups WHERE id=? AND user_id=?", (backup_id, user_id))
|
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:
|
if not cur.rowcount:
|
||||||
raise ValueError("Backup not found")
|
raise ValueError("Backup not found")
|
||||||
return {"deleted": backup_id}
|
return {"deleted": backup_id}
|
||||||
|
|
||||||
|
|
||||||
def _settings_row_key(user_id: int | None = None, backup_type: str = "app", profile_id: int | None = None) -> str:
|
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()
|
uid = user_id or auth.current_user_id() or default_user_id()
|
||||||
scope = "profile" if backup_type == "profile" else "app"
|
scope = "profile" if backup_type == "profile" else "app"
|
||||||
if scope == "profile":
|
if scope == "profile":
|
||||||
return f"{AUTO_BACKUP_SETTINGS_KEY}:profile:{uid}:{int(profile_id or 0)}"
|
return f"{AUTO_BACKUP_SETTINGS_KEY}:profile:{int(profile_id or 0)}"
|
||||||
return f"{AUTO_BACKUP_SETTINGS_KEY}:app:{uid}"
|
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:
|
def _latest_backup_created_at(user_id: int, backup_type: str = "app", profile_id: int | None = None) -> str | None:
|
||||||
clauses = ["user_id=?", "COALESCE(backup_type,'app')=?"]
|
clauses = ["COALESCE(backup_type,'app')=?"]
|
||||||
params: list[object] = [user_id, backup_type]
|
params: list[object] = [backup_type]
|
||||||
if backup_type == "profile":
|
if backup_type == "profile":
|
||||||
clauses.append("profile_id=?")
|
clauses.append("profile_id=?")
|
||||||
params.append(int(profile_id or 0))
|
params.append(int(profile_id or 0))
|
||||||
|
else:
|
||||||
|
clauses.append("user_id=?")
|
||||||
|
params.append(user_id)
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
f"SELECT created_at FROM app_backups WHERE {' AND '.join(clauses)} ORDER BY created_at DESC, id DESC LIMIT 1",
|
f"SELECT created_at FROM app_backups WHERE {' AND '.join(clauses)} ORDER BY created_at DESC, id DESC LIMIT 1",
|
||||||
@@ -308,7 +377,6 @@ def _latest_backup_created_at(user_id: int, backup_type: str = "app", profile_id
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
return str(row["created_at"] or "") if row and row.get("created_at") else None
|
return str(row["created_at"] or "") if row and row.get("created_at") else None
|
||||||
|
|
||||||
|
|
||||||
def _preview_value(value: object) -> object:
|
def _preview_value(value: object) -> object:
|
||||||
if value is None or isinstance(value, (int, float, bool)):
|
if value is None or isinstance(value, (int, float, bool)):
|
||||||
return value
|
return value
|
||||||
@@ -325,9 +393,13 @@ def _preview_row(row: dict) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def get_auto_backup_settings(user_id: int | None = None, backup_type: str = "app", profile_id: int | None = None) -> dict:
|
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)
|
key = _settings_row_key(user_id, backup_type, profile_id)
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (key,)).fetchone()
|
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 = {**DEFAULT_AUTO_BACKUP_SETTINGS, **_loads(row.get("value") if row else "{}")}
|
||||||
settings["enabled"] = bool(settings.get("enabled"))
|
settings["enabled"] = bool(settings.get("enabled"))
|
||||||
settings["interval_hours"] = max(1, int(settings.get("interval_hours") or 24))
|
settings["interval_hours"] = max(1, int(settings.get("interval_hours") or 24))
|
||||||
@@ -335,6 +407,9 @@ def get_auto_backup_settings(user_id: int | None = None, backup_type: str = "app
|
|||||||
settings["backup_type"] = "profile" if backup_type == "profile" else "app"
|
settings["backup_type"] = "profile" if backup_type == "profile" else "app"
|
||||||
if backup_type == "profile":
|
if backup_type == "profile":
|
||||||
settings["profile_id"] = int(profile_id or 0)
|
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
|
return settings
|
||||||
|
|
||||||
|
|
||||||
@@ -361,11 +436,28 @@ def save_auto_backup_settings(data: dict, user_id: int | None = None, backup_typ
|
|||||||
return settings
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def preview_backup(backup_id: int, user_id: int | None = None) -> dict:
|
||||||
payload = payload_for_backup(backup_id, user_id)
|
payload = payload_for_backup(backup_id, user_id)
|
||||||
tables = payload.get("tables") or {}
|
tables = payload.get("tables") or {}
|
||||||
|
owner = _backup_owner_info(backup_id)
|
||||||
return {
|
return {
|
||||||
"version": payload.get("version"),
|
"version": payload.get("version"),
|
||||||
|
"owner_user_id": owner.get("owner_user_id"),
|
||||||
|
"owner_name": owner.get("owner_name"),
|
||||||
"created_at": payload.get("created_at"),
|
"created_at": payload.get("created_at"),
|
||||||
"backup_type": _backup_type(payload),
|
"backup_type": _backup_type(payload),
|
||||||
"source_profile_id": payload.get("source_profile_id"),
|
"source_profile_id": payload.get("source_profile_id"),
|
||||||
@@ -385,16 +477,18 @@ 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, backup_type: str = "app", profile_id: int | None = None) -> int:
|
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()
|
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")
|
cutoff = (datetime.now(timezone.utc) - timedelta(days=max(1, int(retention_days)))).isoformat(timespec="seconds")
|
||||||
clauses = ["user_id=?", "COALESCE(backup_type,'app')=?", "created_at<?"]
|
clauses = ["COALESCE(backup_type,'app')=?", "created_at<?"]
|
||||||
params: list[object] = [user_id, backup_type, cutoff]
|
params: list[object] = [backup_type, cutoff]
|
||||||
if backup_type == "profile":
|
if backup_type == "profile":
|
||||||
clauses.append("profile_id=?")
|
clauses.append("profile_id=?")
|
||||||
params.append(int(profile_id or 0))
|
params.append(int(profile_id or 0))
|
||||||
|
else:
|
||||||
|
clauses.append("user_id=?")
|
||||||
|
params.append(user_id)
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
cur = conn.execute(f"DELETE FROM app_backups WHERE {' AND '.join(clauses)}", tuple(params))
|
cur = conn.execute(f"DELETE FROM app_backups WHERE {' AND '.join(clauses)}", tuple(params))
|
||||||
return int(cur.rowcount or 0)
|
return int(cur.rowcount or 0)
|
||||||
|
|
||||||
|
|
||||||
def _should_run(settings: dict, last_value: str | None) -> bool:
|
def _should_run(settings: dict, last_value: str | None) -> bool:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
try:
|
try:
|
||||||
@@ -433,18 +527,33 @@ def maybe_create_automatic_backup(user_id: int | None = None, backup_type: str =
|
|||||||
|
|
||||||
def _profile_schedule_keys() -> list[tuple[int, int]]:
|
def _profile_schedule_keys() -> list[tuple[int, int]]:
|
||||||
prefix = f"{AUTO_BACKUP_SETTINGS_KEY}:profile:"
|
prefix = f"{AUTO_BACKUP_SETTINGS_KEY}:profile:"
|
||||||
keys: list[tuple[int, int]] = []
|
keys: set[tuple[int, int]] = set()
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
rows = conn.execute("SELECT key FROM app_settings WHERE key LIKE ?", (prefix + "%",)).fetchall()
|
rows = conn.execute("SELECT key FROM app_settings WHERE key LIKE ?", (prefix + "%",)).fetchall()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
parts = str(row.get("key") or "").split(":")
|
parts = str(row.get("key") or "").split(":")
|
||||||
try:
|
try:
|
||||||
keys.append((int(parts[-2]), int(parts[-1])))
|
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:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
return keys
|
return sorted(keys)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def start_scheduler() -> None:
|
||||||
global _scheduler_started
|
global _scheduler_started
|
||||||
with _scheduler_lock:
|
with _scheduler_lock:
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
from ..config import DB_PATH
|
||||||
|
|
||||||
|
_VACUUM_LOCK = threading.Lock()
|
||||||
|
MIN_DISK_HEADROOM_BYTES = 128 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _human_size(value: int | float | None) -> str:
|
||||||
|
size = float(value or 0)
|
||||||
|
units = ["B", "KiB", "MiB", "GiB", "TiB"]
|
||||||
|
idx = 0
|
||||||
|
while size >= 1024 and idx < len(units) - 1:
|
||||||
|
size /= 1024.0
|
||||||
|
idx += 1
|
||||||
|
if idx == 0:
|
||||||
|
return f"{int(size)} {units[idx]}"
|
||||||
|
return f"{size:.2f} {units[idx]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _connect() -> sqlite3.Connection:
|
||||||
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(DB_PATH, timeout=60, isolation_level=None)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA busy_timeout = 60000")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _pragma_int(conn: sqlite3.Connection, pragma_name: str) -> int:
|
||||||
|
row = conn.execute(f"PRAGMA {pragma_name}").fetchone()
|
||||||
|
if row is None:
|
||||||
|
return 0
|
||||||
|
return int(row[0] or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def database_status() -> dict[str, Any]:
|
||||||
|
size_bytes = DB_PATH.stat().st_size if DB_PATH.exists() else 0
|
||||||
|
wal_path = DB_PATH.with_name(DB_PATH.name + "-wal")
|
||||||
|
wal_bytes = wal_path.stat().st_size if wal_path.exists() else 0
|
||||||
|
page_size = 0
|
||||||
|
page_count = 0
|
||||||
|
freelist_count = 0
|
||||||
|
error = None
|
||||||
|
if DB_PATH.exists():
|
||||||
|
try:
|
||||||
|
with _connect() as conn:
|
||||||
|
page_size = _pragma_int(conn, "page_size")
|
||||||
|
page_count = _pragma_int(conn, "page_count")
|
||||||
|
freelist_count = _pragma_int(conn, "freelist_count")
|
||||||
|
except Exception as exc:
|
||||||
|
error = str(exc)
|
||||||
|
free_bytes = int(page_size * freelist_count)
|
||||||
|
logical_bytes = int(page_size * page_count)
|
||||||
|
free_ratio = (free_bytes / logical_bytes) if logical_bytes else 0.0
|
||||||
|
try:
|
||||||
|
disk = shutil.disk_usage(str(DB_PATH.parent))
|
||||||
|
disk_free = int(disk.free)
|
||||||
|
except Exception:
|
||||||
|
disk_free = 0
|
||||||
|
return {
|
||||||
|
"path": str(DB_PATH),
|
||||||
|
"size": int(size_bytes),
|
||||||
|
"size_h": _human_size(size_bytes),
|
||||||
|
"wal_size": int(wal_bytes),
|
||||||
|
"wal_size_h": _human_size(wal_bytes),
|
||||||
|
"page_size": page_size,
|
||||||
|
"page_count": page_count,
|
||||||
|
"freelist_count": freelist_count,
|
||||||
|
"free_inside": free_bytes,
|
||||||
|
"free_inside_h": _human_size(free_bytes),
|
||||||
|
"free_ratio": round(free_ratio, 4),
|
||||||
|
"free_ratio_percent": round(free_ratio * 100, 2),
|
||||||
|
"disk_free": disk_free,
|
||||||
|
"disk_free_h": _human_size(disk_free),
|
||||||
|
"vacuum_running": _VACUUM_LOCK.locked(),
|
||||||
|
"error": error,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _checkpoint_truncate(conn: sqlite3.Connection) -> dict[str, int] | None:
|
||||||
|
try:
|
||||||
|
row = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return {"busy": int(row[0] or 0), "log": int(row[1] or 0), "checkpointed": int(row[2] or 0)}
|
||||||
|
except sqlite3.DatabaseError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def vacuum_database(force: bool = False) -> dict[str, Any]:
|
||||||
|
if not DB_PATH.exists():
|
||||||
|
raise FileNotFoundError(f"Database not found: {DB_PATH}")
|
||||||
|
if not _VACUUM_LOCK.acquire(blocking=False):
|
||||||
|
raise RuntimeError("Database vacuum is already running")
|
||||||
|
try:
|
||||||
|
before = database_status()
|
||||||
|
required_free = int(before.get("size") or 0) + MIN_DISK_HEADROOM_BYTES
|
||||||
|
available_free = int(before.get("disk_free") or 0)
|
||||||
|
if available_free and available_free < required_free:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Not enough free disk space for VACUUM: "
|
||||||
|
f"need about {_human_size(required_free)}, have {_human_size(available_free)}"
|
||||||
|
)
|
||||||
|
if not force and int(before.get("free_inside") or 0) <= 0:
|
||||||
|
return {"ok": True, "skipped": True, "reason": "No free pages inside SQLite database", "before": before, "after": before}
|
||||||
|
started = time.perf_counter()
|
||||||
|
with _connect() as conn:
|
||||||
|
checkpoint_before = _checkpoint_truncate(conn)
|
||||||
|
conn.execute("VACUUM")
|
||||||
|
checkpoint_after = _checkpoint_truncate(conn)
|
||||||
|
after = database_status()
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"skipped": False,
|
||||||
|
"duration_seconds": round(time.perf_counter() - started, 3),
|
||||||
|
"checkpoint_before": checkpoint_before,
|
||||||
|
"checkpoint_after": checkpoint_after,
|
||||||
|
"before": before,
|
||||||
|
"after": after,
|
||||||
|
"reclaimed": max(0, int(before.get("size") or 0) - int(after.get("size") or 0)),
|
||||||
|
"reclaimed_h": _human_size(max(0, int(before.get("size") or 0) - int(after.get("size") or 0))),
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
_VACUUM_LOCK.release()
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from . import download_planner
|
from . import download_planner
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import psutil
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import psutil
|
|
||||||
|
|
||||||
from ..db import connect, default_user_id, utcnow
|
from ..db import connect, default_user_id, utcnow
|
||||||
from . import rtorrent
|
from . import auth, operation_logs, rtorrent
|
||||||
|
|
||||||
|
PLANNER_STARTUP_DELAY_SECONDS = 60
|
||||||
|
_APP_STARTED_AT = time.monotonic()
|
||||||
|
|
||||||
DEFAULTS = {
|
DEFAULTS = {
|
||||||
"enabled": False,
|
"enabled": False,
|
||||||
@@ -45,6 +46,57 @@ DEFAULTS = {
|
|||||||
_LAST_RUN: dict[int, float] = {}
|
_LAST_RUN: dict[int, float] = {}
|
||||||
_LAST_LIMITS: dict[int, tuple[int, int]] = {}
|
_LAST_LIMITS: dict[int, tuple[int, int]] = {}
|
||||||
_HIGH_CPU_SINCE: dict[int, float] = {}
|
_HIGH_CPU_SINCE: dict[int, float] = {}
|
||||||
|
_PLANNER_CONNECTION_STATUS: dict[int, str] = {}
|
||||||
|
_SCHEDULER_STARTED = False
|
||||||
|
_SCHEDULER_LOCK = threading.Lock()
|
||||||
|
_PROFILE_LOCKS: dict[int, threading.Lock] = {}
|
||||||
|
_PROFILE_LOCKS_GUARD = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_lock(profile_id: int) -> threading.Lock:
|
||||||
|
"""Keep one planner run per profile active at a time."""
|
||||||
|
with _PROFILE_LOCKS_GUARD:
|
||||||
|
if profile_id not in _PROFILE_LOCKS:
|
||||||
|
_PROFILE_LOCKS[profile_id] = threading.Lock()
|
||||||
|
return _PROFILE_LOCKS[profile_id]
|
||||||
|
|
||||||
|
|
||||||
|
def _all_profiles() -> list[dict]:
|
||||||
|
"""Read every configured profile directly from DB for browser-independent background work."""
|
||||||
|
with connect() as conn:
|
||||||
|
return [dict(row) for row in conn.execute("SELECT * FROM rtorrent_profiles ORDER BY id").fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def _owner_user_id(profile: dict) -> int:
|
||||||
|
"""Use the profile owner for background planner checks."""
|
||||||
|
return int(profile.get("user_id") or default_user_id())
|
||||||
|
|
||||||
|
|
||||||
|
def _rtorrent_ready(profile: dict) -> tuple[bool, str]:
|
||||||
|
"""Check rTorrent connectivity before the planner evaluates or applies changes."""
|
||||||
|
try:
|
||||||
|
rtorrent.client_for(profile).call("system.client_version")
|
||||||
|
return True, ""
|
||||||
|
except Exception as exc:
|
||||||
|
return False, str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _log_connection_status(profile: dict, status: str, message: str, *, error: str = "", user_id: int | None = None) -> None:
|
||||||
|
"""Record planner connectivity state changes as system operations without noisy repeats."""
|
||||||
|
profile_id = int(profile.get("id") or 0)
|
||||||
|
if _PLANNER_CONNECTION_STATUS.get(profile_id) == status:
|
||||||
|
return
|
||||||
|
_PLANNER_CONNECTION_STATUS[profile_id] = status
|
||||||
|
operation_logs.record(
|
||||||
|
profile_id,
|
||||||
|
"download_planner_status",
|
||||||
|
message,
|
||||||
|
severity="warning" if error else "info",
|
||||||
|
source="system",
|
||||||
|
action="download_planner",
|
||||||
|
details={"status": status, "error": error},
|
||||||
|
user_id=user_id or int(profile.get("user_id") or 0) or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _bool(value: Any) -> bool:
|
def _bool(value: Any) -> bool:
|
||||||
@@ -140,14 +192,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:
|
with connect() as conn:
|
||||||
return conn.execute(
|
row = conn.execute(
|
||||||
"SELECT * FROM download_plan_settings WHERE user_id=? AND profile_id=?",
|
"SELECT * FROM download_plan_settings WHERE profile_id=? ORDER BY updated_at DESC, user_id ASC LIMIT 1",
|
||||||
(user_id, profile_id),
|
(profile_id,),
|
||||||
).fetchone()
|
).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:
|
def _preference_row_for_disk_source(profile_id: int, user_id: int | None = None) -> dict | None:
|
||||||
@@ -269,12 +338,13 @@ def get_settings(profile_id: int, user_id: int | None = None) -> dict:
|
|||||||
row = _row(user_id, profile_id)
|
row = _row(user_id, profile_id)
|
||||||
if not row:
|
if not row:
|
||||||
migrated = normalize({**DEFAULTS, **_legacy_disk_guard_defaults(int(profile_id), user_id)})
|
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:
|
try:
|
||||||
data = json.loads(row.get("settings_json") or "{}")
|
data = json.loads(row.get("settings_json") or "{}")
|
||||||
except Exception:
|
except Exception:
|
||||||
data = {}
|
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))
|
runtime_override = _override_until(int(profile_id))
|
||||||
if runtime_override:
|
if runtime_override:
|
||||||
settings["manual_override_until"] = runtime_override
|
settings["manual_override_until"] = runtime_override
|
||||||
@@ -283,18 +353,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:
|
def save_settings(profile_id: int, data: dict, user_id: int | None = None) -> dict:
|
||||||
user_id = user_id or default_user_id()
|
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)
|
settings = normalize(data)
|
||||||
now = utcnow()
|
now = utcnow()
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
|
conn.execute("DELETE FROM download_plan_settings WHERE profile_id=?", (int(profile_id),))
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO download_plan_settings(user_id, profile_id, settings_json, updated_at)
|
INSERT INTO download_plan_settings(user_id, profile_id, settings_json, updated_at)
|
||||||
VALUES(?,?,?,?)
|
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),
|
(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]:
|
def _active_downloading_hashes(profile: dict) -> list[str]:
|
||||||
@@ -303,7 +375,7 @@ def _active_downloading_hashes(profile: dict) -> list[str]:
|
|||||||
for row in rows:
|
for row in rows:
|
||||||
if int(row.get("complete") or 0):
|
if int(row.get("complete") or 0):
|
||||||
continue
|
continue
|
||||||
if int(row.get("state") or 0) and not row.get("paused"):
|
if int(row.get("state") or 0) and not row.get("paused") and str(row.get("status") or "") != "Queued":
|
||||||
h = str(row.get("hash") or "")
|
h = str(row.get("hash") or "")
|
||||||
if h:
|
if h:
|
||||||
hashes.append(h)
|
hashes.append(h)
|
||||||
@@ -445,16 +517,26 @@ def evaluate(profile: dict, settings: dict | None = None, now: datetime | None =
|
|||||||
|
|
||||||
def enforce(profile: dict, force: bool = False, user_id: int | None = None) -> dict:
|
def enforce(profile: dict, force: bool = False, user_id: int | None = None) -> dict:
|
||||||
profile_id = int(profile.get("id") or 0)
|
profile_id = int(profile.get("id") or 0)
|
||||||
user_id = user_id or int(profile.get("user_id") or default_user_id())
|
settings = get_settings(profile_id, user_id or int(profile.get("user_id") or default_user_id()))
|
||||||
# Note: Background planner runs without Flask session state, so settings are resolved with the profile owner.
|
user_id = int(settings.get("owner_user_id") or user_id or profile.get("user_id") or default_user_id())
|
||||||
settings = get_settings(profile_id, 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"):
|
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, user_id=user_id)}
|
return {"ok": True, "enabled": False, "profile_id": profile_id, "history": history(profile_id, 20), "history_total": history_count(profile_id), "preview": preview(profile, user_id=user_id)}
|
||||||
|
startup_remaining = int(PLANNER_STARTUP_DELAY_SECONDS - (time.monotonic() - _APP_STARTED_AT))
|
||||||
|
if not force and startup_remaining > 0:
|
||||||
|
# Note: The background planner keeps the same startup grace as rTorrent config apply, while manual checks still run immediately.
|
||||||
|
return {"ok": True, "enabled": True, "profile_id": profile_id, "skipped": True, "reason": "startup_delay", "retry_after_seconds": startup_remaining}
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
interval = int(settings.get("check_interval_seconds") or 30)
|
interval = int(settings.get("check_interval_seconds") or 30)
|
||||||
if not force and now - _LAST_RUN.get(profile_id, 0) < interval:
|
if not force and now - _LAST_RUN.get(profile_id, 0) < interval:
|
||||||
return {"ok": True, "enabled": True, "profile_id": profile_id, "skipped": True}
|
return {"ok": True, "enabled": True, "profile_id": profile_id, "skipped": True}
|
||||||
_LAST_RUN[profile_id] = now
|
_LAST_RUN[profile_id] = now
|
||||||
|
ready, connection_error = _rtorrent_ready(profile)
|
||||||
|
if not ready:
|
||||||
|
_log_connection_status(profile, "waiting", f"Download Planner is waiting for rTorrent: {connection_error}", error=connection_error, user_id=user_id)
|
||||||
|
return {"ok": True, "enabled": True, "profile_id": profile_id, "skipped": True, "reason": "rtorrent_unavailable", "error": connection_error, "retry_after_seconds": interval}
|
||||||
|
_log_connection_status(profile, "connected", "Download Planner detected a working rTorrent connection", user_id=user_id)
|
||||||
decision = evaluate(profile, settings)
|
decision = evaluate(profile, settings)
|
||||||
result: dict[str, Any] = {"ok": True, "enabled": True, **decision, "limits_changed": False, "paused": 0, "resumed": 0}
|
result: dict[str, Any] = {"ok": True, "enabled": True, **decision, "limits_changed": False, "paused": 0, "resumed": 0}
|
||||||
wanted_limits = (int(decision["down"]), int(decision["up"]))
|
wanted_limits = (int(decision["down"]), int(decision["up"]))
|
||||||
@@ -505,8 +587,7 @@ def enforce(profile: dict, force: bool = False, user_id: int | None = None) -> d
|
|||||||
|
|
||||||
def preview(profile: dict, user_id: int | None = None) -> dict:
|
def preview(profile: dict, user_id: int | None = None) -> dict:
|
||||||
profile_id = int(profile.get("id") or 0)
|
profile_id = int(profile.get("id") or 0)
|
||||||
user_id = user_id or int(profile.get("user_id") or default_user_id())
|
settings = get_settings(profile_id, user_id or int(profile.get("user_id") or default_user_id()))
|
||||||
settings = get_settings(profile_id, user_id)
|
|
||||||
decision = evaluate(profile, settings)
|
decision = evaluate(profile, settings)
|
||||||
return {
|
return {
|
||||||
"profile_id": profile_id,
|
"profile_id": profile_id,
|
||||||
@@ -523,32 +604,42 @@ def preview(profile: dict, user_id: int | None = None) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def start_scheduler(socketio=None) -> None:
|
def start_scheduler(socketio=None) -> None:
|
||||||
|
"""Start the browser-independent planner loop for every configured profile."""
|
||||||
|
global _SCHEDULER_STARTED
|
||||||
|
with _SCHEDULER_LOCK:
|
||||||
|
if _SCHEDULER_STARTED:
|
||||||
|
return
|
||||||
|
_SCHEDULER_STARTED = True
|
||||||
|
|
||||||
def loop():
|
def loop():
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
from .preferences import active_profile
|
|
||||||
from .websocket import emit_profile_event
|
from .websocket import emit_profile_event
|
||||||
from . import auth
|
for profile in _all_profiles():
|
||||||
profiles: list[dict]
|
profile_id = int(profile.get("id") or 0)
|
||||||
if auth.enabled():
|
if not profile_id:
|
||||||
with connect() as conn:
|
continue
|
||||||
profiles = conn.execute("SELECT * FROM rtorrent_profiles ORDER BY id").fetchall()
|
lock = _profile_lock(profile_id)
|
||||||
else:
|
if not lock.acquire(blocking=False):
|
||||||
profile = active_profile()
|
continue
|
||||||
profiles = [profile] if profile else []
|
|
||||||
for profile in profiles:
|
|
||||||
try:
|
try:
|
||||||
result = enforce(profile, force=False)
|
# Note: Background planner runs per configured profile with the profile owner, not only for the active UI profile.
|
||||||
|
result = enforce(profile, force=False, user_id=_owner_user_id(profile))
|
||||||
if socketio and result.get("enabled") and not result.get("skipped"):
|
if socketio and result.get("enabled") and not result.get("skipped"):
|
||||||
emit_profile_event(socketio, "download_plan_update", result, int(profile["id"]))
|
emit_profile_event(socketio, "download_plan_update", result, profile_id)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if socketio:
|
if socketio:
|
||||||
emit_profile_event(socketio, "download_plan_update", {"ok": False, "profile_id": int(profile.get("id") or 0), "error": str(exc)}, int(profile.get("id") or 0))
|
emit_profile_event(socketio, "download_plan_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
|
||||||
|
finally:
|
||||||
|
lock.release()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if socketio:
|
if socketio:
|
||||||
socketio.sleep(30)
|
socketio.sleep(30)
|
||||||
else:
|
else:
|
||||||
time.sleep(30)
|
time.sleep(30)
|
||||||
|
|
||||||
if socketio:
|
if socketio:
|
||||||
socketio.start_background_task(loop)
|
socketio.start_background_task(loop)
|
||||||
|
else:
|
||||||
|
threading.Thread(target=loop, daemon=True, name="pytorrent-download-planner-scheduler").start()
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ..config import BASE_DIR, USE_OFFLINE_LIBS
|
from ..config import BASE_DIR, USE_OFFLINE_LIBS
|
||||||
|
|
||||||
LIBS_STATIC_DIR = "libs"
|
LIBS_STATIC_DIR = "libs"
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from ..config import GEOIP_DB
|
from ..config import GEOIP_DB
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import geoip2.database
|
import geoip2.database
|
||||||
except Exception: # pragma: no cover
|
except Exception:
|
||||||
geoip2 = None
|
geoip2 = None
|
||||||
|
|
||||||
_reader = None
|
_reader = None
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from ..db import connect, utcnow, default_user_id
|
from ..db import connect, utcnow, default_user_id
|
||||||
from . import auth
|
from . import auth
|
||||||
|
|
||||||
DEFAULT_SETTINGS = {"retention_mode": "days", "retention_days": 30, "retention_lines": 5000}
|
|
||||||
VALID_RETENTION_MODES = {"days", "lines", "both", "manual"}
|
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_TEXT = 4000
|
||||||
MAX_DETAIL_ITEMS = 200
|
MAX_DETAIL_ITEMS = 200
|
||||||
|
|
||||||
@@ -70,7 +80,7 @@ def _details_summary(details: dict) -> str:
|
|||||||
priority = [
|
priority = [
|
||||||
"status", "job_id", "attempt", "attempts", "count", "hash_count", "action",
|
"status", "job_id", "attempt", "attempts", "count", "hash_count", "action",
|
||||||
"source", "source_label", "directory", "label", "target_path", "remove_data",
|
"source", "source_label", "directory", "label", "target_path", "remove_data",
|
||||||
"move_data", "keep_seeding", "error", "error_count", "result_count",
|
"move_data", "target_profile_id", "move_data_downgraded", "keep_seeding", "error", "error_count", "result_count",
|
||||||
]
|
]
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
for key in priority:
|
for key in priority:
|
||||||
@@ -99,61 +109,177 @@ def _row_to_public(row: dict) -> dict:
|
|||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_mode(value: Any, default: str = "days") -> str:
|
||||||
|
mode = str(value or default).lower()
|
||||||
|
return mode if mode in VALID_RETENTION_MODES else default
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_days(value: Any, default: int) -> int:
|
||||||
|
return max(1, min(3650, int(value or default)))
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_lines(value: Any, default: int) -> int:
|
||||||
|
return max(100, min(1_000_000, int(value or default)))
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_interval(value: Any, default: int = 24) -> int:
|
||||||
|
return max(1, min(8760, int(value or default)))
|
||||||
|
|
||||||
|
|
||||||
|
def _log_category(event_type: str = "", source: str = "") -> str:
|
||||||
|
return "job" if str(source or "") in {"job", "worker"} or str(event_type or "").startswith("job_") else "operation"
|
||||||
|
|
||||||
|
|
||||||
|
def _category_where(category: str) -> str:
|
||||||
|
if category == "job":
|
||||||
|
return "(COALESCE(source, '') IN ('job', 'worker') OR event_type LIKE 'job_%')"
|
||||||
|
return "NOT (COALESCE(source, '') IN ('job', 'worker') OR event_type LIKE 'job_%')"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_dt(value: Any) -> datetime | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
text = str(value).replace("Z", "+00:00")
|
||||||
|
dt = datetime.fromisoformat(text)
|
||||||
|
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _next_retention_run(settings: dict, category: str) -> str | None:
|
||||||
|
last = _parse_dt(settings.get(f"{category}_last_retention_run_at"))
|
||||||
|
if not last:
|
||||||
|
return None
|
||||||
|
return (last + timedelta(hours=int(settings.get(f"{category}_retention_interval_hours") or 24))).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_settings_owner_id() -> int:
|
||||||
|
"""Use one canonical owner for profile-level retention settings."""
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def get_settings(profile_id: int = 0, user_id: int | None = None) -> dict:
|
def get_settings(profile_id: int = 0, user_id: int | None = None) -> dict:
|
||||||
user_id = _user_id(user_id)
|
"""Return profile-level retention settings, with legacy per-user rows as fallback only."""
|
||||||
profile_id = int(profile_id or 0)
|
profile_id = int(profile_id or 0)
|
||||||
|
owner_id = _profile_settings_owner_id()
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT * FROM operation_log_settings WHERE user_id=? AND profile_id=?",
|
"""
|
||||||
(user_id, profile_id),
|
SELECT *
|
||||||
|
FROM operation_log_settings
|
||||||
|
WHERE profile_id=?
|
||||||
|
ORDER BY CASE WHEN user_id=? THEN 0 ELSE 1 END, updated_at DESC, user_id ASC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(profile_id, owner_id),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
return {"user_id": user_id, "profile_id": profile_id, **DEFAULT_SETTINGS}
|
data = {"owner_user_id": owner_id, "profile_id": profile_id, **DEFAULT_SETTINGS}
|
||||||
data = {**DEFAULT_SETTINGS, **dict(row)}
|
else:
|
||||||
data["retention_mode"] = data.get("retention_mode") if data.get("retention_mode") in VALID_RETENTION_MODES else "days"
|
data = {**DEFAULT_SETTINGS, **dict(row)}
|
||||||
data["retention_days"] = max(1, int(data.get("retention_days") or DEFAULT_SETTINGS["retention_days"]))
|
data["owner_user_id"] = int(data.pop("user_id", owner_id) or owner_id)
|
||||||
data["retention_lines"] = max(100, int(data.get("retention_lines") or DEFAULT_SETTINGS["retention_lines"]))
|
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
|
return data
|
||||||
|
|
||||||
|
|
||||||
def save_settings(profile_id: int, data: dict, user_id: int | None = None) -> dict:
|
def save_settings(profile_id: int, data: dict, user_id: int | None = None) -> dict:
|
||||||
user_id = _user_id(user_id)
|
user_id = _user_id(user_id)
|
||||||
profile_id = int(profile_id or 0)
|
profile_id = int(profile_id or 0)
|
||||||
mode = str(data.get("retention_mode") or "days").lower()
|
owner_id = _profile_settings_owner_id()
|
||||||
if mode not in VALID_RETENTION_MODES:
|
|
||||||
mode = "days"
|
|
||||||
days = max(1, min(3650, int(data.get("retention_days") or DEFAULT_SETTINGS["retention_days"])))
|
|
||||||
lines = max(100, min(1_000_000, int(data.get("retention_lines") or DEFAULT_SETTINGS["retention_lines"])))
|
|
||||||
now = utcnow()
|
now = utcnow()
|
||||||
|
if not auth.can_write_profile(profile_id, user_id):
|
||||||
|
raise PermissionError("No write access to profile")
|
||||||
|
# Note: retention is intentionally shared by every user that works on the same profile.
|
||||||
|
current = get_settings(profile_id, user_id)
|
||||||
|
legacy_mode = _sanitize_mode(data.get("retention_mode") or current.get("retention_mode"), DEFAULT_SETTINGS["retention_mode"])
|
||||||
|
legacy_days = _sanitize_days(data.get("retention_days") or current.get("retention_days"), DEFAULT_SETTINGS["retention_days"])
|
||||||
|
legacy_lines = _sanitize_lines(data.get("retention_lines") or current.get("retention_lines"), DEFAULT_SETTINGS["retention_lines"])
|
||||||
|
legacy_interval = _sanitize_interval(data.get("retention_interval_hours") or current.get("retention_interval_hours"), DEFAULT_SETTINGS["retention_interval_hours"])
|
||||||
|
values: dict[str, Any] = {
|
||||||
|
"retention_mode": legacy_mode,
|
||||||
|
"retention_days": legacy_days,
|
||||||
|
"retention_lines": legacy_lines,
|
||||||
|
"retention_interval_hours": legacy_interval,
|
||||||
|
}
|
||||||
|
for category, defaults in DEFAULT_CATEGORY_SETTINGS.items():
|
||||||
|
values[f"{category}_retention_mode"] = _sanitize_mode(data.get(f"{category}_retention_mode") or current.get(f"{category}_retention_mode"), defaults["retention_mode"])
|
||||||
|
values[f"{category}_retention_days"] = _sanitize_days(data.get(f"{category}_retention_days") or current.get(f"{category}_retention_days"), defaults["retention_days"])
|
||||||
|
values[f"{category}_retention_lines"] = _sanitize_lines(data.get(f"{category}_retention_lines") or current.get(f"{category}_retention_lines"), defaults["retention_lines"])
|
||||||
|
values[f"{category}_retention_interval_hours"] = _sanitize_interval(data.get(f"{category}_retention_interval_hours") or current.get(f"{category}_retention_interval_hours"), defaults["retention_interval_hours"])
|
||||||
|
values[f"{category}_last_retention_run_at"] = current.get(f"{category}_last_retention_run_at")
|
||||||
|
values[f"{category}_last_retention_deleted"] = int(current.get(f"{category}_last_retention_deleted") or 0)
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO operation_log_settings(user_id, profile_id, retention_mode, retention_days, retention_lines, created_at, updated_at)
|
INSERT INTO operation_log_settings(
|
||||||
VALUES(?,?,?,?,?,?,?)
|
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
|
ON CONFLICT(user_id, profile_id) DO UPDATE SET
|
||||||
retention_mode=excluded.retention_mode,
|
retention_mode=excluded.retention_mode,
|
||||||
retention_days=excluded.retention_days,
|
retention_days=excluded.retention_days,
|
||||||
retention_lines=excluded.retention_lines,
|
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
|
updated_at=excluded.updated_at
|
||||||
""",
|
""",
|
||||||
(user_id, profile_id, mode, days, lines, now, now),
|
(
|
||||||
|
owner_id, profile_id, values["retention_mode"], values["retention_days"], values["retention_lines"], values["retention_interval_hours"],
|
||||||
|
values["job_retention_mode"], values["job_retention_days"], values["job_retention_lines"], values["job_retention_interval_hours"], values["job_last_retention_run_at"], values["job_last_retention_deleted"],
|
||||||
|
values["operation_retention_mode"], values["operation_retention_days"], values["operation_retention_lines"], values["operation_retention_interval_hours"], values["operation_last_retention_run_at"], values["operation_last_retention_deleted"],
|
||||||
|
now, now,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return get_settings(profile_id, user_id)
|
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:
|
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 keep all details in JSON-safe form."""
|
"""Insert one operation log row and lazily run retention for its category when due."""
|
||||||
now = utcnow()
|
now = utcnow()
|
||||||
user_id = _user_id(user_id)
|
user_id = _user_id(user_id)
|
||||||
|
event_type_s = str(event_type)
|
||||||
|
source_s = str(source or "system")
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
cur = conn.execute(
|
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)
|
INSERT INTO operation_logs(user_id, profile_id, event_type, severity, source, torrent_hash, torrent_name, action, message, details_json, created_at)
|
||||||
VALUES(?,?,?,?,?,?,?,?,?,?,?)
|
VALUES(?,?,?,?,?,?,?,?,?,?,?)
|
||||||
""",
|
""",
|
||||||
(user_id, int(profile_id or 0) or None, str(event_type), str(severity or "info"), str(source or "system"), torrent_hash, torrent_name, action, str(message), _details(details), now),
|
(user_id, int(profile_id or 0) or None, event_type_s, str(severity or "info"), source_s, torrent_hash, torrent_name, action, str(message), _details(details), now),
|
||||||
)
|
)
|
||||||
return int(cur.lastrowid)
|
row_id = int(cur.lastrowid)
|
||||||
|
try:
|
||||||
|
maybe_apply_retention(int(profile_id or 0), _log_category(event_type_s, source_s), user_id=user_id)
|
||||||
|
except Exception:
|
||||||
|
# Logging must never fail because cleanup metadata could not be updated.
|
||||||
|
pass
|
||||||
|
return row_id
|
||||||
|
|
||||||
|
|
||||||
def _job_event_type(status: str) -> str:
|
def _job_event_type(status: str) -> str:
|
||||||
@@ -189,6 +315,7 @@ def _job_action_label(action: str) -> str:
|
|||||||
"set_ratio_group": "Set ratio group",
|
"set_ratio_group": "Set ratio group",
|
||||||
"set_limits": "Set speed limits",
|
"set_limits": "Set speed limits",
|
||||||
"smart_queue_check": "Smart Queue check",
|
"smart_queue_check": "Smart Queue check",
|
||||||
|
"profile_transfer": "Move to another profile",
|
||||||
}
|
}
|
||||||
return labels.get(str(action or ""), str(action or "job"))
|
return labels.get(str(action or ""), str(action or "job"))
|
||||||
|
|
||||||
@@ -228,6 +355,8 @@ def record_job_event(profile_id: int, action: str, status: str, payload: dict |
|
|||||||
"target_path": ctx.get("target_path") or payload.get("path"),
|
"target_path": ctx.get("target_path") or payload.get("path"),
|
||||||
"remove_data": ctx.get("remove_data") or payload.get("remove_data"),
|
"remove_data": ctx.get("remove_data") or payload.get("remove_data"),
|
||||||
"move_data": ctx.get("move_data") or payload.get("move_data"),
|
"move_data": ctx.get("move_data") or payload.get("move_data"),
|
||||||
|
"target_profile_id": ctx.get("target_profile_id") or payload.get("target_profile_id"),
|
||||||
|
"move_data_downgraded": ctx.get("move_data_downgraded") or payload.get("move_data_downgraded"),
|
||||||
"keep_seeding": payload.get("keep_seeding"),
|
"keep_seeding": payload.get("keep_seeding"),
|
||||||
"hash_count": len(hashes),
|
"hash_count": len(hashes),
|
||||||
"error": error,
|
"error": error,
|
||||||
@@ -283,7 +412,7 @@ def list_logs(profile_id: int, *, limit: int = 200, offset: int = 0, event_type:
|
|||||||
where.append("event_type=?")
|
where.append("event_type=?")
|
||||||
params.append(event_type)
|
params.append(event_type)
|
||||||
if hide_jobs:
|
if hide_jobs:
|
||||||
where.append("COALESCE(source, '') <> 'job' AND event_type NOT LIKE 'job_%'")
|
where.append("COALESCE(source, '') NOT IN ('job', 'worker') AND event_type NOT LIKE 'job_%'")
|
||||||
if q:
|
if q:
|
||||||
where.append("(message LIKE ? OR torrent_name LIKE ? OR torrent_hash LIKE ? OR action LIKE ? OR details_json LIKE ?)")
|
where.append("(message LIKE ? OR torrent_name LIKE ? OR torrent_hash LIKE ? OR action LIKE ? OR details_json LIKE ?)")
|
||||||
like = f"%{q}%"
|
like = f"%{q}%"
|
||||||
@@ -306,20 +435,29 @@ def stats(profile_id: int) -> dict:
|
|||||||
return {"total": int(total or 0), "by_type": by_type, "by_day": by_day, "by_month": by_month, "top_actions": top_actions, "settings": get_settings(profile_id)}
|
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(settings: dict) -> str:
|
def _retention_label_for(settings: dict, category: str) -> str:
|
||||||
mode = settings.get("retention_mode") or "days"
|
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":
|
if mode == "manual":
|
||||||
return "manual cleanup only"
|
return f"manual cleanup only, checked every {interval}h"
|
||||||
if mode == "lines":
|
if mode == "lines":
|
||||||
return f"retention {settings.get('retention_lines') or DEFAULT_SETTINGS['retention_lines']} lines"
|
return f"retention {lines} lines, checked every {interval}h"
|
||||||
if mode == "both":
|
if mode == "both":
|
||||||
return f"retention {settings.get('retention_days') or DEFAULT_SETTINGS['retention_days']} days and {settings.get('retention_lines') or DEFAULT_SETTINGS['retention_lines']} lines"
|
return f"retention {days} days and {lines} lines, checked every {interval}h"
|
||||||
return f"retention {settings.get('retention_days') or DEFAULT_SETTINGS['retention_days']} days"
|
return f"retention {days} days, checked every {interval}h"
|
||||||
|
|
||||||
|
|
||||||
def clear(profile_id: int, *, event_type: str = "") -> int:
|
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)"]
|
where = ["(profile_id=? OR profile_id IS NULL)"]
|
||||||
params: list[Any] = [int(profile_id or 0)]
|
params: list[Any] = [int(profile_id or 0)]
|
||||||
|
if category in VALID_LOG_CATEGORIES:
|
||||||
|
where.append(_category_where(category))
|
||||||
if event_type:
|
if event_type:
|
||||||
where.append("event_type=?")
|
where.append("event_type=?")
|
||||||
params.append(event_type)
|
params.append(event_type)
|
||||||
@@ -328,22 +466,116 @@ def clear(profile_id: int, *, event_type: str = "") -> int:
|
|||||||
return int(cur.rowcount or 0)
|
return int(cur.rowcount or 0)
|
||||||
|
|
||||||
|
|
||||||
def apply_retention(profile_id: int, user_id: int | None = None) -> dict:
|
def _apply_retention_category(conn, profile_id: int, settings: dict, category: str) -> dict:
|
||||||
"""Apply operation-log retention without touching torrent data or other history tables."""
|
mode = settings.get(f"{category}_retention_mode") or "manual"
|
||||||
settings = get_settings(profile_id, user_id)
|
|
||||||
mode = settings.get("retention_mode") or "manual"
|
|
||||||
deleted_days = 0
|
deleted_days = 0
|
||||||
deleted_lines = 0
|
deleted_lines = 0
|
||||||
|
base_where = f"(profile_id=? OR profile_id IS NULL) AND {_category_where(category)}"
|
||||||
|
if mode in {"days", "both"}:
|
||||||
|
cutoff = (datetime.now(timezone.utc) - timedelta(days=int(settings[f"{category}_retention_days"]))).isoformat(timespec="seconds")
|
||||||
|
cur = conn.execute(f"DELETE FROM operation_logs WHERE {base_where} AND created_at<?", (int(profile_id or 0), cutoff))
|
||||||
|
deleted_days = int(cur.rowcount or 0)
|
||||||
|
if mode in {"lines", "both"}:
|
||||||
|
keep = int(settings[f"{category}_retention_lines"])
|
||||||
|
cur = conn.execute(
|
||||||
|
f"""
|
||||||
|
DELETE FROM operation_logs
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT id FROM operation_logs
|
||||||
|
WHERE {base_where}
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT -1 OFFSET ?
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(int(profile_id or 0), keep),
|
||||||
|
)
|
||||||
|
deleted_lines = int(cur.rowcount or 0)
|
||||||
|
return {"deleted_days": deleted_days, "deleted_lines": deleted_lines, "deleted": deleted_days + deleted_lines}
|
||||||
|
|
||||||
|
|
||||||
|
def _update_retention_metadata(conn, profile_id: int, category: str, deleted: int, settings: dict, user_id: int | None = None) -> None:
|
||||||
|
"""Update last retention state on the shared profile settings row."""
|
||||||
|
now = utcnow()
|
||||||
|
owner_id = _profile_settings_owner_id()
|
||||||
|
profile_id = int(profile_id or 0)
|
||||||
|
cur = conn.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE operation_log_settings
|
||||||
|
SET {category}_last_retention_run_at=?, {category}_last_retention_deleted=?, updated_at=?
|
||||||
|
WHERE user_id=? AND profile_id=?
|
||||||
|
""",
|
||||||
|
(now, int(deleted or 0), now, owner_id, profile_id),
|
||||||
|
)
|
||||||
|
if int(cur.rowcount or 0) == 0:
|
||||||
|
# Note: preserve legacy settings when creating the shared profile row lazily.
|
||||||
|
values = {
|
||||||
|
"retention_mode": _sanitize_mode(settings.get("retention_mode"), DEFAULT_SETTINGS["retention_mode"]),
|
||||||
|
"retention_days": _sanitize_days(settings.get("retention_days"), DEFAULT_SETTINGS["retention_days"]),
|
||||||
|
"retention_lines": _sanitize_lines(settings.get("retention_lines"), DEFAULT_SETTINGS["retention_lines"]),
|
||||||
|
"retention_interval_hours": _sanitize_interval(settings.get("retention_interval_hours"), DEFAULT_SETTINGS["retention_interval_hours"]),
|
||||||
|
}
|
||||||
|
for cat, defaults in DEFAULT_CATEGORY_SETTINGS.items():
|
||||||
|
values[f"{cat}_retention_mode"] = _sanitize_mode(settings.get(f"{cat}_retention_mode"), defaults["retention_mode"])
|
||||||
|
values[f"{cat}_retention_days"] = _sanitize_days(settings.get(f"{cat}_retention_days"), defaults["retention_days"])
|
||||||
|
values[f"{cat}_retention_lines"] = _sanitize_lines(settings.get(f"{cat}_retention_lines"), defaults["retention_lines"])
|
||||||
|
values[f"{cat}_retention_interval_hours"] = _sanitize_interval(settings.get(f"{cat}_retention_interval_hours"), defaults["retention_interval_hours"])
|
||||||
|
values[f"{cat}_last_retention_run_at"] = settings.get(f"{cat}_last_retention_run_at")
|
||||||
|
values[f"{cat}_last_retention_deleted"] = int(settings.get(f"{cat}_last_retention_deleted") or 0)
|
||||||
|
values[f"{category}_last_retention_run_at"] = now
|
||||||
|
values[f"{category}_last_retention_deleted"] = int(deleted or 0)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO operation_log_settings(
|
||||||
|
user_id, profile_id, retention_mode, retention_days, retention_lines,
|
||||||
|
retention_interval_hours,
|
||||||
|
job_retention_mode, job_retention_days, job_retention_lines, job_retention_interval_hours, job_last_retention_run_at, job_last_retention_deleted,
|
||||||
|
operation_retention_mode, operation_retention_days, operation_retention_lines, operation_retention_interval_hours, operation_last_retention_run_at, operation_last_retention_deleted,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(user_id, profile_id) DO UPDATE SET
|
||||||
|
job_last_retention_run_at=excluded.job_last_retention_run_at,
|
||||||
|
job_last_retention_deleted=excluded.job_last_retention_deleted,
|
||||||
|
operation_last_retention_run_at=excluded.operation_last_retention_run_at,
|
||||||
|
operation_last_retention_deleted=excluded.operation_last_retention_deleted,
|
||||||
|
updated_at=excluded.updated_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
owner_id, profile_id, values["retention_mode"], values["retention_days"], values["retention_lines"], values["retention_interval_hours"],
|
||||||
|
values["job_retention_mode"], values["job_retention_days"], values["job_retention_lines"], values["job_retention_interval_hours"], values["job_last_retention_run_at"], values["job_last_retention_deleted"],
|
||||||
|
values["operation_retention_mode"], values["operation_retention_days"], values["operation_retention_lines"], values["operation_retention_interval_hours"], values["operation_last_retention_run_at"], values["operation_last_retention_deleted"],
|
||||||
|
now, now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_retention(profile_id: int, user_id: int | None = None, category: str = "all") -> dict:
|
||||||
|
"""Apply due operation-log retention without touching torrent data or other history tables."""
|
||||||
|
profile_id = int(profile_id or 0)
|
||||||
|
settings = get_settings(profile_id, user_id)
|
||||||
|
categories = [category] if category in VALID_LOG_CATEGORIES else ["job", "operation"]
|
||||||
|
results: dict[str, Any] = {}
|
||||||
|
total = 0
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
if mode in {"days", "both"}:
|
for cat in categories:
|
||||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=int(settings["retention_days"]))).isoformat(timespec="seconds")
|
item = _apply_retention_category(conn, profile_id, settings, cat)
|
||||||
cur = conn.execute("DELETE FROM operation_logs WHERE (profile_id=? OR profile_id IS NULL) AND created_at<?", (int(profile_id or 0), cutoff))
|
_update_retention_metadata(conn, profile_id, cat, int(item["deleted"]), settings, user_id=user_id)
|
||||||
deleted_days = int(cur.rowcount or 0)
|
results[cat] = item
|
||||||
if mode in {"lines", "both"}:
|
total += int(item["deleted"])
|
||||||
keep = int(settings["retention_lines"])
|
fresh = get_settings(profile_id, user_id)
|
||||||
ids = conn.execute("SELECT id FROM operation_logs WHERE profile_id=? OR profile_id IS NULL ORDER BY id DESC LIMIT -1 OFFSET ?", (int(profile_id or 0), keep)).fetchall()
|
return {"deleted": total, "categories": results, "settings": fresh}
|
||||||
if ids:
|
|
||||||
placeholders = ",".join("?" for _ in ids)
|
|
||||||
cur = conn.execute(f"DELETE FROM operation_logs WHERE id IN ({placeholders})", tuple(r["id"] for r in ids))
|
def maybe_apply_retention(profile_id: int, category: str, user_id: int | None = None) -> dict:
|
||||||
deleted_lines = int(cur.rowcount or 0)
|
"""Run retention for a category only when interval since last cleanup elapsed."""
|
||||||
return {"deleted_days": deleted_days, "deleted_lines": deleted_lines, "deleted": deleted_days + deleted_lines, "settings": settings}
|
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
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -18,7 +17,6 @@ def _cleanup_expired(now: float | None = None) -> None:
|
|||||||
|
|
||||||
def _create_temporary_link(kind: str, profile_id: int, user_id: int, payload: dict) -> dict:
|
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."""
|
"""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()
|
now = time.time()
|
||||||
token = secrets.token_urlsafe(24)
|
token = secrets.token_urlsafe(24)
|
||||||
with _TEMPORARY_LINK_LOCK:
|
with _TEMPORARY_LINK_LOCK:
|
||||||
@@ -35,7 +33,6 @@ def _create_temporary_link(kind: str, profile_id: int, user_id: int, payload: di
|
|||||||
|
|
||||||
def create_pdf_preview_link(torrent_hash: str, file_index: int, profile_id: int, user_id: int) -> dict:
|
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."""
|
"""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(
|
return _create_temporary_link(
|
||||||
"pdf_preview",
|
"pdf_preview",
|
||||||
profile_id,
|
profile_id,
|
||||||
@@ -46,7 +43,6 @@ def create_pdf_preview_link(torrent_hash: str, file_index: int, profile_id: int,
|
|||||||
|
|
||||||
def create_file_download_link(torrent_hash: str, file_index: int, profile_id: int, user_id: int) -> dict:
|
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."""
|
"""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(
|
return _create_temporary_link(
|
||||||
"file_download",
|
"file_download",
|
||||||
profile_id,
|
profile_id,
|
||||||
@@ -57,7 +53,6 @@ def create_file_download_link(torrent_hash: str, file_index: int, profile_id: in
|
|||||||
|
|
||||||
def create_file_zip_download_link(torrent_hash: str, indexes: list[int] | None, profile_id: int, user_id: int) -> dict:
|
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."""
|
"""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]
|
clean_indexes = None if indexes is None else [int(index) for index in indexes]
|
||||||
return _create_temporary_link(
|
return _create_temporary_link(
|
||||||
"file_zip_download",
|
"file_zip_download",
|
||||||
@@ -69,7 +64,6 @@ def create_file_zip_download_link(torrent_hash: str, indexes: list[int] | None,
|
|||||||
|
|
||||||
def create_torrent_file_download_link(torrent_hash: str, profile_id: int, user_id: int) -> dict:
|
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."""
|
"""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(
|
return _create_temporary_link(
|
||||||
"torrent_file_download",
|
"torrent_file_download",
|
||||||
profile_id,
|
profile_id,
|
||||||
@@ -80,7 +74,6 @@ def create_torrent_file_download_link(torrent_hash: str, profile_id: int, user_i
|
|||||||
|
|
||||||
def create_torrent_files_zip_download_link(hashes: list[str], profile_id: int, user_id: int) -> dict:
|
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."""
|
"""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(
|
return _create_temporary_link(
|
||||||
"torrent_files_zip_download",
|
"torrent_files_zip_download",
|
||||||
profile_id,
|
profile_id,
|
||||||
@@ -91,7 +84,6 @@ def create_torrent_files_zip_download_link(hashes: list[str], profile_id: int, u
|
|||||||
|
|
||||||
def get_temporary_link(token: str) -> dict | None:
|
def get_temporary_link(token: str) -> dict | None:
|
||||||
"""Return a temporary target if the link is still valid."""
|
"""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()
|
clean = str(token or "").strip()
|
||||||
if not clean:
|
if not clean:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ..db import connect, utcnow
|
from ..db import connect, utcnow
|
||||||
from ..config import POLL_INTERVAL, MIN_POLL_INTERVAL_SECONDS
|
from ..config import POLL_INTERVAL, MIN_POLL_INTERVAL_SECONDS
|
||||||
|
|
||||||
@@ -81,7 +79,6 @@ def normalize_settings(data: dict | None) -> dict:
|
|||||||
"recovery_after_errors": int(_coerce_float(raw.get("recovery_after_errors"), 3, 1, 20)),
|
"recovery_after_errors": int(_coerce_float(raw.get("recovery_after_errors"), 3, 1, 20)),
|
||||||
}
|
}
|
||||||
if settings["safe_fallback_enabled"]:
|
if settings["safe_fallback_enabled"]:
|
||||||
# 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():
|
for key, minimum in SAFE_FALLBACK_MINIMUMS.items():
|
||||||
settings[key] = max(float(settings.get(key) or DEFAULTS[key]), float(minimum))
|
settings[key] = max(float(settings.get(key) or DEFAULTS[key]), float(minimum))
|
||||||
return settings
|
return settings
|
||||||
@@ -91,7 +88,6 @@ def get_settings(profile_id: int) -> dict:
|
|||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
row = conn.execute("SELECT settings_json FROM poller_settings WHERE profile_id=?", (int(profile_id),)).fetchone()
|
row = conn.execute("SELECT settings_json FROM poller_settings WHERE profile_id=?", (int(profile_id),)).fetchone()
|
||||||
if not row:
|
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()
|
legacy = conn.execute("SELECT value FROM app_settings WHERE key=?", (_key(profile_id),)).fetchone()
|
||||||
if legacy:
|
if legacy:
|
||||||
try:
|
try:
|
||||||
@@ -240,7 +236,6 @@ def should_heartbeat(now: float, settings: dict, state: ProfilePollState, change
|
|||||||
|
|
||||||
def mark_live_poll(state: ProfilePollState, started_at: float, ok: bool, error: str = "", updated_count: int = 0, requires_full_refresh: bool = False) -> None:
|
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()
|
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.live_poll_count += 1
|
||||||
state.last_live_duration_ms = round((now - started_at) * 1000.0, 2)
|
state.last_live_duration_ms = round((now - started_at) * 1000.0, 2)
|
||||||
state.last_live_updated_count = int(updated_count or 0)
|
state.last_live_updated_count = int(updated_count or 0)
|
||||||
@@ -254,7 +249,6 @@ def mark_live_poll(state: ProfilePollState, started_at: float, ok: bool, error:
|
|||||||
|
|
||||||
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:
|
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()
|
now = time.monotonic()
|
||||||
# Note: List poller diagnostics are separate because this slower loop runs full torrent snapshot reconciliation.
|
|
||||||
state.list_poll_count += 1
|
state.list_poll_count += 1
|
||||||
state.last_list_duration_ms = round((now - started_at) * 1000.0, 2)
|
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_added_count = int(added_count or 0)
|
||||||
@@ -269,7 +263,6 @@ def mark_list_poll(state: ProfilePollState, started_at: float, ok: bool, error:
|
|||||||
|
|
||||||
def reset_runtime_stats(profile_id: int) -> dict:
|
def reset_runtime_stats(profile_id: int) -> dict:
|
||||||
state = state_for(profile_id)
|
state = state_for(profile_id)
|
||||||
# Note: Cleanup resets diagnostic counters only; poller timers and saved settings keep running unchanged.
|
|
||||||
state.tick_count = 0
|
state.tick_count = 0
|
||||||
state.last_tick_ms = 0.0
|
state.last_tick_ms = 0.0
|
||||||
state.last_tick_gap_ms = 0.0
|
state.last_tick_gap_ms = 0.0
|
||||||
@@ -385,10 +378,19 @@ def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool
|
|||||||
return dict(state.stats)
|
return dict(state.stats)
|
||||||
|
|
||||||
|
|
||||||
def snapshot(profile_id: int) -> dict:
|
def snapshot(profile_id: int, settings: dict | None = None) -> dict:
|
||||||
state = state_for(profile_id)
|
state = state_for(profile_id)
|
||||||
|
effective_settings = normalize_settings(settings) if settings is not None else get_settings(profile_id)
|
||||||
data = dict(state.stats or {"profile_id": int(profile_id), "tick_count": state.tick_count})
|
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.
|
runtime_ready = bool(state.stats) or state.tick_count > 0
|
||||||
|
data.setdefault("runtime_ready", runtime_ready)
|
||||||
|
data.setdefault("adaptive_enabled", bool(effective_settings.get("adaptive_enabled", DEFAULTS["adaptive_enabled"])))
|
||||||
|
data.setdefault("adaptive_mode", state.adaptive_mode if runtime_ready else ("fixed" if not data.get("adaptive_enabled") else "waiting"))
|
||||||
|
data.setdefault("live_stats_interval_seconds", effective_live_interval(effective_settings, state))
|
||||||
|
data.setdefault("torrent_list_interval_seconds", effective_list_interval(effective_settings, state))
|
||||||
|
data.setdefault("configured_min_interval_seconds", MIN_POLL_INTERVAL_SECONDS)
|
||||||
|
if not runtime_ready:
|
||||||
|
data["last_ok"] = None
|
||||||
data.update({
|
data.update({
|
||||||
"live_poll_count": state.live_poll_count,
|
"live_poll_count": state.live_poll_count,
|
||||||
"list_poll_count": state.list_poll_count,
|
"list_poll_count": state.list_poll_count,
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
from ..db import connect
|
||||||
|
from . import preferences, rtorrent
|
||||||
|
|
||||||
|
PORT_CHECK_CACHE_SECONDS = 6 * 60 * 60
|
||||||
|
MAX_PORT_CHECK_CANDIDATES = 256
|
||||||
|
|
||||||
|
|
||||||
|
def _app_setting_get(key: str) -> str | None:
|
||||||
|
with connect() as conn:
|
||||||
|
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (key,)).fetchone()
|
||||||
|
return row.get("value") if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def _app_setting_set(key: str, value: str) -> None:
|
||||||
|
with connect() as conn:
|
||||||
|
conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (key, value))
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_from_epoch(value: Any) -> str | None:
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(float(value), timezone.utc).isoformat(timespec="seconds")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _public_ip(profile: dict | None = None, force: bool = False) -> str:
|
||||||
|
if profile and bool(profile.get("is_remote")):
|
||||||
|
return rtorrent.remote_public_ip(profile, force=force)
|
||||||
|
req = urllib.request.Request("https://api.ipify.org", headers={"User-Agent": "pyTorrent/port-check"})
|
||||||
|
with urllib.request.urlopen(req, timeout=8) as res:
|
||||||
|
return res.read(64).decode("utf-8", "replace").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_port_candidates(value: str, limit: int = MAX_PORT_CHECK_CANDIDATES) -> tuple[list[int], bool]:
|
||||||
|
"""Return valid incoming port candidates from rTorrent network.port_range."""
|
||||||
|
ports: list[int] = []
|
||||||
|
seen: set[int] = set()
|
||||||
|
truncated = False
|
||||||
|
|
||||||
|
def add(port: int) -> None:
|
||||||
|
nonlocal truncated
|
||||||
|
if not 1 <= port <= 65535 or port in seen:
|
||||||
|
return
|
||||||
|
if len(ports) >= limit:
|
||||||
|
truncated = True
|
||||||
|
return
|
||||||
|
seen.add(port)
|
||||||
|
ports.append(port)
|
||||||
|
|
||||||
|
for start, end in re.findall(r"(\d{1,5})\s*-\s*(\d{1,5})", value or ""):
|
||||||
|
a, b = int(start), int(end)
|
||||||
|
if a > b:
|
||||||
|
a, b = b, a
|
||||||
|
for port in range(a, b + 1):
|
||||||
|
add(port)
|
||||||
|
if truncated:
|
||||||
|
break
|
||||||
|
|
||||||
|
without_ranges = re.sub(r"\d{1,5}\s*-\s*\d{1,5}", " ", value or "")
|
||||||
|
for item in re.findall(r"\d{1,5}", without_ranges):
|
||||||
|
add(int(item))
|
||||||
|
|
||||||
|
return ports, truncated
|
||||||
|
|
||||||
|
|
||||||
|
def _incoming_ports(profile: dict) -> dict:
|
||||||
|
try:
|
||||||
|
raw_value = str(rtorrent.client_for(profile).call("network.port_range") or "")
|
||||||
|
except Exception:
|
||||||
|
raw_value = ""
|
||||||
|
ports, truncated = _parse_port_candidates(raw_value)
|
||||||
|
return {"ports": ports, "raw": raw_value, "truncated": truncated}
|
||||||
|
|
||||||
|
|
||||||
|
def _yougetsignal_check(public_ip: str, port: int) -> dict:
|
||||||
|
body = urllib.parse.urlencode({"remoteAddress": public_ip, "portNumber": str(port)}).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://ports.yougetsignal.com/check-port.php",
|
||||||
|
data=body,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||||
|
"User-Agent": "pyTorrent/port-check",
|
||||||
|
"Accept": "text/html,application/json,*/*",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=12) as res:
|
||||||
|
text = res.read(8192).decode("utf-8", "replace")
|
||||||
|
low = text.lower()
|
||||||
|
if "is open" in low:
|
||||||
|
return {"status": "open", "source": "yougetsignal", "raw": text[:500]}
|
||||||
|
if "is closed" in low:
|
||||||
|
return {"status": "closed", "source": "yougetsignal", "raw": text[:500]}
|
||||||
|
return {"status": "unknown", "source": "yougetsignal", "raw": text[:500]}
|
||||||
|
|
||||||
|
|
||||||
|
def _local_port_fallback(public_ip: str, port: int) -> dict:
|
||||||
|
try:
|
||||||
|
with socket.create_connection((public_ip, port), timeout=3):
|
||||||
|
return {"status": "open", "source": "local-fallback"}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"status": "unknown", "source": "local-fallback", "error": f"Local fallback inconclusive: {exc}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _check_ports(public_ip: str, ports: list[int], checker) -> dict:
|
||||||
|
checked: list[int] = []
|
||||||
|
first_closed: dict | None = None
|
||||||
|
last_result: dict = {"status": "unknown"}
|
||||||
|
|
||||||
|
for port in ports:
|
||||||
|
checked.append(port)
|
||||||
|
current = checker(public_ip, port)
|
||||||
|
last_result = current
|
||||||
|
if current.get("status") == "open":
|
||||||
|
current.update({"port": port, "open_port": port, "checked_ports": checked})
|
||||||
|
return current
|
||||||
|
if current.get("status") == "closed" and first_closed is None:
|
||||||
|
first_closed = current
|
||||||
|
|
||||||
|
result = first_closed or last_result
|
||||||
|
result.update({"port": ports[0] if ports else None, "open_port": None, "checked_ports": checked})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def port_check_status(profile: dict | None = None, force: bool = False, user_id: int | None = None) -> dict:
|
||||||
|
"""Return cached or freshly checked incoming-port status for one rTorrent profile."""
|
||||||
|
profile = profile or preferences.active_profile(user_id)
|
||||||
|
prefs = preferences.get_preferences(user_id, int(profile.get("id"))) if profile else preferences.get_preferences(user_id)
|
||||||
|
enabled = bool((prefs or {}).get("port_check_enabled"))
|
||||||
|
if not profile:
|
||||||
|
return {"status": "unknown", "enabled": enabled, "error": "No profile"}
|
||||||
|
|
||||||
|
port_info = _incoming_ports(profile)
|
||||||
|
ports = port_info["ports"]
|
||||||
|
if not ports:
|
||||||
|
return {"status": "unknown", "enabled": enabled, "error": "Cannot read rTorrent network.port_range"}
|
||||||
|
|
||||||
|
ports_key = ",".join(str(port) for port in ports)
|
||||||
|
cache_key = f"port_check:{profile['id']}:{ports_key}:{int(bool(port_info['truncated']))}"
|
||||||
|
if not force:
|
||||||
|
cached = _app_setting_get(cache_key)
|
||||||
|
if cached:
|
||||||
|
try:
|
||||||
|
data = json.loads(cached)
|
||||||
|
if time.time() - float(data.get("checked_at_epoch") or 0) < PORT_CHECK_CACHE_SECONDS:
|
||||||
|
data["cached"] = True
|
||||||
|
data["enabled"] = enabled
|
||||||
|
if not data.get("checked_at"):
|
||||||
|
data["checked_at"] = _iso_from_epoch(data.get("checked_at_epoch"))
|
||||||
|
return data
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
checked_at_epoch = time.time()
|
||||||
|
result = {
|
||||||
|
"status": "unknown",
|
||||||
|
"enabled": enabled,
|
||||||
|
"port": ports[0],
|
||||||
|
"ports": ports,
|
||||||
|
"port_range": port_info["raw"],
|
||||||
|
"ports_truncated": port_info["truncated"],
|
||||||
|
"checked_at_epoch": checked_at_epoch,
|
||||||
|
"checked_at": _iso_from_epoch(checked_at_epoch),
|
||||||
|
"cached": False,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
public_ip = _public_ip(profile, force=force)
|
||||||
|
result["public_ip"] = public_ip
|
||||||
|
result["remote"] = bool(profile.get("is_remote"))
|
||||||
|
result.update(_check_ports(public_ip, ports, _yougetsignal_check))
|
||||||
|
except Exception as exc:
|
||||||
|
result["error"] = f"YouGetSignal failed: {exc}"
|
||||||
|
try:
|
||||||
|
public_ip = result.get("public_ip") or _public_ip(profile, force=force)
|
||||||
|
result["public_ip"] = public_ip
|
||||||
|
result["remote"] = bool(profile.get("is_remote"))
|
||||||
|
result.update(_check_ports(public_ip, ports, _local_port_fallback))
|
||||||
|
except Exception as fallback_exc:
|
||||||
|
result["fallback_error"] = str(fallback_exc)
|
||||||
|
result["source"] = "none"
|
||||||
|
_app_setting_set(cache_key, json.dumps(result))
|
||||||
|
return result
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from ..db import connect, utcnow, default_user_id
|
from ..db import connect, utcnow, default_user_id
|
||||||
from . import auth
|
from . import auth
|
||||||
from .frontend_assets import BOOTSTRAP_THEME_LABELS
|
from .frontend_assets import BOOTSTRAP_THEME_LABELS
|
||||||
@@ -28,7 +26,6 @@ FONT_FAMILIES = {
|
|||||||
"adwaita-mono": "Adwaita Mono",
|
"adwaita-mono": "Adwaita Mono",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Note: Backend owns the recommended torrent table layout so frontend builds do not duplicate presets.
|
|
||||||
RECOMMENDED_TABLE_COLUMNS = {
|
RECOMMENDED_TABLE_COLUMNS = {
|
||||||
"hidden": ["hash", "priority", "hashing", "active", "message", "complete", "state", "ratio_group"],
|
"hidden": ["hash", "priority", "hashing", "active", "message", "complete", "state", "ratio_group"],
|
||||||
"shown": ["down_total", "to_download", "up_total", "created"],
|
"shown": ["down_total", "to_download", "up_total", "created"],
|
||||||
@@ -296,17 +293,39 @@ def legacy_disk_monitor_preferences(user_id: int | None = None) -> dict:
|
|||||||
return _normalize_disk_monitor(row)
|
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:
|
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()
|
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)
|
profile_id = int(profile_id or _active_profile_id_for_user(user_id) or 0)
|
||||||
if not profile_id:
|
if not profile_id:
|
||||||
return legacy_disk_monitor_preferences(user_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:
|
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:
|
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.
|
# 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:
|
def save_disk_monitor_preferences(profile_id: int | None, data: dict, user_id: int | None = None) -> dict:
|
||||||
@@ -314,6 +333,8 @@ def save_disk_monitor_preferences(profile_id: int | None, data: dict, user_id: i
|
|||||||
profile_id = int(profile_id or _active_profile_id_for_user(user_id) or 0)
|
profile_id = int(profile_id or _active_profile_id_for_user(user_id) or 0)
|
||||||
if not profile_id:
|
if not profile_id:
|
||||||
return legacy_disk_monitor_preferences(user_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)
|
current = get_disk_monitor_preferences(profile_id, user_id)
|
||||||
merged = dict(current)
|
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"):
|
for key in ("disk_monitor_paths_json", "disk_monitor_mode", "disk_monitor_selected_path", "disk_monitor_stop_enabled", "disk_monitor_stop_threshold"):
|
||||||
@@ -323,10 +344,14 @@ def save_disk_monitor_preferences(profile_id: int | None, data: dict, user_id: i
|
|||||||
now = utcnow()
|
now = utcnow()
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
conn.execute(
|
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(?,?,?,?,?,?,?,?,?) "
|
"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(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",
|
"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",
|
||||||
(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),
|
(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
|
return clean
|
||||||
|
|
||||||
|
|
||||||
@@ -349,7 +374,7 @@ def _seed_profile_preferences(conn, user_id: int, profile_id: int) -> dict:
|
|||||||
return dict(row)
|
return dict(row)
|
||||||
# Note: First profile preference row is seeded from legacy user-level values so upgrades keep the current layout/filter behavior.
|
# Note: First profile preference row is seeded from legacy user-level values so upgrades keep the current layout/filter behavior.
|
||||||
conn.execute(
|
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,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
"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,
|
user_id,
|
||||||
profile_id,
|
profile_id,
|
||||||
@@ -360,6 +385,8 @@ def _seed_profile_preferences(conn, user_id: int, profile_id: int) -> dict:
|
|||||||
int(legacy.get("port_check_enabled") or 0),
|
int(legacy.get("port_check_enabled") or 0),
|
||||||
int(legacy.get("tracker_favicons_enabled") or 0),
|
int(legacy.get("tracker_favicons_enabled") or 0),
|
||||||
int(legacy.get("reverse_dns_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,
|
||||||
now,
|
now,
|
||||||
),
|
),
|
||||||
@@ -394,6 +421,12 @@ def save_profile_preferences(user_id: int, profile_id: int | None, data: dict) -
|
|||||||
if data.get("reverse_dns_enabled") is not None:
|
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.
|
# 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
|
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:
|
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"))
|
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 "{}")
|
parsed = json.loads(value or "{}")
|
||||||
@@ -412,7 +445,7 @@ def save_profile_preferences(user_id: int, profile_id: int | None, data: dict) -
|
|||||||
value = str(data.get("active_filter") or "all").strip()
|
value = str(data.get("active_filter") or "all").strip()
|
||||||
if not value or len(value) > 180:
|
if not value or len(value) > 180:
|
||||||
value = "all"
|
value = "all"
|
||||||
allowed_static_filters = {"all", "downloading", "seeding", "paused", "checking", "error", "stopped", "moving"}
|
allowed_static_filters = {"all", "downloading", "queued", "seeding", "paused", "checking", "error", "post_check", "stopped", "moving"}
|
||||||
if value not in allowed_static_filters and not value.startswith("label:") and not value.startswith("tracker:"):
|
if value not in allowed_static_filters and not value.startswith("label:") and not value.startswith("tracker:"):
|
||||||
value = "all"
|
value = "all"
|
||||||
updates["active_filter"] = value
|
updates["active_filter"] = value
|
||||||
@@ -420,8 +453,8 @@ def save_profile_preferences(user_id: int, profile_id: int | None, data: dict) -
|
|||||||
return
|
return
|
||||||
merged = {**current, **updates}
|
merged = {**current, **updates}
|
||||||
conn.execute(
|
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,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?) "
|
"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, updated_at=excluded.updated_at",
|
"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,
|
user_id,
|
||||||
profile_id,
|
profile_id,
|
||||||
@@ -432,6 +465,8 @@ def save_profile_preferences(user_id: int, profile_id: int | None, data: dict) -
|
|||||||
int(merged.get("port_check_enabled") or 0),
|
int(merged.get("port_check_enabled") or 0),
|
||||||
int(merged.get("tracker_favicons_enabled") or 0),
|
int(merged.get("tracker_favicons_enabled") or 0),
|
||||||
int(merged.get("reverse_dns_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,
|
merged.get("created_at") or now,
|
||||||
now,
|
now,
|
||||||
),
|
),
|
||||||
@@ -453,9 +488,9 @@ def get_preferences(user_id: int | None = None, profile_id: int | None = None):
|
|||||||
merged.update(get_disk_monitor_preferences(profile_id, user_id))
|
merged.update(get_disk_monitor_preferences(profile_id, user_id))
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
def save_preferences(data: dict, user_id: int | None = None):
|
def save_preferences(data: dict, user_id: int | None = None, profile_id: int | None = None):
|
||||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||||
profile_id = _active_profile_id_for_user(user_id)
|
profile_id = profile_id or _active_profile_id_for_user(user_id)
|
||||||
allowed_theme = data.get("theme") if data.get("theme") in {"light", "dark"} else None
|
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
|
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
|
font_family = data.get("font_family") if data.get("font_family") in FONT_FAMILIES else None
|
||||||
@@ -542,3 +577,77 @@ def save_preferences(data: dict, user_id: int | None = None):
|
|||||||
if disk_payload is not None:
|
if disk_payload is not None:
|
||||||
save_disk_monitor_preferences(profile_id, disk_payload, user_id)
|
save_disk_monitor_preferences(profile_id, disk_payload, user_id)
|
||||||
return get_preferences(user_id, profile_id)
|
return get_preferences(user_id, profile_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_int(row: dict, key: str) -> int:
|
||||||
|
try:
|
||||||
|
return int(float(row.get(key) or 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def profile_runtime_stats_from_rows(profile: dict, rows: list[dict], user_id: int | None = None) -> dict:
|
||||||
|
# Note: Stored profile stats are intentionally approximate and updated only when the user switches to that profile.
|
||||||
|
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||||
|
total_size = completed = downloaded = uploaded = active = seeding = downloading = stopped = 0
|
||||||
|
for row in rows or []:
|
||||||
|
size = _row_int(row, 'size')
|
||||||
|
total_size += size
|
||||||
|
completed += min(size, _row_int(row, 'completed_bytes')) if size else _row_int(row, 'completed_bytes')
|
||||||
|
downloaded += _row_int(row, 'down_total')
|
||||||
|
uploaded += _row_int(row, 'up_total')
|
||||||
|
status = str(row.get('status') or '').strip().lower()
|
||||||
|
state = bool(row.get('state'))
|
||||||
|
complete = bool(row.get('complete'))
|
||||||
|
if state:
|
||||||
|
active += 1
|
||||||
|
if complete and state:
|
||||||
|
seeding += 1
|
||||||
|
if not complete and state and status != 'queued':
|
||||||
|
downloading += 1
|
||||||
|
if not state:
|
||||||
|
stopped += 1
|
||||||
|
return {
|
||||||
|
'profile_id': int(profile.get('id') or 0),
|
||||||
|
'user_id': int(user_id),
|
||||||
|
'torrent_count': len(rows or []),
|
||||||
|
'total_size_bytes': total_size,
|
||||||
|
'completed_bytes': completed,
|
||||||
|
'downloaded_bytes': downloaded,
|
||||||
|
'uploaded_bytes': uploaded,
|
||||||
|
'active_count': active,
|
||||||
|
'seeding_count': seeding,
|
||||||
|
'downloading_count': downloading,
|
||||||
|
'stopped_count': stopped,
|
||||||
|
'updated_at': utcnow(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def save_profile_runtime_stats(profile: dict, rows: list[dict], user_id: int | None = None) -> dict:
|
||||||
|
stats = profile_runtime_stats_from_rows(profile, rows, user_id=user_id)
|
||||||
|
with connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO profile_runtime_stats(
|
||||||
|
profile_id,user_id,torrent_count,total_size_bytes,completed_bytes,downloaded_bytes,uploaded_bytes,
|
||||||
|
active_count,seeding_count,downloading_count,stopped_count,updated_at
|
||||||
|
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(profile_id) DO UPDATE SET
|
||||||
|
user_id=excluded.user_id, torrent_count=excluded.torrent_count, total_size_bytes=excluded.total_size_bytes,
|
||||||
|
completed_bytes=excluded.completed_bytes, downloaded_bytes=excluded.downloaded_bytes, uploaded_bytes=excluded.uploaded_bytes,
|
||||||
|
active_count=excluded.active_count, seeding_count=excluded.seeding_count, downloading_count=excluded.downloading_count,
|
||||||
|
stopped_count=excluded.stopped_count, updated_at=excluded.updated_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
stats['profile_id'], stats['user_id'], stats['torrent_count'], stats['total_size_bytes'], stats['completed_bytes'],
|
||||||
|
stats['downloaded_bytes'], stats['uploaded_bytes'], stats['active_count'], stats['seeding_count'],
|
||||||
|
stats['downloading_count'], stats['stopped_count'], stats['updated_at'],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def get_profile_runtime_stats(profile_id: int) -> dict | None:
|
||||||
|
with connect() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM profile_runtime_stats WHERE profile_id=?", (int(profile_id),)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from ..db import connect, utcnow
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_limit(value: object) -> int:
|
||||||
|
try:
|
||||||
|
limit = int(float(value or 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
return max(0, limit)
|
||||||
|
|
||||||
|
|
||||||
|
def get_limits(profile_id: int | None) -> dict:
|
||||||
|
profile_id = int(profile_id or 0)
|
||||||
|
if not profile_id:
|
||||||
|
return {"down": 0, "up": 0, "configured": False}
|
||||||
|
with connect() as conn:
|
||||||
|
row = conn.execute("SELECT down_limit, up_limit FROM profile_speed_limits WHERE profile_id=?", (profile_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return {"down": 0, "up": 0, "configured": False}
|
||||||
|
return {"down": int(row.get("down_limit") or 0), "up": int(row.get("up_limit") or 0), "configured": True}
|
||||||
|
|
||||||
|
|
||||||
|
def save_limits(profile_id: int, down: object, up: object) -> dict:
|
||||||
|
profile_id = int(profile_id or 0)
|
||||||
|
if not profile_id:
|
||||||
|
raise ValueError("Missing profile id")
|
||||||
|
clean = {"down": normalize_limit(down), "up": normalize_limit(up), "configured": True}
|
||||||
|
now = utcnow()
|
||||||
|
with connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO profile_speed_limits(profile_id, down_limit, up_limit, created_at, updated_at)
|
||||||
|
VALUES(?,?,?,?,?)
|
||||||
|
ON CONFLICT(profile_id) DO UPDATE SET
|
||||||
|
down_limit=excluded.down_limit,
|
||||||
|
up_limit=excluded.up_limit,
|
||||||
|
updated_at=excluded.updated_at
|
||||||
|
""",
|
||||||
|
(profile_id, clean["down"], clean["up"], now, now),
|
||||||
|
)
|
||||||
|
return clean
|
||||||
|
|
||||||
|
|
||||||
|
def delete_limits(profile_id: int) -> None:
|
||||||
|
with connect() as conn:
|
||||||
|
conn.execute("DELETE FROM profile_speed_limits WHERE profile_id=?", (int(profile_id or 0),))
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from ..db import connect, utcnow, default_user_id
|
from ..db import connect, utcnow, default_user_id
|
||||||
from . import rtorrent
|
from . import auth, rtorrent
|
||||||
from .workers import enqueue
|
from .workers import enqueue
|
||||||
|
|
||||||
|
|
||||||
@@ -67,12 +65,14 @@ def _should_apply(profile: dict, group: dict, torrent: dict) -> tuple[bool, str]
|
|||||||
|
|
||||||
|
|
||||||
def check(profile: dict, user_id: int | None = None) -> dict:
|
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"])
|
profile_id = int(profile["id"])
|
||||||
with connect() as conn:
|
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()}
|
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
|
applied = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
queued_jobs = []
|
queued_jobs = []
|
||||||
@@ -93,6 +93,11 @@ def check(profile: dict, user_id: int | None = None) -> dict:
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
action = str(group.get("action") or "stop")
|
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}}
|
payload = {"hashes": [torrent["hash"]], "source": "ratio", "job_context": {"source": "ratio", "rule_name": group.get("name"), "hash_count": 1}}
|
||||||
if action == "remove_data":
|
if action == "remove_data":
|
||||||
api_action = "remove"
|
api_action = "remove"
|
||||||
@@ -105,10 +110,10 @@ def check(profile: dict, user_id: int | None = None) -> dict:
|
|||||||
payload["label"] = group.get("set_label") or group.get("name") or ""
|
payload["label"] = group.get("set_label") or group.get("name") or ""
|
||||||
else:
|
else:
|
||||||
api_action = action if action in {"stop", "remove", "pause"} else "stop"
|
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)
|
queued_jobs.append(job_id)
|
||||||
applied += 1
|
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}
|
return {"applied": applied, "skipped": skipped, "job_ids": queued_jobs}
|
||||||
|
|
||||||
|
|
||||||
@@ -127,12 +132,17 @@ def start_scheduler(socketio=None) -> None:
|
|||||||
try:
|
try:
|
||||||
from .preferences import get_profile
|
from .preferences import get_profile
|
||||||
with connect() as conn:
|
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:
|
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()
|
||||||
|
owner_id = int(owner["user_id"] if owner and owner.get("user_id") else default_user_id())
|
||||||
|
profile = get_profile(profile_id, owner_id)
|
||||||
if not profile:
|
if not profile:
|
||||||
continue
|
continue
|
||||||
result = check(profile, int(row["user_id"]))
|
# Note: Ratio rules are evaluated per profile owner, not the active browser user.
|
||||||
|
result = check(profile, user_id=owner_id)
|
||||||
if socketio and result.get("applied"):
|
if socketio and result.get("applied"):
|
||||||
socketio.emit("ratio_rules_checked", {"profile_id": profile["id"], **result}, to=f"profile:{profile['id']}")
|
socketio.emit("ratio_rules_checked", {"profile_id": profile["id"], **result}, to=f"profile:{profile['id']}")
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from ..config import JOBS_RETENTION_DAYS, LOG_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, TRAFFIC_HISTORY_RETENTION_DAYS
|
from ..config import JOBS_RETENTION_DAYS, LOG_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, TRAFFIC_HISTORY_RETENTION_DAYS
|
||||||
from ..db import connect
|
from ..db import connect
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import socket
|
import socket
|
||||||
import time
|
import time
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -201,9 +200,14 @@ def start_scheduler(socketio=None) -> None:
|
|||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
profiles = conn.execute("SELECT DISTINCT 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:
|
for row in profiles:
|
||||||
profile = get_profile(int(row["profile_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()
|
||||||
|
owner_id = int(owner["user_id"] if owner and owner.get("user_id") else default_user_id())
|
||||||
|
profile = get_profile(profile_id, owner_id)
|
||||||
if profile:
|
if profile:
|
||||||
result = check(profile, only_due=True)
|
# Note: RSS jobs run with the profile owner in background mode, independent of browser activity.
|
||||||
|
result = check(profile, user_id=owner_id, only_due=True)
|
||||||
if socketio and result.get("queued"):
|
if socketio and result.get("queued"):
|
||||||
socketio.emit("rss_checked", {"profile_id": profile["id"], **result}, to=f"profile:{profile['id']}")
|
socketio.emit("rss_checked", {"profile_id": profile["id"], **result}, to=f"profile:{profile['id']}")
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
# rTorrent service modules
|
|
||||||
|
|
||||||
The old `pytorrent/services/rtorrent.py` monolith is end-of-life.
|
|
||||||
Do not recreate it and do not add new rTorrent logic outside this directory.
|
|
||||||
|
|
||||||
Use focused modules in `pytorrent/services/rtorrent/` instead:
|
|
||||||
- `client.py` for SCGI/XMLRPC transport and shared caches.
|
|
||||||
- `system.py` for status, footer metrics, disk and remote host usage.
|
|
||||||
- `torrents.py` for torrent list and torrent operations.
|
|
||||||
- `files.py`, `config.py`, `diagnostics.py` for their dedicated areas.
|
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
# EOL note: do not recreate or edit the old pytorrent/services/rtorrent.py monolith.
|
|
||||||
# All rTorrent code belongs in this package directory.
|
|
||||||
|
|
||||||
# Note: Public functions are re-exported here so existing imports from services.rtorrent remain transparent.
|
|
||||||
# Compatibility note: module __all__ definitions include selected private helpers used by existing routes.
|
|
||||||
from .client import *
|
from .client import *
|
||||||
from .system import *
|
from .system import *
|
||||||
from .diagnostics import *
|
from .diagnostics import *
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
import re
|
import re
|
||||||
from .client import *
|
from .client import *
|
||||||
@@ -11,13 +10,11 @@ _HEX_RE = re.compile(r"[0-9a-fA-F]")
|
|||||||
|
|
||||||
def _clean_hex_bitfield(value) -> str:
|
def _clean_hex_bitfield(value) -> str:
|
||||||
"""Return only hexadecimal bitfield characters from rTorrent output."""
|
"""Return only hexadecimal bitfield characters from rTorrent output."""
|
||||||
# Note: rTorrent may return spacing or non-hex separators; keep only the actual bitfield payload.
|
|
||||||
return "".join(_HEX_RE.findall(str(value or ""))).lower()
|
return "".join(_HEX_RE.findall(str(value or ""))).lower()
|
||||||
|
|
||||||
|
|
||||||
def _hex_to_bits(value: str, limit: int | None = None) -> list[int]:
|
def _hex_to_bits(value: str, limit: int | None = None) -> list[int]:
|
||||||
"""Decode an rTorrent hex bitfield into one bit per torrent piece."""
|
"""Decode an rTorrent hex bitfield into one bit per torrent piece."""
|
||||||
# Note: d.bitfield is a packed bitset, not a per-nibble completion percentage; decoding fixes false partial cells near 100% torrents.
|
|
||||||
bits: list[int] = []
|
bits: list[int] = []
|
||||||
for char in _clean_hex_bitfield(value):
|
for char in _clean_hex_bitfield(value):
|
||||||
nibble = int(char, 16)
|
nibble = int(char, 16)
|
||||||
@@ -47,7 +44,6 @@ def _chunk_status(completed: int, total: int, seen: bool = False) -> str:
|
|||||||
|
|
||||||
def _group_cells(cells: list[dict], max_cells: int) -> list[dict]:
|
def _group_cells(cells: list[dict], max_cells: int) -> list[dict]:
|
||||||
"""Reduce very large torrents to a browser-friendly number of visual cells."""
|
"""Reduce very large torrents to a browser-friendly number of visual cells."""
|
||||||
# Note: Grouping now happens on real piece states, so the aggregated percentage matches the actual torrent progress.
|
|
||||||
if max_cells <= 0 or len(cells) <= max_cells:
|
if max_cells <= 0 or len(cells) <= max_cells:
|
||||||
return cells
|
return cells
|
||||||
grouped: list[dict] = []
|
grouped: list[dict] = []
|
||||||
@@ -79,7 +75,6 @@ def _group_cells(cells: list[dict], max_cells: int) -> list[dict]:
|
|||||||
|
|
||||||
def _build_piece_cells(total_chunks: int, have_bits: list[int], seen_bits: list[int]) -> list[dict]:
|
def _build_piece_cells(total_chunks: int, have_bits: list[int], seen_bits: list[int]) -> list[dict]:
|
||||||
"""Create one raw cell per real torrent piece."""
|
"""Create one raw cell per real torrent piece."""
|
||||||
# Note: The UI still groups these cells later when needed, but the source data remains exact per piece.
|
|
||||||
cells: list[dict] = []
|
cells: list[dict] = []
|
||||||
for idx in range(max(0, int(total_chunks or 0))):
|
for idx in range(max(0, int(total_chunks or 0))):
|
||||||
completed = 1 if idx < len(have_bits) and have_bits[idx] else 0
|
completed = 1 if idx < len(have_bits) and have_bits[idx] else 0
|
||||||
@@ -101,7 +96,6 @@ def _build_piece_cells(total_chunks: int, have_bits: list[int], seen_bits: list[
|
|||||||
|
|
||||||
def torrent_chunks(profile: dict, torrent_hash: str, max_cells: int = 2048) -> dict:
|
def torrent_chunks(profile: dict, torrent_hash: str, max_cells: int = 2048) -> dict:
|
||||||
"""Return ruTorrent-like visual chunk data for one torrent."""
|
"""Return ruTorrent-like visual chunk data for one torrent."""
|
||||||
# Note: Uses documented rTorrent XML-RPC fields: d.bitfield, d.chunks_seen, d.chunk_size and d.size_chunks.
|
|
||||||
c = client_for(profile)
|
c = client_for(profile)
|
||||||
values = {
|
values = {
|
||||||
"bitfield": _clean_hex_bitfield(c.call("d.bitfield", torrent_hash)),
|
"bitfield": _clean_hex_bitfield(c.call("d.bitfield", torrent_hash)),
|
||||||
@@ -177,7 +171,6 @@ def _files_touching_chunks(c: ScgiRtorrentClient, torrent_hash: str, first_chunk
|
|||||||
|
|
||||||
def torrent_chunk_action(profile: dict, torrent_hash: str, action: str, payload: dict | None = None) -> dict:
|
def torrent_chunk_action(profile: dict, torrent_hash: str, action: str, payload: dict | None = None) -> dict:
|
||||||
"""Run safe actions related to visual chunk selection."""
|
"""Run safe actions related to visual chunk selection."""
|
||||||
# Note: rTorrent does not expose a supported XML-RPC method to redownload one arbitrary chunk; recheck is torrent-wide.
|
|
||||||
payload = payload or {}
|
payload = payload or {}
|
||||||
action = str(action or "").strip().lower()
|
action = str(action or "").strip().lower()
|
||||||
c = client_for(profile)
|
c = client_for(profile)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import errno
|
import errno
|
||||||
import os
|
import os
|
||||||
import posixpath
|
import posixpath
|
||||||
@@ -95,6 +94,7 @@ _REMOTE_USAGE_CACHE: dict[int, tuple[float, dict]] = {}
|
|||||||
_REMOTE_USAGE_TTL_SECONDS = 60.0
|
_REMOTE_USAGE_TTL_SECONDS = 60.0
|
||||||
_REMOTE_PUBLIC_IP_CACHE: dict[int, tuple[float, str]] = {}
|
_REMOTE_PUBLIC_IP_CACHE: dict[int, tuple[float, str]] = {}
|
||||||
_REMOTE_PUBLIC_IP_TTL_SECONDS = 6 * 60 * 60.0
|
_REMOTE_PUBLIC_IP_TTL_SECONDS = 6 * 60 * 60.0
|
||||||
|
PY_MANUAL_PAUSE_FIELD = "py_manual_pause"
|
||||||
POST_CHECK_DOWNLOAD_LABEL = "To download after check"
|
POST_CHECK_DOWNLOAD_LABEL = "To download after check"
|
||||||
_POST_CHECK_WATCH_TTL_SECONDS = 48 * 60 * 60
|
_POST_CHECK_WATCH_TTL_SECONDS = 48 * 60 * 60
|
||||||
_POST_CHECK_WATCH_MIN_SECONDS = 2.0
|
_POST_CHECK_WATCH_MIN_SECONDS = 2.0
|
||||||
@@ -345,6 +345,30 @@ def _run_remote_rm(c: ScgiRtorrentClient, path: str, poll_interval: float = 2.0)
|
|||||||
raise RuntimeError(output)
|
raise RuntimeError(output)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def remote_can_write_directory(profile: dict, path: str) -> dict:
|
||||||
|
"""Return whether the source rTorrent OS user can write to a remote directory safely."""
|
||||||
|
clean = _remote_clean_path(path)
|
||||||
|
# Note: Profile transfers may touch filesystem paths, so only absolute non-root directories are probed.
|
||||||
|
if not clean.startswith("/") or clean in {"/", "."}:
|
||||||
|
return {"ok": False, "path": clean, "error": "unsafe destination path"}
|
||||||
|
script = (
|
||||||
|
'p=$1; '
|
||||||
|
'case "$p" in /*) ;; *) echo "NO\tunsafe path"; exit 0;; esac; '
|
||||||
|
'if [ -d "$p" ]; then '
|
||||||
|
' if [ -w "$p" ]; then echo "OK\tdirectory writable"; else echo "NO\tdirectory not writable"; fi; '
|
||||||
|
' exit 0; '
|
||||||
|
'fi; '
|
||||||
|
'parent=${p%/*}; [ -n "$parent" ] || parent=/; '
|
||||||
|
'if [ -d "$parent" ] && [ -w "$parent" ]; then echo "OK\tparent writable"; else echo "NO\tparent not writable"; fi'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
output = str(_rt_execute(client_for(profile), "execute.capture", "sh", "-c", script, "pytorrent-transfer-write-check", clean) or "").strip()
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "path": clean, "error": str(exc)}
|
||||||
|
ok = output.startswith("OK")
|
||||||
|
return {"ok": ok, "path": clean, "message": output.split("\t", 1)[1] if "\t" in output else output}
|
||||||
|
|
||||||
def _remove_torrent_data(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
def _remove_torrent_data(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
||||||
data_path = _safe_rm_rf_path(_torrent_data_path(c, torrent_hash))
|
data_path = _safe_rm_rf_path(_torrent_data_path(c, torrent_hash))
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .client import *
|
from .client import *
|
||||||
|
|
||||||
RTORRENT_CONFIG_FIELDS = [
|
RTORRENT_CONFIG_FIELDS = [
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .client import *
|
from .client import *
|
||||||
from .. import poller_control
|
from .. import poller_control
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .client import *
|
from .client import *
|
||||||
from ...config import BASE_DIR
|
from ...config import BASE_DIR
|
||||||
|
|
||||||
@@ -25,7 +24,6 @@ def torrent_files(profile: dict, torrent_hash: str) -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
def torrent_file_tree(profile: dict, torrent_hash: str) -> dict:
|
def torrent_file_tree(profile: dict, torrent_hash: str) -> dict:
|
||||||
# Note: The tree is built from rTorrent file paths without changing the existing flat file API.
|
|
||||||
root = {"name": "", "path": "", "type": "directory", "size": 0, "children": {}}
|
root = {"name": "", "path": "", "type": "directory", "size": 0, "children": {}}
|
||||||
for item in torrent_files(profile, torrent_hash):
|
for item in torrent_files(profile, torrent_hash):
|
||||||
parts = [part for part in str(item.get("path") or "").split("/") if part]
|
parts = [part for part in str(item.get("path") or "").split("/") if part]
|
||||||
|
|||||||
@@ -1,4 +1,2 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
# Note: Backward-compatible internal alias for modules created during refactor.
|
|
||||||
from .client import *
|
from .client import *
|
||||||
|
|||||||
@@ -1,18 +1,77 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from threading import RLock
|
from threading import RLock
|
||||||
|
|
||||||
from .client import *
|
from .client import *
|
||||||
from .config import default_download_path
|
from .config import default_download_path
|
||||||
from ...utils import human_size
|
from ...utils import human_size
|
||||||
|
|
||||||
|
|
||||||
def browse_path(profile: dict, path: str | None = None) -> dict:
|
|
||||||
"""List directories through rTorrent execute.capture to avoid pyTorrent FS permissions."""
|
def _rtorrent_home_path(profile: dict) -> str:
|
||||||
# Note: Directory browsing stays remote-side, matching the original monolithic service behavior.
|
# Note: This reads the remote rTorrent process home, not the pyTorrent server home.
|
||||||
|
try:
|
||||||
|
c = client_for(profile)
|
||||||
|
return _remote_clean_path(str(_rt_execute(c, "execute.capture", "sh", "-c", 'printf "%s" "${HOME:-}"') or "").strip())
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _append_path_browse_candidate(candidates: list[str], value: str) -> None:
|
||||||
|
clean = _remote_clean_path(value or "")
|
||||||
|
if clean and clean.startswith("/") and clean != "/" and clean not in candidates:
|
||||||
|
candidates.append(clean)
|
||||||
|
|
||||||
|
|
||||||
|
def _path_browse_fallback_candidates(profile: dict) -> list[str]:
|
||||||
|
candidates: list[str] = []
|
||||||
|
download_path = _remote_clean_path(default_download_path(profile) or "")
|
||||||
|
download_parent = _remote_clean_path(posixpath.dirname(download_path.rstrip("/")) if download_path else "")
|
||||||
|
|
||||||
|
# Note: Fallback prefers the configured download area, then its parent, then the rTorrent user home.
|
||||||
|
_append_path_browse_candidate(candidates, download_path)
|
||||||
|
_append_path_browse_candidate(candidates, download_parent)
|
||||||
|
_append_path_browse_candidate(candidates, _rtorrent_home_path(profile))
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_accessible_directory(profile: dict, paths: list[str]) -> str:
|
||||||
c = client_for(profile)
|
c = client_for(profile)
|
||||||
base = _remote_clean_path(path or default_download_path(profile))
|
script = (
|
||||||
|
'for base in "$@"; do '
|
||||||
|
'[ -n "$base" ] || continue; '
|
||||||
|
'[ "$base" = "/" ] && continue; '
|
||||||
|
'[ -d "$base" ] || continue; '
|
||||||
|
'[ -L "$base" ] && continue; '
|
||||||
|
'[ -r "$base" ] || continue; '
|
||||||
|
'[ -x "$base" ] || continue; '
|
||||||
|
'physical=$(cd -P -- "$base" 2>/dev/null && pwd -P) || continue; '
|
||||||
|
'[ -n "$physical" ] || continue; '
|
||||||
|
'[ "$physical" = "/" ] && continue; '
|
||||||
|
'printf "%s" "$physical"; exit 0; '
|
||||||
|
'done'
|
||||||
|
)
|
||||||
|
clean_paths = [_remote_clean_path(path or "") for path in paths if str(path or "").strip()]
|
||||||
|
output = _rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-access-check", *clean_paths)
|
||||||
|
return _remote_clean_path(str(output or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_browse_base(profile: dict, requested_path: str | None) -> tuple[str, str, bool]:
|
||||||
|
fallback_candidates = _path_browse_fallback_candidates(profile)
|
||||||
|
fallback = _remote_accessible_directory(profile, fallback_candidates)
|
||||||
|
if not fallback:
|
||||||
|
raise RuntimeError("Cannot determine an accessible rTorrent browse fallback")
|
||||||
|
|
||||||
|
requested = _remote_clean_path(requested_path or fallback)
|
||||||
|
if requested == "/":
|
||||||
|
return fallback, fallback, True
|
||||||
|
|
||||||
|
allowed = _remote_accessible_directory(profile, [requested])
|
||||||
|
return (allowed or fallback), fallback, not bool(allowed)
|
||||||
|
|
||||||
|
def browse_path(profile: dict, path: str | None = None) -> dict:
|
||||||
|
"""List allowed rTorrent directories through execute.capture without exposing the full filesystem."""
|
||||||
|
c = client_for(profile)
|
||||||
|
base, fallback_root, used_fallback = _safe_browse_base(profile, path)
|
||||||
script = (
|
script = (
|
||||||
'base=$1; '
|
'base=$1; '
|
||||||
'[ -d "$base" ] || exit 2; '
|
'[ -d "$base" ] || exit 2; '
|
||||||
@@ -20,7 +79,17 @@ def browse_path(profile: dict, path: str | None = None) -> dict:
|
|||||||
'dir_count=0; file_count=0; '
|
'dir_count=0; file_count=0; '
|
||||||
'for p in "$base"/* "$base"/.[!.]* "$base"/..?*; do '
|
'for p in "$base"/* "$base"/.[!.]* "$base"/..?*; do '
|
||||||
'[ -e "$p" ] || continue; '
|
'[ -e "$p" ] || continue; '
|
||||||
'if [ -d "$p" ]; then dir_count=$((dir_count+1)); name=${p##*/}; printf "D\\t%s\\t%s\\n" "$name" "$p"; '
|
'[ -L "$p" ] && continue; '
|
||||||
|
'if [ -d "$p" ]; then '
|
||||||
|
'dir_count=$((dir_count+1)); '
|
||||||
|
'[ -r "$p" ] || continue; '
|
||||||
|
'[ -x "$p" ] || continue; '
|
||||||
|
'physical=$(cd -P -- "$p" 2>/dev/null && pwd -P) || continue; '
|
||||||
|
'[ -n "$physical" ] || continue; '
|
||||||
|
'[ "$physical" = "/" ] && continue; '
|
||||||
|
'name=${p##*/}; empty=1; '
|
||||||
|
'if find "$physical" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null | grep -q .; then empty=0; fi; '
|
||||||
|
'printf "D\\t%s\\t%s\\t%s\\n" "$name" "$physical" "$empty"; '
|
||||||
'elif [ -f "$p" ]; then file_count=$((file_count+1)); fi; '
|
'elif [ -f "$p" ]; then file_count=$((file_count+1)); fi; '
|
||||||
'done; '
|
'done; '
|
||||||
'printf "M\\t%s\\t%s\\n" "$dir_count" "$file_count"; '
|
'printf "M\\t%s\\t%s\\n" "$dir_count" "$file_count"; '
|
||||||
@@ -37,9 +106,11 @@ def browse_path(profile: dict, path: str | None = None) -> dict:
|
|||||||
continue
|
continue
|
||||||
marker, rest = line.split("\t", 1)
|
marker, rest = line.split("\t", 1)
|
||||||
if marker == "D" and "\t" in rest:
|
if marker == "D" and "\t" in rest:
|
||||||
name, full_path = rest.split("\t", 1)
|
parts = rest.split("\t", 2)
|
||||||
|
name, full_path = parts[0], parts[1]
|
||||||
|
is_empty = len(parts) > 2 and parts[2] == "1"
|
||||||
if name not in {".", ".."}:
|
if name not in {".", ".."}:
|
||||||
dirs.append({"name": name, "path": full_path})
|
dirs.append({"name": name, "path": full_path, "empty": is_empty})
|
||||||
elif marker == "M" and "\t" in rest:
|
elif marker == "M" and "\t" in rest:
|
||||||
first, second = rest.split("\t", 1)
|
first, second = rest.split("\t", 1)
|
||||||
try:
|
try:
|
||||||
@@ -59,12 +130,15 @@ def browse_path(profile: dict, path: str | None = None) -> dict:
|
|||||||
disk_total = disk_used = disk_free = disk_percent = 0
|
disk_total = disk_used = disk_free = disk_percent = 0
|
||||||
dirs.sort(key=lambda x: x["name"].lower())
|
dirs.sort(key=lambda x: x["name"].lower())
|
||||||
parent = posixpath.dirname(base.rstrip("/")) or "/"
|
parent = posixpath.dirname(base.rstrip("/")) or "/"
|
||||||
if parent == base:
|
if parent == base or parent == "/" or not _remote_accessible_directory(profile, [parent]):
|
||||||
parent = base
|
parent = base
|
||||||
# Note: Path picker metadata is best-effort and remote-side, so it works for move targets on remote rTorrent hosts.
|
|
||||||
return {
|
return {
|
||||||
"path": base,
|
"path": base,
|
||||||
"parent": parent,
|
"parent": parent,
|
||||||
|
"root": fallback_root,
|
||||||
|
"allowed_roots": [fallback_root],
|
||||||
|
"access_policy": "rtorrent-permissions",
|
||||||
|
"fallback": used_fallback,
|
||||||
"dirs": dirs[:300],
|
"dirs": dirs[:300],
|
||||||
"source": "rtorrent",
|
"source": "rtorrent",
|
||||||
"dir_count": dir_count,
|
"dir_count": dir_count,
|
||||||
@@ -78,6 +152,59 @@ def browse_path(profile: dict, path: str | None = None) -> dict:
|
|||||||
"used_percent": disk_percent,
|
"used_percent": disk_percent,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_directory_name(name: str) -> str:
|
||||||
|
value = str(name or "").strip()
|
||||||
|
if not value or value in {".", ".."} or "/" in value or "\x00" in value:
|
||||||
|
raise ValueError("Invalid directory name")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def create_directory(profile: dict, parent: str, name: str) -> dict:
|
||||||
|
"""Create a remote directory without changing existing path-picker behavior."""
|
||||||
|
# Note: Directory creation is remote-side, so Add/Move sees the same filesystem as rTorrent.
|
||||||
|
c = client_for(profile)
|
||||||
|
clean_parent = _remote_clean_path(parent or default_download_path(profile))
|
||||||
|
clean_name = _safe_directory_name(name)
|
||||||
|
target = _remote_join(clean_parent, clean_name)
|
||||||
|
script = (
|
||||||
|
'parent=$1; target=$2; '
|
||||||
|
'if [ ! -d "$parent" ]; then printf "ERR\tParent directory does not exist"; exit 0; fi; '
|
||||||
|
'if [ -e "$target" ] || [ -L "$target" ]; then printf "ERR\tDirectory already exists"; exit 0; fi; '
|
||||||
|
'mkdir -- "$target" 2>/dev/null || { printf "ERR\tCannot create directory"; exit 0; }; '
|
||||||
|
'printf "OK\t%s" "$target"'
|
||||||
|
)
|
||||||
|
output = str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-mkdir", clean_parent, target) or "").strip()
|
||||||
|
if not output.startswith("OK\t"):
|
||||||
|
raise RuntimeError(output.split("\t", 1)[1] if "\t" in output else "Cannot create directory")
|
||||||
|
return {"path": output.split("\t", 1)[1], "name": clean_name}
|
||||||
|
|
||||||
|
|
||||||
|
def rename_empty_directory(profile: dict, path: str, new_name: str) -> dict:
|
||||||
|
"""Rename an empty remote directory in place."""
|
||||||
|
# Note: Rename is intentionally limited to empty folders to avoid invalidating active torrent paths.
|
||||||
|
c = client_for(profile)
|
||||||
|
source = _remote_clean_path(path or "")
|
||||||
|
clean_name = _safe_directory_name(new_name)
|
||||||
|
if not source or source == "/":
|
||||||
|
raise ValueError("Cannot rename this directory")
|
||||||
|
parent = posixpath.dirname(source.rstrip("/")) or "/"
|
||||||
|
target = _remote_join(parent, clean_name)
|
||||||
|
if source == target:
|
||||||
|
return {"path": target, "name": clean_name, "parent": parent}
|
||||||
|
script = (
|
||||||
|
'src=$1; dst=$2; '
|
||||||
|
'if [ ! -d "$src" ]; then printf "ERR\tDirectory does not exist"; exit 0; fi; '
|
||||||
|
'if [ -e "$dst" ] || [ -L "$dst" ]; then printf "ERR\tTarget directory already exists"; exit 0; fi; '
|
||||||
|
'if [ -n "$(find "$src" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]; then printf "ERR\tOnly empty directories can be renamed"; exit 0; fi; '
|
||||||
|
'mv -- "$src" "$dst" 2>/dev/null || { printf "ERR\tCannot rename directory"; exit 0; }; '
|
||||||
|
'printf "OK\t%s" "$dst"'
|
||||||
|
)
|
||||||
|
output = str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-rename-dir", source, target) or "").strip()
|
||||||
|
if not output.startswith("OK\t"):
|
||||||
|
raise RuntimeError(output.split("\t", 1)[1] if "\t" in output else "Cannot rename directory")
|
||||||
|
return {"path": output.split("\t", 1)[1], "name": clean_name, "parent": parent}
|
||||||
|
|
||||||
def remote_public_ip(profile: dict, force: bool = False) -> str:
|
def remote_public_ip(profile: dict, force: bool = False) -> str:
|
||||||
profile_id = int(profile.get("id") or 0)
|
profile_id = int(profile.get("id") or 0)
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from .client import *
|
from .client import *
|
||||||
from .files import set_file_priorities
|
from .files import export_torrent_file, iter_remote_file_chunks, set_file_priorities
|
||||||
from .system import disk_usage_for_default_path
|
from .system import disk_usage_for_default_path
|
||||||
|
|
||||||
|
|
||||||
XMLRPC_DEFAULT_SIZE_LIMIT_BYTES = 512 * 1024
|
XMLRPC_DEFAULT_SIZE_LIMIT_BYTES = 512 * 1024
|
||||||
|
|
||||||
|
|
||||||
def _parse_xmlrpc_size_limit(value) -> int:
|
def _parse_xmlrpc_size_limit(value) -> int:
|
||||||
"""Parse rTorrent XML-RPC size values such as 524288, 16M or 8K."""
|
"""Parse rTorrent XML-RPC size values such as 524288, 16M or 8K."""
|
||||||
# Note: rTorrent accepts human suffixes in config files; UI validation normalizes them to bytes.
|
|
||||||
text = str(value or '').strip().lower()
|
text = str(value or '').strip().lower()
|
||||||
if not text:
|
if not text:
|
||||||
return XMLRPC_DEFAULT_SIZE_LIMIT_BYTES
|
return XMLRPC_DEFAULT_SIZE_LIMIT_BYTES
|
||||||
@@ -29,7 +25,6 @@ def _parse_xmlrpc_size_limit(value) -> int:
|
|||||||
|
|
||||||
def xmlrpc_size_limit(profile: dict) -> dict:
|
def xmlrpc_size_limit(profile: dict) -> dict:
|
||||||
"""Return the current rTorrent XML-RPC request size limit."""
|
"""Return the current rTorrent XML-RPC request size limit."""
|
||||||
# Note: This value controls .torrent uploads because load.raw sends the torrent through XML-RPC.
|
|
||||||
try:
|
try:
|
||||||
raw = client_for(profile).call('network.xmlrpc.size_limit')
|
raw = client_for(profile).call('network.xmlrpc.size_limit')
|
||||||
limit = _parse_xmlrpc_size_limit(raw)
|
limit = _parse_xmlrpc_size_limit(raw)
|
||||||
@@ -40,7 +35,6 @@ def xmlrpc_size_limit(profile: dict) -> dict:
|
|||||||
|
|
||||||
def estimate_torrent_upload_request_size(data: bytes, start: bool = True, directory: str = '', label: str = '', file_priorities: list[dict] | None = None) -> int:
|
def estimate_torrent_upload_request_size(data: bytes, start: bool = True, directory: str = '', label: str = '', file_priorities: list[dict] | None = None) -> int:
|
||||||
"""Estimate the XML-RPC body size produced by rTorrent load.raw* for a .torrent file."""
|
"""Estimate the XML-RPC body size produced by rTorrent load.raw* for a .torrent file."""
|
||||||
# Note: XML-RPC uses base64 for Binary payloads, so the request is larger than the raw .torrent file.
|
|
||||||
commands = []
|
commands = []
|
||||||
if directory:
|
if directory:
|
||||||
commands.append(f'd.directory.set={directory}')
|
commands.append(f'd.directory.set={directory}')
|
||||||
@@ -93,7 +87,6 @@ def _is_post_check_watched(profile_id: int, torrent_hash: str) -> bool:
|
|||||||
if age > _POST_CHECK_WATCH_TTL_SECONDS:
|
if age > _POST_CHECK_WATCH_TTL_SECONDS:
|
||||||
_clear_post_check_watch(profile_id, torrent_hash)
|
_clear_post_check_watch(profile_id, torrent_hash)
|
||||||
return False
|
return False
|
||||||
# Note: A short grace period prevents labeling a recheck that was queued but has not visibly entered hashing yet.
|
|
||||||
return age >= _POST_CHECK_WATCH_MIN_SECONDS
|
return age >= _POST_CHECK_WATCH_MIN_SECONDS
|
||||||
|
|
||||||
|
|
||||||
@@ -124,7 +117,6 @@ def clear_post_check_download_label(c: ScgiRtorrentClient, torrent_hash: str, cu
|
|||||||
labels = _label_names(str(label_source or ""))
|
labels = _label_names(str(label_source or ""))
|
||||||
if POST_CHECK_DOWNLOAD_LABEL not in labels:
|
if POST_CHECK_DOWNLOAD_LABEL not in labels:
|
||||||
return False
|
return False
|
||||||
# Note: The temporary post-check label is removed only after the torrent leaves the stopped waiting queue.
|
|
||||||
c.call("d.custom1.set", str(torrent_hash or ""), _label_value([label for label in labels if label != POST_CHECK_DOWNLOAD_LABEL]))
|
c.call("d.custom1.set", str(torrent_hash or ""), _label_value([label for label in labels if label != POST_CHECK_DOWNLOAD_LABEL]))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -151,11 +143,9 @@ def _cleanup_post_check_label_if_ready(c: ScgiRtorrentClient, row: dict) -> bool
|
|||||||
if POST_CHECK_DOWNLOAD_LABEL not in labels:
|
if POST_CHECK_DOWNLOAD_LABEL not in labels:
|
||||||
return False
|
return False
|
||||||
status = str(row.get("status") or "").lower()
|
status = str(row.get("status") or "").lower()
|
||||||
# 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"
|
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):
|
if not (_row_progress_complete(row) or status == "seeding" or started_after_wait):
|
||||||
return False
|
return False
|
||||||
# Note: Keep the post-check label while the torrent is stopped; remove it once it is started for download/seeding.
|
|
||||||
clear_post_check_download_label(c, str(row.get("hash") or ""), str(row.get("label") or ""))
|
clear_post_check_download_label(c, str(row.get("hash") or ""), str(row.get("label") or ""))
|
||||||
row["label"] = _without_post_check_download_label(str(row.get("label") or ""))
|
row["label"] = _without_post_check_download_label(str(row.get("label") or ""))
|
||||||
return True
|
return True
|
||||||
@@ -183,7 +173,6 @@ def apply_post_check_policy(profile: dict, rows: list[dict], previous_rows: dict
|
|||||||
complete = _row_progress_complete(row)
|
complete = _row_progress_complete(row)
|
||||||
try:
|
try:
|
||||||
if complete:
|
if complete:
|
||||||
# Note: A fully checked torrent is started with the same helper as the manual Start action so it seeds immediately.
|
|
||||||
start_result = start_or_resume_hash(c, h)
|
start_result = start_or_resume_hash(c, h)
|
||||||
clear_post_check_download_label(c, h, str(row.get("label") or ""))
|
clear_post_check_download_label(c, h, str(row.get("label") or ""))
|
||||||
row.update({"state": 1, "active": 1, "paused": False, "status": "Seeding", "label": _without_post_check_download_label(str(row.get("label") or ""))})
|
row.update({"state": 1, "active": 1, "paused": False, "status": "Seeding", "label": _without_post_check_download_label(str(row.get("label") or ""))})
|
||||||
@@ -193,7 +182,6 @@ def apply_post_check_policy(profile: dict, rows: list[dict], previous_rows: dict
|
|||||||
if POST_CHECK_DOWNLOAD_LABEL not in labels:
|
if POST_CHECK_DOWNLOAD_LABEL not in labels:
|
||||||
labels.append(POST_CHECK_DOWNLOAD_LABEL)
|
labels.append(POST_CHECK_DOWNLOAD_LABEL)
|
||||||
label_value = _label_value(labels)
|
label_value = _label_value(labels)
|
||||||
# Note: Incomplete torrents are left stopped after check so Smart Queue can start them later within the global limit.
|
|
||||||
c.call("d.stop", h)
|
c.call("d.stop", h)
|
||||||
try:
|
try:
|
||||||
c.call("d.close", h)
|
c.call("d.close", h)
|
||||||
@@ -212,7 +200,7 @@ TORRENT_FIELDS = [
|
|||||||
"d.hash=", "d.name=", "d.state=", "d.complete=", "d.size_bytes=", "d.completed_bytes=",
|
"d.hash=", "d.name=", "d.state=", "d.complete=", "d.size_bytes=", "d.completed_bytes=",
|
||||||
"d.ratio=", "d.up.rate=", "d.down.rate=", "d.up.total=", "d.down.total=", "d.peers_connected=",
|
"d.ratio=", "d.up.rate=", "d.down.rate=", "d.up.total=", "d.down.total=", "d.peers_connected=",
|
||||||
"d.peers_complete=", "d.priority=", "d.directory=", "d.base_path=", "d.creation_date=", "d.custom1=",
|
"d.peers_complete=", "d.priority=", "d.directory=", "d.base_path=", "d.creation_date=", "d.custom1=",
|
||||||
"d.custom=py_ratio_group", "d.message=", "d.hashing=", "d.is_active=", "d.is_multi_file=",
|
"d.custom=py_ratio_group", f"d.custom={PY_MANUAL_PAUSE_FIELD}", "d.message=", "d.hashing=", "d.is_active=", "d.is_open=", "d.is_multi_file=",
|
||||||
]
|
]
|
||||||
|
|
||||||
TORRENT_OPTIONAL_FIELDS = [
|
TORRENT_OPTIONAL_FIELDS = [
|
||||||
@@ -224,12 +212,11 @@ LIVE_TORRENT_FIELDS = [
|
|||||||
"d.hash=", "d.state=", "d.complete=", "d.size_bytes=", "d.completed_bytes=",
|
"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.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.peers_connected=", "d.peers_complete=", "d.message=", "d.hashing=", "d.is_active=",
|
||||||
"d.custom1=",
|
"d.is_open=", "d.custom1=", f"d.custom={PY_MANUAL_PAUSE_FIELD}",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def human_duration(seconds: int) -> str:
|
def human_duration(seconds: int) -> str:
|
||||||
# Note: Download ETA is derived locally from remaining bytes and current download speed.
|
|
||||||
seconds = max(0, int(seconds or 0))
|
seconds = max(0, int(seconds or 0))
|
||||||
if seconds <= 0:
|
if seconds <= 0:
|
||||||
return '-'
|
return '-'
|
||||||
@@ -254,17 +241,10 @@ def normalize_row(row: list) -> dict:
|
|||||||
eta_seconds = int(remaining_bytes / down_rate) if down_rate > 0 and not int(row[3] or 0) else 0
|
eta_seconds = int(remaining_bytes / down_rate) if down_rate > 0 and not int(row[3] or 0) else 0
|
||||||
directory = str(row[14] or "")
|
directory = str(row[14] or "")
|
||||||
base_path = str(row[15] or "")
|
base_path = str(row[15] or "")
|
||||||
is_multi_file = int(row[22] or 0) if len(row) > 22 else 0
|
state = int(row[2] or 0)
|
||||||
# Note: Last activity is optional because older rTorrent builds may not expose this timestamp.
|
complete = int(row[3] or 0)
|
||||||
last_activity = int(row[23] or 0) if len(row) > 23 else 0
|
is_multi_file = int(row[24] or 0) if len(row) > 24 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
|
|
||||||
# torrents. Data deletion still uses the full d.base_path elsewhere.
|
|
||||||
if base_path and base_path != "/":
|
if base_path and base_path != "/":
|
||||||
display_parent = posixpath.dirname(base_path.rstrip("/")) or "/"
|
display_parent = posixpath.dirname(base_path.rstrip("/")) or "/"
|
||||||
display_path = display_parent.rstrip("/") + "/" if display_parent != "/" else display_parent
|
display_path = display_parent.rstrip("/") + "/" if display_parent != "/" else display_parent
|
||||||
@@ -275,26 +255,31 @@ def normalize_row(row: list) -> dict:
|
|||||||
display_path = directory.rstrip("/") + "/" if directory != "/" else directory
|
display_path = directory.rstrip("/") + "/" if directory != "/" else directory
|
||||||
else:
|
else:
|
||||||
display_path = ""
|
display_path = ""
|
||||||
msg = str(row[19] or "")
|
manual_pause = str(row[19] or "").strip() == "1"
|
||||||
|
msg = str(row[20] or "")
|
||||||
msg_l = msg.lower()
|
msg_l = msg.lower()
|
||||||
hashing = int(row[20] or 0) if len(row) > 20 else 0
|
hashing = int(row[21] or 0) if len(row) > 21 else 0
|
||||||
is_active = int(row[21] or 0) if len(row) > 21 else int(row[2] or 0)
|
is_active = int(row[22] or 0) if len(row) > 22 else int(state)
|
||||||
state = int(row[2] or 0)
|
is_open = int(row[23] or 0) if len(row) > 23 else int(is_active or state)
|
||||||
complete = int(row[3] or 0)
|
last_activity = int(row[25] or 0) if len(row) > 25 else 0
|
||||||
# Note: d.hashing is authoritative; stale "hash check complete" messages must not keep the UI in Checking forever.
|
if not last_activity and (down_rate > 0 or up_rate > 0):
|
||||||
|
last_activity = int(time.time())
|
||||||
|
completed_at = int(row[26] or 0) if len(row) > 26 else 0
|
||||||
is_checking = bool(hashing) or _message_indicates_active_check(msg_l)
|
is_checking = bool(hashing) or _message_indicates_active_check(msg_l)
|
||||||
post_check = POST_CHECK_DOWNLOAD_LABEL in _label_names(str(row[17] or "")) and not is_checking and not bool(is_active)
|
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
|
is_paused = manual_pause 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.
|
is_queued = bool(state) and bool(is_open) and not bool(is_active) and not bool(complete) and not is_paused and not is_checking and not post_check
|
||||||
status = "Checking" if is_checking else "Post-check" if post_check else "Paused" if is_paused else "Seeding" if complete and state else "Downloading" if state else "Stopped"
|
status = "Checking" if is_checking else "Post-check" if post_check else "Paused" if is_paused else "Queued" if is_queued else "Seeding" if complete and state else "Downloading" if state else "Stopped"
|
||||||
to_download_bytes = remaining_bytes if not complete else 0
|
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 {
|
return {
|
||||||
"hash": str(row[0] or ""),
|
"hash": str(row[0] or ""),
|
||||||
"name": str(row[1] or ""),
|
"name": str(row[1] or ""),
|
||||||
"state": state,
|
"state": state,
|
||||||
"active": is_active,
|
"active": is_active,
|
||||||
|
"open": is_open,
|
||||||
"paused": is_paused,
|
"paused": is_paused,
|
||||||
|
"queued": is_queued,
|
||||||
"complete": complete,
|
"complete": complete,
|
||||||
"size": size,
|
"size": size,
|
||||||
"size_h": human_size(size),
|
"size_h": human_size(size),
|
||||||
@@ -331,7 +316,6 @@ def normalize_row(row: list) -> dict:
|
|||||||
|
|
||||||
def normalize_live_row(row: list) -> dict:
|
def normalize_live_row(row: list) -> dict:
|
||||||
"""Normalize the small row used by the fast live stats poller."""
|
"""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)
|
size = int(row[3] or 0)
|
||||||
completed = int(row[4] or 0)
|
completed = int(row[4] or 0)
|
||||||
complete = int(row[2] or 0)
|
complete = int(row[2] or 0)
|
||||||
@@ -344,18 +328,24 @@ def normalize_live_row(row: list) -> dict:
|
|||||||
msg = str(row[12] or "")
|
msg = str(row[12] or "")
|
||||||
hashing = int(row[13] or 0)
|
hashing = int(row[13] or 0)
|
||||||
is_active = int(row[14] or 0)
|
is_active = int(row[14] or 0)
|
||||||
labels = str(row[15] or "")
|
is_open = int(row[15] or 0) if len(row) > 15 else int(is_active or state)
|
||||||
|
labels = str(row[16] or "") if len(row) > 16 else ""
|
||||||
|
manual_pause = str(row[17] or "").strip() == "1" if len(row) > 17 else False
|
||||||
is_checking = bool(hashing) or _message_indicates_active_check(msg.lower())
|
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)
|
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
|
# Note: Live patches keep Queued separate from explicit user Paused using the same app marker as full snapshots.
|
||||||
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"
|
is_paused = manual_pause and not is_checking and not post_check
|
||||||
|
is_queued = bool(state) and bool(is_open) and not bool(is_active) and not bool(complete) and not is_paused and not is_checking and not post_check
|
||||||
|
status = "Checking" if is_checking else "Post-check" if post_check else "Paused" if is_paused else "Queued" if is_queued else "Seeding" if complete and state else "Downloading" if state else "Stopped"
|
||||||
progress = 100.0 if size <= 0 and complete else round((completed / size) * 100, 2) if size else 0.0
|
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
|
to_download_bytes = remaining_bytes if not complete else 0
|
||||||
return {
|
return {
|
||||||
"hash": str(row[0] or ""),
|
"hash": str(row[0] or ""),
|
||||||
"state": state,
|
"state": state,
|
||||||
"active": is_active,
|
"active": is_active,
|
||||||
|
"open": is_open,
|
||||||
"paused": is_paused,
|
"paused": is_paused,
|
||||||
|
"queued": is_queued,
|
||||||
"complete": complete,
|
"complete": complete,
|
||||||
"completed_bytes": completed,
|
"completed_bytes": completed,
|
||||||
"progress": progress,
|
"progress": progress,
|
||||||
@@ -393,13 +383,10 @@ def list_torrents(profile: dict) -> list[dict]:
|
|||||||
try:
|
try:
|
||||||
rows = c.d.multicall2("", "main", *(TORRENT_FIELDS + TORRENT_OPTIONAL_FIELDS))
|
rows = c.d.multicall2("", "main", *(TORRENT_FIELDS + TORRENT_OPTIONAL_FIELDS))
|
||||||
except Exception:
|
except Exception:
|
||||||
# Keep compatibility with older rTorrent builds that do not expose optional timestamp fields.
|
|
||||||
rows = c.d.multicall2("", "main", *TORRENT_FIELDS)
|
rows = c.d.multicall2("", "main", *TORRENT_FIELDS)
|
||||||
return [normalize_row(list(row)) for row in rows]
|
return [normalize_row(list(row)) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def torrent_peers(profile: dict, torrent_hash: str) -> list[dict]:
|
def torrent_peers(profile: dict, torrent_hash: str) -> list[dict]:
|
||||||
fields = [
|
fields = [
|
||||||
"p.address=", "p.client_version=", "p.completed_percent=", "p.down_rate=",
|
"p.address=", "p.client_version=", "p.completed_percent=", "p.down_rate=",
|
||||||
@@ -431,8 +418,6 @@ def torrent_peers(profile: dict, torrent_hash: str) -> list[dict]:
|
|||||||
return peers
|
return peers
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _call_first(c: ScgiRtorrentClient, candidates: list[tuple[str, tuple]]) -> dict:
|
def _call_first(c: ScgiRtorrentClient, candidates: list[tuple[str, tuple]]) -> dict:
|
||||||
errors = []
|
errors = []
|
||||||
for method, args in candidates:
|
for method, args in candidates:
|
||||||
@@ -444,7 +429,6 @@ def _call_first(c: ScgiRtorrentClient, candidates: list[tuple[str, tuple]]) -> d
|
|||||||
raise RuntimeError("; ".join(errors))
|
raise RuntimeError("; ".join(errors))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _tracker_domain(url: str) -> str:
|
def _tracker_domain(url: str) -> str:
|
||||||
raw = str(url or '').strip()
|
raw = str(url or '').strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
@@ -458,7 +442,6 @@ def _tracker_domain(url: str) -> str:
|
|||||||
|
|
||||||
def tracker_summary(profile: dict, torrent_hashes: list[str] | None = None, limit: int = 1000) -> dict:
|
def tracker_summary(profile: dict, torrent_hashes: list[str] | None = None, limit: int = 1000) -> dict:
|
||||||
"""Return tracker domains grouped by torrent for the sidebar filter."""
|
"""Return tracker domains grouped by torrent for the sidebar filter."""
|
||||||
# Note: Tracker summary is read-only and isolated from the normal torrent snapshot, so slow tracker RPC calls cannot break the main list.
|
|
||||||
hashes = [str(h or '').strip() for h in (torrent_hashes or []) if str(h or '').strip()]
|
hashes = [str(h or '').strip() for h in (torrent_hashes or []) if str(h or '').strip()]
|
||||||
if not hashes:
|
if not hashes:
|
||||||
hashes = [t.get('hash') for t in list_torrents(profile) if t.get('hash')]
|
hashes = [t.get('hash') for t in list_torrents(profile) if t.get('hash')]
|
||||||
@@ -618,47 +601,77 @@ def _str_rpc(c: ScgiRtorrentClient, method: str, h: str, default: str = '') -> s
|
|||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _set_manual_pause(c: ScgiRtorrentClient, torrent_hash: str, enabled: bool) -> None:
|
||||||
|
"""Persist the user Pause intent without touching the visible label field."""
|
||||||
|
# Note: rTorrent has no reliable queued-vs-user-paused flag, so pyTorrent stores that intent in d.custom.
|
||||||
|
c.call('d.custom.set', str(torrent_hash or ''), PY_MANUAL_PAUSE_FIELD, '1' if enabled else '')
|
||||||
|
|
||||||
|
|
||||||
|
def _manual_pause_enabled(c: ScgiRtorrentClient, torrent_hash: str) -> bool:
|
||||||
|
h = str(torrent_hash or '')
|
||||||
|
for method, args in (
|
||||||
|
(f'd.custom={PY_MANUAL_PAUSE_FIELD}', (h,)),
|
||||||
|
('d.custom', (h, PY_MANUAL_PAUSE_FIELD)),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
if str(c.call(method, *args) or '').strip() == '1':
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
def _download_runtime_state(c: ScgiRtorrentClient, h: str) -> dict:
|
def _download_runtime_state(c: ScgiRtorrentClient, h: str) -> dict:
|
||||||
"""Read rTorrent state using the native pause model: stopped, paused or active."""
|
"""Read rTorrent state using the native pause model: stopped, paused or active."""
|
||||||
state = _int_rpc(c, 'd.state', h)
|
state = _int_rpc(c, 'd.state', h)
|
||||||
active = _int_rpc(c, 'd.is_active', h)
|
active = _int_rpc(c, 'd.is_active', h)
|
||||||
opened = _int_rpc(c, 'd.is_open', 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)
|
label = _str_rpc(c, 'd.custom1', h)
|
||||||
|
manual_pause = _manual_pause_enabled(c, h)
|
||||||
post_check = POST_CHECK_DOWNLOAD_LABEL in _label_names(label) and not bool(active)
|
post_check = POST_CHECK_DOWNLOAD_LABEL in _label_names(label) and not bool(active)
|
||||||
|
paused = bool(manual_pause and not post_check)
|
||||||
|
queued = bool(state and opened and not active and not paused and not post_check)
|
||||||
return {
|
return {
|
||||||
'state': state,
|
'state': state,
|
||||||
'open': opened,
|
'open': opened,
|
||||||
'active': active,
|
'active': active,
|
||||||
'paused': bool(state and opened and not active and not post_check),
|
'paused': paused,
|
||||||
|
'queued': queued,
|
||||||
'stopped': not bool(state),
|
'stopped': not bool(state),
|
||||||
'post_check': post_check,
|
'post_check': post_check,
|
||||||
'label': label,
|
'label': label,
|
||||||
|
'manual_pause': manual_pause,
|
||||||
'message': _str_rpc(c, 'd.message', h),
|
'message': _str_rpc(c, 'd.message', h),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def pause_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
def pause_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
||||||
"""Pause an active rTorrent item without stopping or closing it."""
|
"""Mark a torrent as user-paused and ask rTorrent to pause it."""
|
||||||
h = str(torrent_hash or '')
|
h = str(torrent_hash or '')
|
||||||
if not h:
|
if not h:
|
||||||
return {'hash': h, 'ok': False, 'error': 'missing hash'}
|
return {'hash': h, 'ok': False, 'error': 'missing hash'}
|
||||||
before = _download_runtime_state(c, h)
|
before = _download_runtime_state(c, h)
|
||||||
result = {'hash': h, 'before': before, 'commands': []}
|
result = {'hash': h, 'before': before, 'commands': []}
|
||||||
try:
|
try:
|
||||||
|
_set_manual_pause(c, h, True)
|
||||||
|
result['commands'].append('set_py_manual_pause')
|
||||||
if before.get('stopped'):
|
if before.get('stopped'):
|
||||||
# Note: rTorrent does not turn a stopped item into a paused one with d.pause alone.
|
# Note: A stopped torrent has no native paused flag; opening it first lets the UI and later Resume follow the same path.
|
||||||
# First move it out of STOP, then pause it, which matches the expected START -> PAUSE flow.
|
|
||||||
try:
|
try:
|
||||||
c.call('d.open', h)
|
c.call('d.open', h)
|
||||||
result['commands'].append('d.open')
|
result['commands'].append('d.open')
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
result.setdefault('ignored_errors', []).append(f'd.open: {exc}')
|
result.setdefault('ignored_errors', []).append(f'd.open: {exc}')
|
||||||
c.call('d.start', h)
|
try:
|
||||||
result['commands'].append('d.start')
|
c.call('d.start', h)
|
||||||
# Note: Smart Queue frees a slot with d.pause, not d.stop, so later d.resume behaves like ruTorrent.
|
result['commands'].append('d.start')
|
||||||
c.call('d.pause', h)
|
except Exception as exc:
|
||||||
result['commands'].append('d.pause')
|
result.setdefault('ignored_errors', []).append(f'd.start: {exc}')
|
||||||
|
try:
|
||||||
|
c.call('d.pause', h)
|
||||||
|
result['commands'].append('d.pause')
|
||||||
|
except Exception as exc:
|
||||||
|
result.setdefault('ignored_errors', []).append(f'd.pause: {exc}')
|
||||||
result['after'] = _download_runtime_state(c, h)
|
result['after'] = _download_runtime_state(c, h)
|
||||||
result['ok'] = True
|
result['ok'] = True
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -674,9 +687,16 @@ def stop_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
|||||||
before = _download_runtime_state(c, h)
|
before = _download_runtime_state(c, h)
|
||||||
result = {'hash': h, 'before': before, 'commands': []}
|
result = {'hash': h, 'before': before, 'commands': []}
|
||||||
if before.get('stopped') and not before.get('post_check'):
|
if before.get('stopped') and not before.get('post_check'):
|
||||||
|
if before.get('manual_pause'):
|
||||||
|
_set_manual_pause(c, h, False)
|
||||||
|
result['commands'].append('clear_py_manual_pause')
|
||||||
|
before = _download_runtime_state(c, h)
|
||||||
result.update({'ok': True, 'skipped': 'already_stopped', 'after': before})
|
result.update({'ok': True, 'skipped': 'already_stopped', 'after': before})
|
||||||
return result
|
return result
|
||||||
try:
|
try:
|
||||||
|
if before.get('manual_pause'):
|
||||||
|
_set_manual_pause(c, h, False)
|
||||||
|
result['commands'].append('clear_py_manual_pause')
|
||||||
# Note: User Stop converts the app-level Post-check state into a regular stopped torrent.
|
# Note: User Stop converts the app-level Post-check state into a regular stopped torrent.
|
||||||
if before.get('post_check'):
|
if before.get('post_check'):
|
||||||
clear_post_check_download_label(c, h, before.get('label'))
|
clear_post_check_download_label(c, h, before.get('label'))
|
||||||
@@ -692,23 +712,34 @@ def stop_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def resume_paused_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
def resume_paused_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
||||||
"""Resume only a paused rTorrent item; never convert it through stop/start."""
|
"""Resume a user-paused torrent and clear pyTorrent's pause marker."""
|
||||||
h = str(torrent_hash or '')
|
h = str(torrent_hash or '')
|
||||||
if not h:
|
if not h:
|
||||||
return {'hash': h, 'ok': False, 'error': 'missing hash'}
|
return {'hash': h, 'ok': False, 'error': 'missing hash'}
|
||||||
before = _download_runtime_state(c, h)
|
before = _download_runtime_state(c, h)
|
||||||
result: dict = {'hash': h, 'before': before, 'commands': []}
|
result: dict = {'hash': h, 'before': before, 'commands': []}
|
||||||
if before.get('stopped'):
|
if before.get('active') and not before.get('manual_pause'):
|
||||||
result.update({'ok': False, 'skipped': 'stopped_not_paused', 'after': before})
|
|
||||||
return result
|
|
||||||
if before.get('active'):
|
|
||||||
result.update({'ok': True, 'skipped': 'already_active', 'after': before})
|
result.update({'ok': True, 'skipped': 'already_active', 'after': before})
|
||||||
return result
|
return result
|
||||||
try:
|
try:
|
||||||
# Note: ruTorrent unpauses with the equivalent of d.resume. Do not add d.start/d.open,
|
if before.get('manual_pause'):
|
||||||
# because those commands belong to Stopped/Open state, not a clean Paused state.
|
_set_manual_pause(c, h, False)
|
||||||
c.call('d.resume', h)
|
result['commands'].append('clear_py_manual_pause')
|
||||||
result['commands'].append('d.resume')
|
try:
|
||||||
|
c.call('d.resume', h)
|
||||||
|
result['commands'].append('d.resume')
|
||||||
|
except Exception as exc:
|
||||||
|
result.setdefault('ignored_errors', []).append(f'd.resume: {exc}')
|
||||||
|
try:
|
||||||
|
c.call('d.open', h)
|
||||||
|
result['commands'].append('d.open')
|
||||||
|
except Exception as exc:
|
||||||
|
result.setdefault('ignored_errors', []).append(f'd.open: {exc}')
|
||||||
|
try:
|
||||||
|
c.call('d.start', h)
|
||||||
|
result['commands'].append('d.start')
|
||||||
|
except Exception as exc:
|
||||||
|
result.setdefault('ignored_errors', []).append(f'd.start: {exc}')
|
||||||
result['after'] = _download_runtime_state(c, h)
|
result['after'] = _download_runtime_state(c, h)
|
||||||
result['ok'] = True
|
result['ok'] = True
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -717,17 +748,21 @@ def resume_paused_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str, prefer_start: bool = False) -> dict:
|
def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str, prefer_start: bool = False) -> dict:
|
||||||
"""Start stopped torrents or resume real paused torrents.
|
"""Start stopped torrents and recover open/inactive paused torrents.
|
||||||
|
|
||||||
Smart Queue passes prefer_start=True for candidates that were selected as stopped.
|
rTorrent can expose a torrent as state=1, open=1 and active=0 while d.resume/d.start
|
||||||
This avoids treating rTorrent's intermediate open/inactive state after a check as
|
alone does not wake it up. Manual Start uses the same recovery path users already
|
||||||
a user pause and sending only d.resume, which can leave items pending forever.
|
perform by hand: d.stop followed by d.open and d.start.
|
||||||
"""
|
"""
|
||||||
h = str(torrent_hash or '')
|
h = str(torrent_hash or '')
|
||||||
if not h:
|
if not h:
|
||||||
return {'hash': h, 'ok': False, 'error': 'missing hash'}
|
return {'hash': h, 'ok': False, 'error': 'missing hash'}
|
||||||
before = _download_runtime_state(c, h)
|
before = _download_runtime_state(c, h)
|
||||||
result: dict = {'hash': h, 'before': before, 'commands': []}
|
result: dict = {'hash': h, 'before': before, 'commands': []}
|
||||||
|
if before.get('manual_pause'):
|
||||||
|
_set_manual_pause(c, h, False)
|
||||||
|
result['commands'].append('clear_py_manual_pause')
|
||||||
|
before = _download_runtime_state(c, h)
|
||||||
|
|
||||||
if before.get('active'):
|
if before.get('active'):
|
||||||
if before.get('post_check'):
|
if before.get('post_check'):
|
||||||
@@ -736,17 +771,9 @@ def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str, prefer_start:
|
|||||||
result.update({'ok': True, 'skipped': 'already_active', 'after': before})
|
result.update({'ok': True, 'skipped': 'already_active', 'after': before})
|
||||||
return result
|
return result
|
||||||
|
|
||||||
if before.get('paused') and not prefer_start and not before.get('post_check'):
|
if (before.get('paused') and not prefer_start) or before.get('queued') or 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.
|
|
||||||
resumed = resume_paused_hash(c, h)
|
|
||||||
resumed['mode'] = 'resume_paused'
|
|
||||||
return resumed
|
|
||||||
|
|
||||||
if before.get('post_check'):
|
|
||||||
try:
|
try:
|
||||||
# Note: Post-check start first forces a clean stopped state, matching the manual Stop -> Start recovery path.
|
# Note: Start intentionally normalizes open/inactive torrents through Stop -> Start because d.resume can leave them stuck.
|
||||||
c.call('d.stop', h)
|
c.call('d.stop', h)
|
||||||
result['commands'].append('d.stop')
|
result['commands'].append('d.stop')
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -777,6 +804,140 @@ def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str, prefer_start:
|
|||||||
result['ok'] = result.get('ok', True)
|
result['ok'] = result.get('ok', True)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _read_exported_torrent_bytes(profile: dict, torrent_hash: str) -> tuple[bytes, dict]:
|
||||||
|
item = export_torrent_file(profile, torrent_hash)
|
||||||
|
if item.get("local"):
|
||||||
|
return LocalPath(str(item.get("path") or "")).read_bytes(), item
|
||||||
|
data = b"".join(bytes(chunk) for chunk in iter_remote_file_chunks(profile, str(item.get("path") or "")) if chunk)
|
||||||
|
if not data:
|
||||||
|
raise RuntimeError(f"Cannot read exported torrent file for {torrent_hash}")
|
||||||
|
return data, item
|
||||||
|
|
||||||
|
|
||||||
|
def _move_profile_transfer_data(source_client: ScgiRtorrentClient, torrent_hash: str, target_path: str) -> dict:
|
||||||
|
"""Move one torrent data path for a profile transfer after backend permission checks."""
|
||||||
|
src = _remote_clean_path(_torrent_data_path(source_client, torrent_hash))
|
||||||
|
if not src:
|
||||||
|
raise ValueError(f"Cannot determine source path for {torrent_hash}")
|
||||||
|
dst = _remote_join(target_path, posixpath.basename(src.rstrip("/")))
|
||||||
|
try:
|
||||||
|
source_client.call("d.stop", torrent_hash)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
source_client.call("d.close", torrent_hash)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if src == dst:
|
||||||
|
return {"skipped_data_move": "source and destination are the same"}
|
||||||
|
_run_remote_move(source_client, src, dst)
|
||||||
|
return {"moved_from": src, "moved_to": dst}
|
||||||
|
|
||||||
|
|
||||||
|
def transfer_profile(source_profile: dict, target_profile: dict, torrent_hashes: list[str], payload: dict | None = None, checkpoint=None, resume_state: dict | None = None) -> dict:
|
||||||
|
"""Move torrent entries between rTorrent profiles; data moving is delegated to a separate helper."""
|
||||||
|
payload = payload or {}
|
||||||
|
resume_state = resume_state or {}
|
||||||
|
target_path = _remote_clean_path(payload.get("target_path") or payload.get("path") or "")
|
||||||
|
move_data = bool(payload.get("move_data"))
|
||||||
|
post_action = str(payload.get("post_action") or "none").strip().lower()
|
||||||
|
if post_action not in {"none", "current", "start", "stop", "pause", "check", "recheck"}:
|
||||||
|
raise ValueError("Unsupported post-transfer action")
|
||||||
|
label_mode = str(payload.get("label_mode") or "none").strip().lower()
|
||||||
|
label_value = str(payload.get("label_value") or "").strip()
|
||||||
|
if label_mode not in {"none", "custom", "moved_from", "moved_to"}:
|
||||||
|
label_mode = "none"
|
||||||
|
if label_mode == "moved_from":
|
||||||
|
label_value = f"Moved from {source_profile.get('name') or source_profile.get('id') or 'profile'}"
|
||||||
|
elif label_mode == "moved_to":
|
||||||
|
label_value = f"Moved to {target_profile.get('name') or target_profile.get('id') or 'profile'}"
|
||||||
|
elif label_mode != "custom":
|
||||||
|
label_value = ""
|
||||||
|
if len(label_value) > 120:
|
||||||
|
label_value = label_value[:120]
|
||||||
|
if not target_path or not target_path.startswith("/") or target_path == "/":
|
||||||
|
raise ValueError("Missing or unsafe target path")
|
||||||
|
completed_hashes = set(str(x) for x in (resume_state.get("completed_hashes") or []))
|
||||||
|
previous_results = list(resume_state.get("results") or [])
|
||||||
|
source_client = client_for(source_profile)
|
||||||
|
target_client = client_for(target_profile)
|
||||||
|
|
||||||
|
def mark_done(torrent_hash: str, results: list) -> None:
|
||||||
|
completed_hashes.add(str(torrent_hash))
|
||||||
|
if checkpoint:
|
||||||
|
checkpoint({"completed_hashes": sorted(completed_hashes), "results": results}, len(completed_hashes), len(torrent_hashes))
|
||||||
|
|
||||||
|
results = previous_results
|
||||||
|
for h in [x for x in torrent_hashes if str(x) not in completed_hashes]:
|
||||||
|
item = {
|
||||||
|
"hash": h,
|
||||||
|
"source_profile_id": int(source_profile.get("id") or 0),
|
||||||
|
"target_profile_id": int(target_profile.get("id") or 0),
|
||||||
|
"target_path": target_path,
|
||||||
|
"move_data": move_data,
|
||||||
|
"move_data_requested": bool(payload.get("move_data_requested")),
|
||||||
|
"move_data_downgraded": bool(payload.get("move_data_downgraded")),
|
||||||
|
}
|
||||||
|
data, exported = _read_exported_torrent_bytes(source_profile, h)
|
||||||
|
item["exported_from"] = exported.get("path")
|
||||||
|
limit = validate_torrent_upload_size(target_profile, data, False, target_path, "")
|
||||||
|
if not limit.get("ok"):
|
||||||
|
raise RuntimeError(f"Target profile XML-RPC limit is too small for {h}: {limit.get('request_h')} > {limit.get('limit_h')}")
|
||||||
|
try:
|
||||||
|
label = str(source_client.call("d.custom1", h) or "")
|
||||||
|
except Exception:
|
||||||
|
label = ""
|
||||||
|
target_label = label_value if label_value else label
|
||||||
|
try:
|
||||||
|
was_state = int(source_client.call("d.state", h) or 0)
|
||||||
|
except Exception:
|
||||||
|
was_state = 0
|
||||||
|
try:
|
||||||
|
was_active = int(source_client.call("d.is_active", h) or 0)
|
||||||
|
except Exception:
|
||||||
|
was_active = was_state
|
||||||
|
moved_to = ""
|
||||||
|
if move_data:
|
||||||
|
move_result = _move_profile_transfer_data(source_client, h, target_path)
|
||||||
|
item.update(move_result)
|
||||||
|
moved_to = str(move_result.get("moved_to") or "")
|
||||||
|
# Note: The default keeps the torrent status from the source profile; explicit actions override it.
|
||||||
|
start_on_target = bool(was_state or was_active) if post_action in {"none", "current"} else post_action == "start"
|
||||||
|
try:
|
||||||
|
added = add_torrent_raw(target_profile, data, start_on_target, target_path, target_label)
|
||||||
|
if not added.get("ok"):
|
||||||
|
raise RuntimeError(added.get("error") or "target add failed")
|
||||||
|
except Exception:
|
||||||
|
if move_data and moved_to:
|
||||||
|
try:
|
||||||
|
source_client.call("d.directory.set", h, target_path)
|
||||||
|
if was_state or was_active:
|
||||||
|
source_client.call("d.start", h)
|
||||||
|
item["rollback"] = "source torrent kept and pointed at moved data"
|
||||||
|
except Exception as rollback_exc:
|
||||||
|
item["rollback_error"] = str(rollback_exc)
|
||||||
|
raise
|
||||||
|
if post_action in {"stop", "pause", "check", "recheck"}:
|
||||||
|
try:
|
||||||
|
if post_action == "stop":
|
||||||
|
target_client.call("d.stop", h)
|
||||||
|
elif post_action == "pause":
|
||||||
|
pause_hash(target_client, h)
|
||||||
|
else:
|
||||||
|
target_client.call("d.check_hash", h)
|
||||||
|
item["post_action_applied"] = post_action
|
||||||
|
except Exception as post_exc:
|
||||||
|
item["post_action_error"] = str(post_exc)
|
||||||
|
source_client.call("d.erase", h)
|
||||||
|
item["target_started"] = start_on_target
|
||||||
|
item["label"] = target_label
|
||||||
|
item["previous_label"] = label
|
||||||
|
item["post_action"] = post_action
|
||||||
|
results.append(item)
|
||||||
|
mark_done(h, results)
|
||||||
|
return {"ok": True, "count": len(torrent_hashes), "move_data": move_data, "target_profile_id": int(target_profile.get("id") or 0), "target_path": target_path, "label": label_value, "post_action": post_action, "results": results}
|
||||||
|
|
||||||
def action(profile: dict, torrent_hashes: list[str], name: str, payload: dict | None = None, checkpoint=None, resume_state: dict | None = None) -> dict:
|
def action(profile: dict, torrent_hashes: list[str], name: str, payload: dict | None = None, checkpoint=None, resume_state: dict | None = None) -> dict:
|
||||||
payload = payload or {}
|
payload = payload or {}
|
||||||
resume_state = resume_state or {}
|
resume_state = resume_state or {}
|
||||||
@@ -890,7 +1051,7 @@ def action(profile: dict, torrent_hashes: list[str], name: str, payload: dict |
|
|||||||
mark_done(h, item, results)
|
mark_done(h, item, results)
|
||||||
return {"ok": True, "count": len(torrent_hashes), "remove_data": False, "results": results}
|
return {"ok": True, "count": len(torrent_hashes), "remove_data": False, "results": results}
|
||||||
if name in {"resume", "unpause"}:
|
if name in {"resume", "unpause"}:
|
||||||
# Note: Resume/Unpause uses only d.resume for Paused state.
|
# Note: Resume/Unpause keeps native rTorrent resume semantics; Start is the recovery action for stuck open/inactive torrents.
|
||||||
results = previous_results
|
results = previous_results
|
||||||
for h in pending_hashes():
|
for h in pending_hashes():
|
||||||
item = resume_paused_hash(c, h)
|
item = resume_paused_hash(c, h)
|
||||||
@@ -898,7 +1059,7 @@ def action(profile: dict, torrent_hashes: list[str], name: str, payload: dict |
|
|||||||
mark_done(h, item, results)
|
mark_done(h, item, results)
|
||||||
return {"ok": True, "count": len(torrent_hashes), "remove_data": False, "results": results}
|
return {"ok": True, "count": len(torrent_hashes), "remove_data": False, "results": results}
|
||||||
if name == "start":
|
if name == "start":
|
||||||
# Note: Start separates Stopped from Paused; paused items go through d.resume, stopped items through d.start.
|
# Note: Start recovers stuck Paused/open-inactive rows with Stop -> Start while keeping normal stopped rows on d.start.
|
||||||
results = previous_results
|
results = previous_results
|
||||||
for h in pending_hashes():
|
for h in pending_hashes():
|
||||||
item = start_or_resume_hash(c, h)
|
item = start_or_resume_hash(c, h)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from ..config import BASE_DIR, SMART_QUEUE_LABEL, SMART_QUEUE_STALLED_LABEL
|
from ..config import BASE_DIR, SMART_QUEUE_LABEL, SMART_QUEUE_STALLED_LABEL
|
||||||
from ..db import connect, default_user_id, utcnow
|
from ..db import connect, default_user_id, utcnow
|
||||||
from . import rtorrent
|
from . import rtorrent
|
||||||
@@ -391,9 +389,8 @@ def _smart_queue_label_cleanup_value(live_label: str | None, previous_label: str
|
|||||||
|
|
||||||
|
|
||||||
def _has_stalled_label(value: str | None) -> bool:
|
def _has_stalled_label(value: str | None) -> bool:
|
||||||
# Note: Stalled is treated case-insensitively so manually edited labels still block Smart Queue.
|
# Note: Stalled is an exact technical label; lower-case variants are normal user labels.
|
||||||
target = SMART_QUEUE_STALLED_LABEL.casefold()
|
return SMART_QUEUE_STALLED_LABEL in _label_names(value)
|
||||||
return any(label.casefold() == target for label in _label_names(value))
|
|
||||||
|
|
||||||
|
|
||||||
def _without_queue_technical_labels(value: str | None) -> str:
|
def _without_queue_technical_labels(value: str | None) -> str:
|
||||||
@@ -403,7 +400,7 @@ def _without_queue_technical_labels(value: str | None) -> str:
|
|||||||
def _ensure_stalled_label(client: Any, torrent_hash: str, current_label: str = '') -> bool:
|
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]
|
labels = [label for label in _label_names(current_label) if label != SMART_QUEUE_LABEL]
|
||||||
changed = False
|
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)
|
labels.append(SMART_QUEUE_STALLED_LABEL)
|
||||||
changed = True
|
changed = True
|
||||||
if SMART_QUEUE_LABEL in _label_names(current_label):
|
if SMART_QUEUE_LABEL in _label_names(current_label):
|
||||||
@@ -421,13 +418,13 @@ def _ensure_stalled_label(client: Any, torrent_hash: str, current_label: str = '
|
|||||||
def _without_stalled_label(value: str | None) -> str:
|
def _without_stalled_label(value: str | None) -> str:
|
||||||
"""Return labels without Smart Queue's Stalled marker."""
|
"""Return labels without Smart Queue's Stalled marker."""
|
||||||
# Note: This keeps user labels intact while clearing only the automatic stalled state.
|
# 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.casefold() != SMART_QUEUE_STALLED_LABEL.casefold()])
|
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:
|
def _clear_stalled_label(client: Any, torrent_hash: str, current_label: str = '') -> bool:
|
||||||
"""Remove the Stalled marker from a torrent that is active again."""
|
"""Remove the Stalled marker from a torrent that is active again."""
|
||||||
labels = _label_names(current_label)
|
labels = _label_names(current_label)
|
||||||
if not any(label.casefold() == SMART_QUEUE_STALLED_LABEL.casefold() for label in labels):
|
if SMART_QUEUE_STALLED_LABEL not in labels:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
# Note: Active downloads must not keep the Stalled marker after they resume transferring.
|
# Note: Active downloads must not keep the Stalled marker after they resume transferring.
|
||||||
@@ -835,7 +832,7 @@ def _is_running_download_slot(t: dict[str, Any]) -> bool:
|
|||||||
"""Return True for incomplete torrents that already occupy a Smart Queue slot."""
|
"""Return True for incomplete torrents that already occupy a Smart Queue slot."""
|
||||||
# Note: Do not exclude Smart Queue/Stalled labels here. Manual Start can leave old labels,
|
# Note: Do not exclude Smart Queue/Stalled labels here. Manual Start can leave old labels,
|
||||||
# and those torrents still must count toward the global Smart Queue limit.
|
# and those torrents still must count toward the global Smart Queue limit.
|
||||||
return _is_started_download_slot(t)
|
return _is_started_download_slot(t) and not _is_user_paused(t)
|
||||||
|
|
||||||
|
|
||||||
def _has_recent_transfer_activity(t: dict[str, Any], stalled_seconds: int) -> bool:
|
def _has_recent_transfer_activity(t: dict[str, Any], stalled_seconds: int) -> bool:
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ..db import connect, utcnow
|
from ..db import connect, utcnow
|
||||||
from .rtorrent import human_rate
|
from .rtorrent import human_rate
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,104 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
import threading
|
||||||
from time import sleep
|
from time import monotonic
|
||||||
from . import preferences, rtorrent
|
from ..db import connect
|
||||||
|
from . import operation_logs, rtorrent
|
||||||
|
|
||||||
_started = False
|
_started = False
|
||||||
|
_start_lock = threading.Lock()
|
||||||
|
_applied_profiles: set[int] = set()
|
||||||
|
_last_status: dict[int, str] = {}
|
||||||
|
|
||||||
|
|
||||||
def schedule_startup_config_apply(socketio, delay_seconds: int = 60) -> None:
|
def _profiles() -> list[dict]:
|
||||||
"""Apply saved rTorrent UI overrides after pyTorrent has been running for a moment."""
|
"""Read all configured profiles because startup work has no browser user session."""
|
||||||
global _started
|
with connect() as conn:
|
||||||
if _started:
|
return [dict(row) for row in conn.execute("SELECT * FROM rtorrent_profiles ORDER BY id").fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def _log_status(profile: dict, status: str, message: str, *, error: str = "", result: dict | None = None) -> None:
|
||||||
|
"""Write meaningful startup config state changes as system operations."""
|
||||||
|
profile_id = int(profile.get("id") or 0)
|
||||||
|
if status in {"waiting", "skipped"} and _last_status.get(profile_id) == status:
|
||||||
return
|
return
|
||||||
_started = True
|
_last_status[profile_id] = status
|
||||||
|
operation_logs.record(
|
||||||
|
profile_id,
|
||||||
|
"rtorrent_config_startup",
|
||||||
|
message,
|
||||||
|
severity="warning" if error else "info",
|
||||||
|
source="system",
|
||||||
|
action="rtorrent_config",
|
||||||
|
details={"status": status, "error": error, "result": result or {}},
|
||||||
|
user_id=int(profile.get("user_id") or 0) or None,
|
||||||
|
)
|
||||||
|
|
||||||
def runner():
|
|
||||||
sleep(max(0, int(delay_seconds)))
|
def _rtorrent_ready(profile: dict) -> tuple[bool, str]:
|
||||||
try:
|
"""Check rTorrent before applying saved runtime overrides."""
|
||||||
for profile in preferences.list_profiles():
|
try:
|
||||||
result = rtorrent.apply_startup_overrides(profile)
|
rtorrent.client_for(profile).call("system.client_version")
|
||||||
if not result.get("skipped"):
|
return True, ""
|
||||||
socketio.emit("rtorrent_config_applied", {"profile_id": profile["id"], "result": result})
|
except Exception as exc:
|
||||||
except Exception as exc:
|
return False, str(exc)
|
||||||
socketio.emit("rtorrent_config_applied", {"ok": False, "error": str(exc)})
|
|
||||||
|
|
||||||
|
def _apply_profile(socketio, profile: dict) -> None:
|
||||||
|
"""Apply saved config only after the target rTorrent is reachable."""
|
||||||
|
profile_id = int(profile.get("id") or 0)
|
||||||
|
if not profile_id or profile_id in _applied_profiles:
|
||||||
|
return
|
||||||
|
ok, error = _rtorrent_ready(profile)
|
||||||
|
if not ok:
|
||||||
|
_log_status(profile, "waiting", f"rTorrent config apply is waiting for connection: {error}", error=error)
|
||||||
|
return
|
||||||
|
result = rtorrent.apply_startup_overrides(profile)
|
||||||
|
if result.get("skipped"):
|
||||||
|
_applied_profiles.add(profile_id)
|
||||||
|
_log_status(profile, "skipped", "No saved rTorrent startup config overrides to apply", result=result)
|
||||||
|
return
|
||||||
|
_applied_profiles.add(profile_id)
|
||||||
|
_log_status(profile, "applied", "Saved rTorrent startup config overrides applied", result=result)
|
||||||
|
socketio.emit("rtorrent_config_applied", {"profile_id": profile_id, "result": result}, to=f"profile:{int(profile_id)}")
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_startup_config_apply(socketio, delay_seconds: int = 60, retry_seconds: int = 30, max_wait_seconds: int = 3600) -> None:
|
||||||
|
"""Apply saved rTorrent UI overrides after the configured startup delay without requiring a browser."""
|
||||||
|
global _started
|
||||||
|
with _start_lock:
|
||||||
|
if _started:
|
||||||
|
return
|
||||||
|
_started = True
|
||||||
|
|
||||||
|
def runner() -> None:
|
||||||
|
socketio.sleep(max(0, int(delay_seconds)))
|
||||||
|
started_at = monotonic()
|
||||||
|
while True:
|
||||||
|
failed_profile_id = 0
|
||||||
|
try:
|
||||||
|
profiles = _profiles()
|
||||||
|
for profile in profiles:
|
||||||
|
failed_profile_id = int(profile.get("id") or 0)
|
||||||
|
# Note: Startup config applies per profile after connectivity is detected; it does not depend on the active UI profile.
|
||||||
|
_apply_profile(socketio, profile)
|
||||||
|
pending = [int(profile.get("id") or 0) for profile in profiles if int(profile.get("id") or 0) not in _applied_profiles]
|
||||||
|
if not pending or monotonic() - started_at >= max(0, int(max_wait_seconds)):
|
||||||
|
for profile in profiles:
|
||||||
|
profile_id = int(profile.get("id") or 0)
|
||||||
|
if profile_id in pending:
|
||||||
|
_log_status(profile, "timeout", "rTorrent config startup apply stopped waiting for connection", error="startup wait timeout")
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
operation_logs.record(
|
||||||
|
failed_profile_id or None,
|
||||||
|
"rtorrent_config_startup",
|
||||||
|
f"rTorrent startup config scheduler failed: {exc}",
|
||||||
|
severity="warning",
|
||||||
|
source="system",
|
||||||
|
action="rtorrent_config",
|
||||||
|
details={"error": str(exc)},
|
||||||
|
)
|
||||||
|
socketio.emit("rtorrent_config_applied", {"ok": False, "profile_id": int(failed_profile_id or 0), "error": str(exc)}, to=f"profile:{int(failed_profile_id)}" if failed_profile_id else None)
|
||||||
|
socketio.sleep(max(5, int(retry_seconds)))
|
||||||
|
|
||||||
socketio.start_background_task(runner)
|
socketio.start_background_task(runner)
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from threading import RLock
|
from threading import RLock
|
||||||
from time import time
|
from time import time
|
||||||
from . import rtorrent, operation_logs
|
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"}
|
_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"}
|
_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"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
from pathlib import PurePosixPath
|
from pathlib import PurePosixPath
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ..db import connect, utcnow
|
from ..db import connect, utcnow
|
||||||
from . import rtorrent
|
from . import rtorrent
|
||||||
from .torrent_cache import torrent_cache
|
from .torrent_cache import torrent_cache
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from threading import RLock
|
from threading import RLock
|
||||||
from time import time
|
from time import time
|
||||||
@@ -19,7 +18,7 @@ _ERROR_PATTERNS = (
|
|||||||
"unreachable",
|
"unreachable",
|
||||||
"denied",
|
"denied",
|
||||||
)
|
)
|
||||||
_SUMMARY_TYPES = ("all", "downloading", "seeding", "paused", "checking", "error", "post_check", "stopped")
|
_SUMMARY_TYPES = ("all", "downloading", "queued", "seeding", "paused", "checking", "error", "post_check", "stopped")
|
||||||
_summary_cache: dict[int, dict] = {}
|
_summary_cache: dict[int, dict] = {}
|
||||||
_summary_lock = RLock()
|
_summary_lock = RLock()
|
||||||
|
|
||||||
@@ -46,7 +45,9 @@ def _matches(row: dict, summary_type: str) -> bool:
|
|||||||
if summary_type == "all":
|
if summary_type == "all":
|
||||||
return True
|
return True
|
||||||
if summary_type == "downloading":
|
if summary_type == "downloading":
|
||||||
return not checking and not bool(row.get("complete")) and bool(row.get("state")) and not bool(row.get("paused"))
|
return not checking and not bool(row.get("complete")) and bool(row.get("state")) and not bool(row.get("paused")) and str(row.get("status") or "") != "Queued"
|
||||||
|
if summary_type == "queued":
|
||||||
|
return not checking and (bool(row.get("queued")) or str(row.get("status") or "") == "Queued")
|
||||||
if summary_type == "seeding":
|
if summary_type == "seeding":
|
||||||
return not checking and bool(row.get("complete")) and bool(row.get("state")) and not bool(row.get("paused"))
|
return not checking and bool(row.get("complete")) and bool(row.get("state")) and not bool(row.get("paused"))
|
||||||
if summary_type == "paused":
|
if summary_type == "paused":
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import re
|
import re
|
||||||
@@ -11,7 +10,6 @@ import urllib.parse
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ..config import BASE_DIR
|
from ..config import BASE_DIR
|
||||||
from ..db import connect, utcnow
|
from ..db import connect, utcnow
|
||||||
|
|
||||||
@@ -438,3 +436,50 @@ def favicon_path(domain: str, enabled: bool = True, force: bool = False) -> tupl
|
|||||||
(clean, utcnow(), now, "; ".join(errors[-8:]) or "favicon not found"),
|
(clean, utcnow(), now, "; ".join(errors[-8:]) or "favicon not found"),
|
||||||
)
|
)
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def cached_domains_for_profile(profile_id: int, limit: int = 200) -> list[str]:
|
||||||
|
"""Return tracker domains already known for a profile from the summary cache."""
|
||||||
|
# Note: The background favicon worker reads cached summary rows first, so it does not need the browser sidebar to discover domains.
|
||||||
|
domains: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
with connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT trackers_json FROM tracker_summary_cache WHERE profile_id=? ORDER BY updated_epoch DESC LIMIT ?",
|
||||||
|
(int(profile_id), max(1, int(limit or 200))),
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
items = json.loads(row.get("trackers_json") or "[]")
|
||||||
|
except Exception:
|
||||||
|
items = []
|
||||||
|
for item in items if isinstance(items, list) else []:
|
||||||
|
domain = tracker_domain(str((item or {}).get("url") or (item or {}).get("domain") or "")) or str((item or {}).get("domain") or "")
|
||||||
|
if domain and domain not in seen:
|
||||||
|
seen.add(domain)
|
||||||
|
domains.append(domain)
|
||||||
|
return domains[:max(1, int(limit or 200))]
|
||||||
|
|
||||||
|
|
||||||
|
def warm_favicon_cache(domains: list[str], enabled: bool = True, limit: int = 20, force: bool = False) -> dict:
|
||||||
|
"""Warm missing or stale tracker favicons for a bounded list of domains."""
|
||||||
|
# Note: Favicon lookup can perform network requests, so the caller must keep the batch size small.
|
||||||
|
clean_domains = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for domain in domains or []:
|
||||||
|
clean = tracker_domain(domain)
|
||||||
|
if clean and clean not in seen:
|
||||||
|
seen.add(clean)
|
||||||
|
clean_domains.append(clean)
|
||||||
|
checked = 0
|
||||||
|
cached = 0
|
||||||
|
errors: list[dict] = []
|
||||||
|
for domain in clean_domains[:max(0, int(limit or 0))]:
|
||||||
|
checked += 1
|
||||||
|
try:
|
||||||
|
path, _mime = favicon_path(domain, enabled=enabled, force=force)
|
||||||
|
if path:
|
||||||
|
cached += 1
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append({"domain": domain, "error": str(exc)})
|
||||||
|
return {"checked": checked, "cached": cached, "errors": errors[:10]}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ..config import TRAFFIC_HISTORY_RETENTION_DAYS
|
from ..config import TRAFFIC_HISTORY_RETENTION_DAYS
|
||||||
from ..db import connect, utcnow
|
from ..db import connect, utcnow
|
||||||
from . import retention
|
from . import retention
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
@@ -9,7 +8,7 @@ from .preferences import active_profile, get_profile
|
|||||||
from ..db import default_user_id
|
from ..db import default_user_id
|
||||||
from .torrent_cache import torrent_cache
|
from .torrent_cache import torrent_cache
|
||||||
from .torrent_summary import cached_summary
|
from .torrent_summary import cached_summary
|
||||||
from . import rtorrent, smart_queue, traffic_history, automation_rules, torrent_stats, auth, speed_peaks, poller_control, download_planner
|
from . import rtorrent, smart_queue, traffic_history, automation_rules, torrent_stats, auth, speed_peaks, poller_control, download_planner, profile_speed_limits
|
||||||
|
|
||||||
|
|
||||||
def _profile_room(profile_id: int) -> str:
|
def _profile_room(profile_id: int) -> str:
|
||||||
@@ -17,33 +16,43 @@ def _profile_room(profile_id: int) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _poller_profiles() -> list[dict]:
|
def _poller_profiles() -> list[dict]:
|
||||||
# Background polling has no browser session, so auth-enabled mode refreshes all profiles and emits only to per-profile rooms.
|
|
||||||
if not auth.enabled():
|
|
||||||
profile = active_profile()
|
|
||||||
return [profile] if profile else []
|
|
||||||
from ..db import connect
|
from ..db import connect
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
|
# Note: Background polling must be profile-scoped and browser-independent, even when auth is disabled.
|
||||||
return conn.execute("SELECT * FROM rtorrent_profiles ORDER BY id").fetchall()
|
return conn.execute("SELECT * FROM rtorrent_profiles ORDER BY id").fetchall()
|
||||||
|
|
||||||
|
|
||||||
def emit_profile_event(socketio, event: str, payload: dict, profile_id: int) -> None:
|
def emit_profile_event(socketio, event: str, payload: dict, profile_id: int) -> None:
|
||||||
target = _profile_room(profile_id) if auth.enabled() else None
|
scoped_payload = {**(payload or {}), "profile_id": int(profile_id)}
|
||||||
socketio.emit(event, payload, to=target) if target else socketio.emit(event, payload)
|
socketio.emit(event, scoped_payload, to=_profile_room(profile_id))
|
||||||
|
|
||||||
|
|
||||||
def _emit_profile(socketio, event: str, payload: dict, profile_id: int) -> None:
|
def _emit_profile(socketio, event: str, payload: dict, profile_id: int) -> None:
|
||||||
emit_profile_event(socketio, event, payload, profile_id)
|
emit_profile_event(socketio, event, payload, profile_id)
|
||||||
|
|
||||||
|
|
||||||
|
_speed_limits_applied: dict[int, tuple[int, int]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_configured_speed_limits(profile: dict, *, force: bool = False) -> None:
|
||||||
|
profile_id = int(profile.get("id") or 0)
|
||||||
|
limits = profile_speed_limits.get_limits(profile_id)
|
||||||
|
if not limits.get("configured"):
|
||||||
|
return
|
||||||
|
key = (int(limits.get("down") or 0), int(limits.get("up") or 0))
|
||||||
|
if not force and _speed_limits_applied.get(profile_id) == key:
|
||||||
|
return
|
||||||
|
# Note: Persisted per-profile limits are applied by the backend poller, not only after browser profile selection.
|
||||||
|
rtorrent.set_limits(profile, limits.get("down"), limits.get("up"))
|
||||||
|
_speed_limits_applied[profile_id] = key
|
||||||
|
|
||||||
|
|
||||||
def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None:
|
def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None:
|
||||||
state = poller_control.state_for(profile_id)
|
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())
|
profile_user_id = int(profile.get("user_id") or default_user_id())
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
torrent_stats.queue_refresh(socketio, profile, force=False, room=_profile_room(profile_id) if auth.enabled() else None)
|
torrent_stats.queue_refresh(socketio, profile, force=False, room=_profile_room(profile_id))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_emit_profile(socketio, "torrent_stats_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
|
_emit_profile(socketio, "torrent_stats_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
|
||||||
try:
|
try:
|
||||||
@@ -59,7 +68,7 @@ def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None:
|
|||||||
_emit_profile(socketio, "smart_queue_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
|
_emit_profile(socketio, "smart_queue_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
|
||||||
try:
|
try:
|
||||||
auto_result = automation_rules.check(profile, user_id=profile_user_id, force=False)
|
auto_result = automation_rules.check(profile, user_id=profile_user_id, 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)
|
_emit_profile(socketio, "automation_update", auto_result, profile_id)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_emit_profile(socketio, "automation_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
|
_emit_profile(socketio, "automation_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
|
||||||
@@ -84,7 +93,6 @@ def _is_active_rows(rows: list[dict]) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _speed_status_from_rows(profile_id: int, rows: list[dict]) -> dict:
|
def _speed_status_from_rows(profile_id: int, rows: list[dict]) -> dict:
|
||||||
# Note: Fast-poller speed status keeps browser-title speed and peaks independent from slower system_stats.
|
|
||||||
down_rate = sum(int(row.get("down_rate") or 0) for row in rows or [])
|
down_rate = sum(int(row.get("down_rate") or 0) for row in rows or [])
|
||||||
up_rate = sum(int(row.get("up_rate") or 0) for row in rows or [])
|
up_rate = sum(int(row.get("up_rate") or 0) for row in rows or [])
|
||||||
return {
|
return {
|
||||||
@@ -144,6 +152,8 @@ def register_socketio_handlers(socketio):
|
|||||||
heartbeat = {"ok": True, "profile_id": pid, "tick": state.tick_count + 1, "error": ""}
|
heartbeat = {"ok": True, "profile_id": pid, "tick": state.tick_count + 1, "error": ""}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Note: This keeps per-profile runtime limits active after app start, without waiting for UI contact.
|
||||||
|
_apply_configured_speed_limits(profile)
|
||||||
rows = torrent_cache.snapshot(pid)
|
rows = torrent_cache.snapshot(pid)
|
||||||
speed_status = _speed_status_from_rows(pid, rows)
|
speed_status = _speed_status_from_rows(pid, rows)
|
||||||
|
|
||||||
@@ -174,7 +184,6 @@ def register_socketio_handlers(socketio):
|
|||||||
else:
|
else:
|
||||||
skipped_emissions += 1
|
skipped_emissions += 1
|
||||||
if live.get("requires_full_refresh"):
|
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
|
state.last_list_at = 0.0
|
||||||
run_list = True
|
run_list = True
|
||||||
else:
|
else:
|
||||||
@@ -208,7 +217,6 @@ def register_socketio_handlers(socketio):
|
|||||||
rtorrent_call_count += 1
|
rtorrent_call_count += 1
|
||||||
if bool(profile.get("is_remote")):
|
if bool(profile.get("is_remote")):
|
||||||
try:
|
try:
|
||||||
# Note: Remote profiles must report CPU/RAM from the rTorrent host, not hide the footer stats.
|
|
||||||
usage = rtorrent.remote_system_usage(profile)
|
usage = rtorrent.remote_system_usage(profile)
|
||||||
status.update(usage)
|
status.update(usage)
|
||||||
status["usage_available"] = True
|
status["usage_available"] = True
|
||||||
@@ -262,7 +270,6 @@ def register_socketio_handlers(socketio):
|
|||||||
global _started
|
global _started
|
||||||
with _start_lock:
|
with _start_lock:
|
||||||
if not _started:
|
if not _started:
|
||||||
# The poller starts with the app, so Smart Queue, planner and automations work without an open UI.
|
|
||||||
socketio.start_background_task(poller)
|
socketio.start_background_task(poller)
|
||||||
_started = True
|
_started = True
|
||||||
|
|
||||||
@@ -281,10 +288,14 @@ def register_socketio_handlers(socketio):
|
|||||||
if not profile:
|
if not profile:
|
||||||
emit("profile_required", {"ok": True, "profiles": []})
|
emit("profile_required", {"ok": True, "profiles": []})
|
||||||
return
|
return
|
||||||
|
try:
|
||||||
|
_apply_configured_speed_limits(profile, force=True)
|
||||||
|
except Exception as exc:
|
||||||
|
emit("rtorrent_error", {"profile_id": profile["id"], "error": str(exc)})
|
||||||
rows = torrent_cache.snapshot(profile["id"])
|
rows = torrent_cache.snapshot(profile["id"])
|
||||||
emit("torrent_snapshot", {"profile_id": profile["id"], "torrents": rows, "summary": cached_summary(profile["id"], rows), "speed_status": _speed_status_from_rows(profile["id"], rows)})
|
emit("torrent_snapshot", {"profile_id": profile["id"], "torrents": rows, "summary": cached_summary(profile["id"], rows), "speed_status": _speed_status_from_rows(profile["id"], rows)})
|
||||||
emit("poller_settings", {"settings": poller_control.get_settings(int(profile["id"])), "runtime": poller_control.snapshot(int(profile["id"]))})
|
emit("poller_settings", {"profile_id": int(profile["id"]), "settings": poller_control.get_settings(int(profile["id"])), "runtime": poller_control.snapshot(int(profile["id"]))})
|
||||||
emit("download_plan_update", {"settings": download_planner.get_settings(int(profile["id"]))})
|
emit("download_plan_update", {"profile_id": int(profile["id"]), "settings": download_planner.get_settings(int(profile["id"]))})
|
||||||
|
|
||||||
@socketio.on("select_profile")
|
@socketio.on("select_profile")
|
||||||
def handle_select_profile(data):
|
def handle_select_profile(data):
|
||||||
@@ -303,8 +314,12 @@ def register_socketio_handlers(socketio):
|
|||||||
emit("rtorrent_error", {"error": "Profile access denied or profile does not exist"})
|
emit("rtorrent_error", {"error": "Profile access denied or profile does not exist"})
|
||||||
return
|
return
|
||||||
join_room(_profile_room(profile_id))
|
join_room(_profile_room(profile_id))
|
||||||
|
try:
|
||||||
|
_apply_configured_speed_limits(profile, force=True)
|
||||||
|
except Exception as exc:
|
||||||
|
emit("rtorrent_error", {"profile_id": profile_id, "error": str(exc)})
|
||||||
diff = torrent_cache.refresh(profile)
|
diff = torrent_cache.refresh(profile)
|
||||||
rows = torrent_cache.snapshot(profile_id)
|
rows = torrent_cache.snapshot(profile_id)
|
||||||
emit("torrent_snapshot", {"profile_id": profile_id, "torrents": rows, "summary": cached_summary(profile_id, rows, force=True), "speed_status": _speed_status_from_rows(profile_id, rows), "error": diff.get("error", "")})
|
emit("torrent_snapshot", {"profile_id": profile_id, "torrents": rows, "summary": cached_summary(profile_id, rows, force=True), "speed_status": _speed_status_from_rows(profile_id, rows), "error": diff.get("error", "")})
|
||||||
emit("poller_settings", {"settings": poller_control.get_settings(profile_id), "runtime": poller_control.snapshot(profile_id)})
|
emit("poller_settings", {"profile_id": profile_id, "settings": poller_control.get_settings(profile_id), "runtime": poller_control.snapshot(profile_id)})
|
||||||
emit("download_plan_update", {"settings": download_planner.get_settings(profile_id)})
|
emit("download_plan_update", {"profile_id": profile_id, "settings": download_planner.get_settings(profile_id)})
|
||||||
|
|||||||
+148
-44
@@ -1,5 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -26,6 +25,11 @@ _sem_lock = threading.Lock()
|
|||||||
_runner_lock = threading.Lock()
|
_runner_lock = threading.Lock()
|
||||||
_watchdog_started = False
|
_watchdog_started = False
|
||||||
_watchdog_lock = threading.Lock()
|
_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):
|
def set_socketio(socketio):
|
||||||
@@ -37,8 +41,7 @@ def _emit(name: str, payload: dict):
|
|||||||
if not _socketio:
|
if not _socketio:
|
||||||
return
|
return
|
||||||
profile_id = payload.get("profile_id")
|
profile_id = payload.get("profile_id")
|
||||||
if auth.enabled() and profile_id:
|
if profile_id:
|
||||||
# Note: Job/socket events are sent only to clients joined to the affected profile room.
|
|
||||||
_socketio.emit(name, payload, to=f"profile:{int(profile_id)}")
|
_socketio.emit(name, payload, to=f"profile:{int(profile_id)}")
|
||||||
else:
|
else:
|
||||||
_socketio.emit(name, payload)
|
_socketio.emit(name, payload)
|
||||||
@@ -97,8 +100,7 @@ def _job_payload(row) -> dict:
|
|||||||
def _is_ordered_job(row) -> bool:
|
def _is_ordered_job(row) -> bool:
|
||||||
payload = _job_payload(row)
|
payload = _job_payload(row)
|
||||||
action = str((row or {}).get("action") or "")
|
action = str((row or {}).get("action") or "")
|
||||||
# Note: Only long/destructive tasks are ordered; lightweight start/stop/label jobs may run beside other work.
|
return action in {"move", "remove", "profile_transfer", "add_magnet", "add_torrent_raw"} or bool(payload.get("requires_order"))
|
||||||
return action in {"move", "remove", "add_magnet", "add_torrent_raw"} or bool(payload.get("requires_order"))
|
|
||||||
|
|
||||||
|
|
||||||
def _is_priority_job(row) -> bool:
|
def _is_priority_job(row) -> bool:
|
||||||
@@ -110,24 +112,55 @@ def _is_light_job(row) -> bool:
|
|||||||
return _is_light_action(str((row or {}).get("action") or ""))
|
return _is_light_action(str((row or {}).get("action") or ""))
|
||||||
|
|
||||||
|
|
||||||
def _has_prior_ordered_jobs(profile_id: int, rowid: int) -> bool:
|
def _ordered_profile_ids(row) -> set[int]:
|
||||||
|
"""Return every profile touched by an ordered job."""
|
||||||
|
# Note: Profile-transfer jobs touch both source and target profiles, so they must be ordered across both sides.
|
||||||
|
ids: set[int] = set()
|
||||||
|
try:
|
||||||
|
profile_id = int((row or {}).get("profile_id") or 0)
|
||||||
|
if profile_id:
|
||||||
|
ids.add(profile_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
payload = _job_payload(row)
|
||||||
|
target_id = int(payload.get("target_profile_id") or 0)
|
||||||
|
if str((row or {}).get("action") or "") == "profile_transfer" and target_id:
|
||||||
|
ids.add(target_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def _ordered_locks_for(row) -> list[threading.Lock]:
|
||||||
|
"""Acquire locks in stable order to avoid deadlocks between cross-profile jobs."""
|
||||||
|
return [_get_exclusive_lock(profile_id) for profile_id in sorted(_ordered_profile_ids(row))]
|
||||||
|
|
||||||
|
|
||||||
|
def _has_prior_ordered_jobs(profile_ids: set[int], rowid: int) -> bool:
|
||||||
|
if not profile_ids:
|
||||||
|
return False
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT rowid AS _rowid, action, payload_json
|
SELECT rowid AS _rowid, profile_id, action, payload_json
|
||||||
FROM jobs
|
FROM jobs
|
||||||
WHERE profile_id=?
|
WHERE rowid<?
|
||||||
AND rowid<?
|
|
||||||
AND status IN ('pending', 'running')
|
AND status IN ('pending', 'running')
|
||||||
ORDER BY rowid
|
ORDER BY rowid
|
||||||
""",
|
""",
|
||||||
(profile_id, rowid),
|
(rowid,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return any(_is_ordered_job(row) and not _is_priority_job(row) for row in rows)
|
for row in rows:
|
||||||
|
if not _is_ordered_job(row) or _is_priority_job(row):
|
||||||
|
continue
|
||||||
|
if profile_ids.intersection(_ordered_profile_ids(row)):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _wait_for_prior_ordered_jobs(job_id: str, profile_id: int, rowid: int) -> bool:
|
def _wait_for_prior_ordered_jobs(job_id: str, profile_ids: set[int], rowid: int) -> bool:
|
||||||
while _has_prior_ordered_jobs(profile_id, rowid):
|
while _has_prior_ordered_jobs(profile_ids, rowid):
|
||||||
fresh = _job_row(job_id)
|
fresh = _job_row(job_id)
|
||||||
if not fresh or fresh["status"] == "cancelled":
|
if not fresh or fresh["status"] == "cancelled":
|
||||||
return False
|
return False
|
||||||
@@ -190,7 +223,6 @@ def enqueue(action_name: str, profile_id: int, payload: dict, user_id: int | Non
|
|||||||
job_id = uuid.uuid4().hex
|
job_id = uuid.uuid4().hex
|
||||||
if force:
|
if force:
|
||||||
payload = dict(payload or {})
|
payload = dict(payload or {})
|
||||||
# Note: Forced pending jobs bypass ordered waits and run in a separate worker slot after explicit user confirmation.
|
|
||||||
payload['force_job'] = True
|
payload['force_job'] = True
|
||||||
payload['priority_job'] = True
|
payload['priority_job'] = True
|
||||||
now = utcnow()
|
now = utcnow()
|
||||||
@@ -200,7 +232,6 @@ 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(?,?,?,?,?,?,?,?,?,?,?)",
|
"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),
|
(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)
|
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"})
|
_emit("job_update", {"id": job_id, "action": action_name, "profile_id": profile_id, "status": "pending"})
|
||||||
_submit_job(job_id, action_name)
|
_submit_job(job_id, action_name)
|
||||||
@@ -212,7 +243,6 @@ def _job_event_meta(payload: dict) -> dict:
|
|||||||
source = str(ctx.get("source") or payload.get("source") or "user")
|
source = str(ctx.get("source") or payload.get("source") or "user")
|
||||||
meta = {"source": source}
|
meta = {"source": source}
|
||||||
if source == "automation":
|
if source == "automation":
|
||||||
# Note: Socket operation toasts use this flag so automation notifications respect user preferences.
|
|
||||||
meta["automation"] = True
|
meta["automation"] = True
|
||||||
meta["source_label"] = str(ctx.get("rule_name") or "automation")
|
meta["source_label"] = str(ctx.get("rule_name") or "automation")
|
||||||
if ctx.get("rule_id") is not None:
|
if ctx.get("rule_id") is not None:
|
||||||
@@ -220,10 +250,84 @@ def _job_event_meta(payload: dict) -> dict:
|
|||||||
return meta
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_job_deletes_data(action_name: str, payload: dict, result: dict | None = None) -> bool:
|
||||||
|
# Note: Disk usage refreshes only when a remove job actually requested data deletion.
|
||||||
|
if str(action_name or "") != "remove":
|
||||||
|
return False
|
||||||
|
if bool((payload or {}).get("remove_data")):
|
||||||
|
return True
|
||||||
|
ctx = (payload or {}).get("job_context") or {}
|
||||||
|
return bool(ctx.get("remove_data") or (result or {}).get("remove_data"))
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_disk_refresh_cache(profile_id: int) -> None:
|
||||||
|
try:
|
||||||
|
rtorrent.clear_profile_runtime_caches(int(profile_id))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_profile_disk_refresh(profile_id: int, reason: str, hash_count: int = 0, delay_seconds: int = 0) -> None:
|
||||||
|
_clear_disk_refresh_cache(profile_id)
|
||||||
|
_emit("disk_refresh_requested", {
|
||||||
|
"profile_id": int(profile_id),
|
||||||
|
"hash_count": int(hash_count or 0),
|
||||||
|
"reason": reason,
|
||||||
|
"delay_seconds": int(delay_seconds or 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _run_delayed_disk_refresh(profile_id: int, delay_seconds: int) -> None:
|
||||||
|
key = (int(profile_id), int(delay_seconds))
|
||||||
|
try:
|
||||||
|
_emit_profile_disk_refresh(profile_id, "remove_data_settled", delay_seconds=delay_seconds)
|
||||||
|
finally:
|
||||||
|
with _disk_refresh_lock:
|
||||||
|
current = _disk_refresh_timers.get(key)
|
||||||
|
if current is threading.current_thread():
|
||||||
|
_disk_refresh_timers.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_profile_disk_refresh(profile_id: int, hash_count: int = 0) -> None:
|
||||||
|
profile_id = int(profile_id)
|
||||||
|
now = time.monotonic()
|
||||||
|
emit_immediately = False
|
||||||
|
timers_to_start: list[threading.Timer] = []
|
||||||
|
with _disk_refresh_lock:
|
||||||
|
last_immediate = float(_disk_refresh_last_immediate.get(profile_id) or 0)
|
||||||
|
if now - last_immediate >= _disk_refresh_min_immediate_seconds:
|
||||||
|
_disk_refresh_last_immediate[profile_id] = now
|
||||||
|
emit_immediately = True
|
||||||
|
for delay_seconds in _disk_refresh_delays:
|
||||||
|
key = (profile_id, int(delay_seconds))
|
||||||
|
old_timer = _disk_refresh_timers.get(key)
|
||||||
|
if old_timer:
|
||||||
|
old_timer.cancel()
|
||||||
|
timer = threading.Timer(float(delay_seconds), _run_delayed_disk_refresh, args=(profile_id, int(delay_seconds)))
|
||||||
|
timer.daemon = True
|
||||||
|
_disk_refresh_timers[key] = timer
|
||||||
|
timers_to_start.append(timer)
|
||||||
|
if emit_immediately:
|
||||||
|
_emit_profile_disk_refresh(profile_id, "remove_data_done", hash_count=hash_count, delay_seconds=0)
|
||||||
|
for timer in timers_to_start:
|
||||||
|
timer.start()
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_disk_refresh_requested(profile_id: int, action_name: str, payload: dict, result: dict | None = None) -> None:
|
||||||
|
if not _remove_job_deletes_data(action_name, payload, result):
|
||||||
|
return
|
||||||
|
_schedule_profile_disk_refresh(int(profile_id), len((payload or {}).get("hashes") or []))
|
||||||
|
|
||||||
def _execute(profile: dict, action_name: str, payload: dict, user_id: int | None = None):
|
def _execute(profile: dict, action_name: str, payload: dict, user_id: int | None = None):
|
||||||
|
def checkpoint(next_state: dict, current: int, total: int):
|
||||||
|
# Note: Checkpoint is defined before every action branch so profile-transfer jobs can resume safely.
|
||||||
|
job_id = payload.get("__job_id")
|
||||||
|
if job_id:
|
||||||
|
_checkpoint_job(str(job_id), next_state, current, total)
|
||||||
|
|
||||||
if action_name == "smart_queue_check":
|
if action_name == "smart_queue_check":
|
||||||
from . import smart_queue
|
from . import smart_queue
|
||||||
# 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)
|
return smart_queue.check(profile, user_id=user_id or default_user_id(), force=True)
|
||||||
if action_name == "add_magnet":
|
if action_name == "add_magnet":
|
||||||
if bool(payload.get("start", True)):
|
if bool(payload.get("start", True)):
|
||||||
@@ -235,6 +339,12 @@ def _execute(profile: dict, action_name: str, payload: dict, user_id: int | None
|
|||||||
if bool(payload.get("start", True)):
|
if bool(payload.get("start", True)):
|
||||||
disk_guard.assert_can_start_download(profile)
|
disk_guard.assert_can_start_download(profile)
|
||||||
return rtorrent.add_torrent_raw(profile, raw, bool(payload.get("start", True)), str(payload.get("directory") or ""), str(payload.get("label") or ""), payload.get("file_priorities") or None)
|
return rtorrent.add_torrent_raw(profile, raw, bool(payload.get("start", True)), str(payload.get("directory") or ""), str(payload.get("label") or ""), payload.get("file_priorities") or None)
|
||||||
|
if action_name == "profile_transfer":
|
||||||
|
# Note: Target profile is resolved inside the worker with the original user's permissions, not trusted from the request payload.
|
||||||
|
target_profile = get_profile(int(payload.get("target_profile_id") or 0), user_id or default_user_id())
|
||||||
|
if not target_profile:
|
||||||
|
raise ValueError("Target profile does not exist or is not accessible")
|
||||||
|
return rtorrent.transfer_profile(profile, target_profile, payload.get("hashes") or [], payload, checkpoint=checkpoint, resume_state=payload.get("__resume_state") or {})
|
||||||
if action_name == "set_limits":
|
if action_name == "set_limits":
|
||||||
return rtorrent.set_limits(profile, payload.get("down"), payload.get("up"))
|
return rtorrent.set_limits(profile, payload.get("down"), payload.get("up"))
|
||||||
hashes = payload.get("hashes") or []
|
hashes = payload.get("hashes") or []
|
||||||
@@ -242,11 +352,6 @@ def _execute(profile: dict, action_name: str, payload: dict, user_id: int | None
|
|||||||
disk_guard.assert_can_start_download(profile)
|
disk_guard.assert_can_start_download(profile)
|
||||||
state = payload.get("__resume_state") or {}
|
state = payload.get("__resume_state") or {}
|
||||||
|
|
||||||
def checkpoint(next_state: dict, current: int, total: int):
|
|
||||||
job_id = payload.get("__job_id")
|
|
||||||
if job_id:
|
|
||||||
_checkpoint_job(str(job_id), next_state, current, total)
|
|
||||||
|
|
||||||
return rtorrent.action(profile, hashes, action_name, payload, checkpoint=checkpoint, resume_state=state)
|
return rtorrent.action(profile, hashes, action_name, payload, checkpoint=checkpoint, resume_state=state)
|
||||||
|
|
||||||
|
|
||||||
@@ -274,18 +379,17 @@ def _mark_running(job_id: str, attempts: int) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _emit_torrent_refresh(profile: dict, action_name: str) -> None:
|
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"}:
|
if action_name not in {"add_magnet", "add_torrent_raw", "remove", "move", "profile_transfer", "start", "stop", "pause", "resume", "unpause", "set_label", "set_ratio_group", "recheck"}:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
diff = torrent_cache.refresh(profile)
|
diff = torrent_cache.refresh(profile)
|
||||||
profile_id = int(profile["id"])
|
profile_id = int(profile["id"])
|
||||||
if diff.get("ok"):
|
if diff.get("ok"):
|
||||||
rows = torrent_cache.snapshot(profile_id)
|
rows = torrent_cache.snapshot(profile_id)
|
||||||
_emit("torrent_patch", {**diff, "summary": cached_summary(profile_id, rows, force=True)})
|
_emit("torrent_patch", {**diff, "profile_id": profile_id, "summary": cached_summary(profile_id, rows, force=True)})
|
||||||
else:
|
else:
|
||||||
_emit("rtorrent_error", diff)
|
_emit("rtorrent_error", {**diff, "profile_id": profile_id})
|
||||||
except Exception as exc:
|
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)})
|
_emit("rtorrent_error", {"profile_id": int(profile.get("id") or 0), "error": str(exc)})
|
||||||
|
|
||||||
|
|
||||||
@@ -294,7 +398,6 @@ def _schedule_delayed_torrent_refresh(profile: dict, action_name: str) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
def delayed_refresh():
|
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)
|
sleep_fn = getattr(_socketio, "sleep", time.sleep)
|
||||||
for delay in (0.75, 1.75):
|
for delay in (0.75, 1.75):
|
||||||
sleep_fn(delay)
|
sleep_fn(delay)
|
||||||
@@ -307,7 +410,7 @@ def _run(job_id: str):
|
|||||||
if not _claim_runner(job_id):
|
if not _claim_runner(job_id):
|
||||||
return
|
return
|
||||||
sem = None
|
sem = None
|
||||||
ordered_lock = None
|
ordered_locks: list[threading.Lock] = []
|
||||||
job = {}
|
job = {}
|
||||||
payload = {}
|
payload = {}
|
||||||
try:
|
try:
|
||||||
@@ -317,16 +420,17 @@ def _run(job_id: str):
|
|||||||
profile = get_profile(int(job["profile_id"]), int(job["user_id"]))
|
profile = get_profile(int(job["profile_id"]), int(job["user_id"]))
|
||||||
if not profile:
|
if not profile:
|
||||||
_set_job(job_id, "failed", "rTorrent profile does not exist", finished=True)
|
_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")
|
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"})
|
_emit("job_update", {"id": job_id, "profile_id": job.get("profile_id"), "status": "failed", "error": "profile not found"})
|
||||||
return
|
return
|
||||||
profile_id = int(profile["id"])
|
profile_id = int(profile["id"])
|
||||||
if _is_ordered_job(job) and not _is_priority_job(job):
|
if _is_ordered_job(job) and not _is_priority_job(job):
|
||||||
if not _wait_for_prior_ordered_jobs(job_id, profile_id, int(job["_rowid"])):
|
involved_profile_ids = _ordered_profile_ids(job)
|
||||||
|
if not _wait_for_prior_ordered_jobs(job_id, involved_profile_ids, int(job["_rowid"])):
|
||||||
return
|
return
|
||||||
ordered_lock = _get_exclusive_lock(profile_id)
|
ordered_locks = _ordered_locks_for(job)
|
||||||
ordered_lock.acquire()
|
for lock in ordered_locks:
|
||||||
|
lock.acquire()
|
||||||
sem = _get_sem(profile, light=_is_light_job(job))
|
sem = _get_sem(profile, light=_is_light_job(job))
|
||||||
sem.acquire()
|
sem.acquire()
|
||||||
job = _job_row(job_id)
|
job = _job_row(job_id)
|
||||||
@@ -344,15 +448,22 @@ def _run(job_id: str):
|
|||||||
_emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "running", "attempts": attempts})
|
_emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "running", "attempts": attempts})
|
||||||
result = _execute(profile, job["action"], payload, user_id=int(job.get("user_id") or 0))
|
result = _execute(profile, job["action"], payload, user_id=int(job.get("user_id") or 0))
|
||||||
fresh = _job_row(job_id)
|
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":
|
if fresh and fresh["status"] != "running":
|
||||||
return
|
return
|
||||||
_set_job(job_id, "done", result=result, finished=True)
|
_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))
|
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})
|
_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: Completed jobs must publish a fresh torrent snapshot/patch so removed or moved torrents disappear without a page reload.
|
|
||||||
action_name = str(job["action"] or "")
|
action_name = str(job["action"] or "")
|
||||||
|
_emit_disk_refresh_requested(int(profile["id"]), action_name, payload, result or {})
|
||||||
_emit_torrent_refresh(profile, action_name)
|
_emit_torrent_refresh(profile, action_name)
|
||||||
|
if action_name == "profile_transfer":
|
||||||
|
# Note: Refresh the destination profile cache as well so users see transferred torrents immediately after switching.
|
||||||
|
try:
|
||||||
|
target_profile = get_profile(int(payload.get("target_profile_id") or 0), int(job.get("user_id") or 0))
|
||||||
|
if target_profile:
|
||||||
|
_emit_torrent_refresh(target_profile, action_name)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
_schedule_delayed_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})
|
_emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "done", "result": result})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -376,8 +487,8 @@ def _run(job_id: str):
|
|||||||
finally:
|
finally:
|
||||||
if sem:
|
if sem:
|
||||||
sem.release()
|
sem.release()
|
||||||
if ordered_lock:
|
for lock in reversed(ordered_locks):
|
||||||
ordered_lock.release()
|
lock.release()
|
||||||
_release_runner(job_id)
|
_release_runner(job_id)
|
||||||
|
|
||||||
|
|
||||||
@@ -415,7 +526,6 @@ def _timeout_running_jobs() -> None:
|
|||||||
continue
|
continue
|
||||||
message = f"Watchdog timeout after {_job_timeout_seconds(profile, row)} seconds"
|
message = f"Watchdog timeout after {_job_timeout_seconds(profile, row)} seconds"
|
||||||
_set_job(row["id"], "failed", message, finished=True)
|
_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)
|
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("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})
|
_emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "failed", "error": message})
|
||||||
@@ -434,8 +544,7 @@ def _resubmit_interrupted_running_jobs() -> None:
|
|||||||
if not profile:
|
if not profile:
|
||||||
continue
|
continue
|
||||||
last_seen_ts = _parse_ts(row.get("heartbeat_at") or row.get("updated_at"))
|
last_seen_ts = _parse_ts(row.get("heartbeat_at") or row.get("updated_at"))
|
||||||
# Note: After process restart there is no in-memory runner for this job.
|
|
||||||
# A short grace avoids stealing work from another still-alive Gunicorn worker.
|
|
||||||
if last_seen_ts is not None and now_ts - last_seen_ts < 90:
|
if last_seen_ts is not None and now_ts - last_seen_ts < 90:
|
||||||
continue
|
continue
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
@@ -444,7 +553,6 @@ def _resubmit_interrupted_running_jobs() -> None:
|
|||||||
("Resuming interrupted job from last checkpoint", utcnow(), row["id"]),
|
("Resuming interrupted job from last checkpoint", utcnow(), row["id"]),
|
||||||
)
|
)
|
||||||
if int(cur.rowcount or 0):
|
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))
|
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})
|
_emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "pending", "resumed": True})
|
||||||
_submit_job(row["id"], row.get("action"))
|
_submit_job(row["id"], row.get("action"))
|
||||||
@@ -467,7 +575,6 @@ def _resubmit_stale_pending_jobs() -> None:
|
|||||||
continue
|
continue
|
||||||
with connect() as conn:
|
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"]))
|
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))
|
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})
|
_emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "pending", "watchdog": True})
|
||||||
_submit_job(row["id"], row.get("action"))
|
_submit_job(row["id"], row.get("action"))
|
||||||
@@ -506,7 +613,6 @@ def _job_summary(row: dict, payload: dict, result: dict) -> str:
|
|||||||
count = int(ctx.get("hash_count") or len(payload.get("hashes") or []) or result.get("count") or 0)
|
count = int(ctx.get("hash_count") or len(payload.get("hashes") or []) or result.get("count") or 0)
|
||||||
parts = []
|
parts = []
|
||||||
if ctx.get("bulk_label"):
|
if ctx.get("bulk_label"):
|
||||||
# Note: Shows which generated bulk part is being displayed in the job queue.
|
|
||||||
parts.append(f"{ctx.get('bulk_label')} of {ctx.get('bulk_parts')}")
|
parts.append(f"{ctx.get('bulk_label')} of {ctx.get('bulk_parts')}")
|
||||||
if count:
|
if count:
|
||||||
parts.append(("bulk " if count > 1 else "single ") + f"{count} torrent(s)")
|
parts.append(("bulk " if count > 1 else "single ") + f"{count} torrent(s)")
|
||||||
@@ -572,7 +678,6 @@ def cancel_job(job_id: str) -> bool:
|
|||||||
row = _job_row(job_id)
|
row = _job_row(job_id)
|
||||||
if not row or row["status"] not in {"pending", "running"}:
|
if not row or row["status"] not in {"pending", "running"}:
|
||||||
return False
|
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)
|
_set_job(job_id, "cancelled", finished=True)
|
||||||
payload = _job_payload(row)
|
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))
|
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))
|
||||||
@@ -590,7 +695,6 @@ def clear_jobs() -> int:
|
|||||||
|
|
||||||
|
|
||||||
def emergency_clear_jobs() -> int:
|
def emergency_clear_jobs() -> int:
|
||||||
# Note: Emergency cleanup first marks active jobs as cancelled, then clears the whole job log list.
|
|
||||||
now = utcnow()
|
now = utcnow()
|
||||||
where, params = _job_scope_sql(writable=True)
|
where, params = _job_scope_sql(writable=True)
|
||||||
status_clause = "status IN ('pending', 'running')"
|
status_clause = "status IN ('pending', 'running')"
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1 +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';";
|
export const appStatusSource = " async function loadAppStatus(){\n const box=$('appStatusManager'); if(!box) return;\n box.innerHTML='<span class=\"spinner-border spinner-border-sm\"></span> Loading diagnostics...';\n try{\n const [status,pollerResponse]=await Promise.all([\n fetch('/api/app/status',{cache:'no-store'}).then(r=>r.json()),\n fetch('/api/poller/settings',{cache:'no-store'}).then(r=>r.json()).catch(()=>({ok:false}))\n ]);\n if(!status.ok) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, profile=st.profile||{};\n const pollerBundle=(pollerResponse && pollerResponse.ok!==false) ? pollerResponse : (st.poller||{});\n const rt=pollerBundle.runtime||{}, ps=pollerBundle.settings||{};\n // Note: App status uses embedded poller data as a fallback, so one failing endpoint cannot leave Runtime poller empty.\n const intervalValue=(runtimeKey,settingsKey)=>rt[runtimeKey] ?? ps[settingsKey] ?? '-';\n const runtimeReady=rt.runtime_ready!==false && (Number(rt.tick_count||0)>0 || Number(rt.live_poll_count||0)>0 || Number(rt.list_poll_count||0)>0 || Number(rt.last_tick_ms||0)>0);\n const waiting=!runtimeReady && rt.runtime_ready===false;\n const mode=waiting?'waiting':(rt.adaptive_mode || ((rt.adaptive_enabled ?? ps.adaptive_enabled)===false?'fixed':'normal'));\n const processCards=[\n diagCard('PID', py.pid),\n diagCard('Uptime', `${py.uptime_seconds||0}s`),\n diagCard('Memory RSS', py.memory_rss_h||py.memory_rss),\n diagCard('Threads', py.threads),\n diagCard('CPU', `${py.cpu_percent ?? '-'}%`),\n diagCard('Python', py.python||'-'),\n diagCard('Worker threads', py.worker_threads ?? '-'),\n diagCard('Jobs total', py.jobs_total ?? '-')\n ];\n const pollerCards=[\n diagCard('Adaptive', (rt.adaptive_enabled ?? ps.adaptive_enabled)===false?'off':'on'),\n diagCard('Mode', mode),\n diagCard('Live interval', `${intervalValue('live_stats_interval_seconds','live_stats_interval_seconds')}s`),\n diagCard('List interval', `${intervalValue('torrent_list_interval_seconds','torrent_list_interval_seconds')}s`),\n diagCard('Last tick', waiting?'waiting':`${rt.duration_ms||rt.last_tick_ms||0} ms`),\n diagCard('Tick gap', waiting?'waiting':`${rt.last_tick_gap_ms||0} ms`),\n diagCard('Payload', waiting?'waiting':fmtBytes(rt.emitted_payload_size||0)),\n diagCard('rTorrent calls', waiting?'waiting':(rt.rtorrent_call_count||0))\n ];\n const connectionCards=[\n diagCard('Active profile', profile.name||profile.id||'-'),\n diagCard('API response time', `${st.api_ms ?? '-'} ms`),\n diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'),\n diagCard('SCGI URL', scgi.url||'-'),\n diagCard('SCGI connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'),\n diagCard('SCGI first byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'),\n diagCard('SCGI total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'),\n diagCard('Request bytes', scgi.request_bytes),\n diagCard('Response bytes', scgi.response_bytes),\n diagCard('XML bytes', scgi.xml_bytes),\n diagCard('rTorrent version', scgi.client_version||'-')\n ];\n const panes=[\n ['process','Process', `${diagnosticsSection('pyTorrent process', processCards)}${diagnosticsSection('Runtime poller', pollerCards)}`],\n ['connection','Connection', diagnosticsSection('Profile and rTorrent', connectionCards)]\n ];\n const tabs=`<div class=\"column-manager-tabs appstatus-tabs\"><ul class=\"nav nav-pills\">${panes.map((p,i)=>`<li class=\"nav-item\"><button class=\"nav-link ${i?'':'active'}\" type=\"button\" data-appstatus-pane=\"${p[0]}\">${p[1]}</button></li>`).join('')}</ul></div>`;\n if($('appStatusTabs')) $('appStatusTabs').innerHTML=tabs;\n box.innerHTML=`${panes.map((p,i)=>`<div class=\"appstatus-pane ${i?'d-none':''}\" data-appstatus-panel=\"${p[0]}\">${p[2]}</div>`).join('')}${scgi.error?`<div class=\"alert alert-danger mt-3 mb-0\">${esc(scgi.error)}</div>`:''}`;\n }catch(e){ box.innerHTML=`<div class=\"alert alert-danger mb-0\">${esc(e.message)}</div>`; }\n }\n\n\n\n const TORRENT_STATS_PANE_STORAGE_KEY = 'pytorrent.torrentStatsPane.v1';";
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +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('#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); ";
|
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); ";
|
||||||
|
|||||||
@@ -1 +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) · ${(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 · Created: ${esc(preview.created_at||'-')} · ${preview.automatic?'automatic':'manual'} · 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','Type','Actions'],rows.map(b=>[esc(b.name),humanDateCell(b.created_at),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";
|
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
-1
@@ -1 +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 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";
|
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
@@ -1 +1 @@
|
|||||||
export const createTorrentSource = " function isCreateTorrentTabActive(){\n return $('createTorrentPane')?.classList.contains('active');\n }\n function syncAddAndCreateActions(){\n const createActive = isCreateTorrentTabActive();\n $('addBtn')?.classList.toggle('d-none', !!createActive);\n $('createTorrentBtn')?.classList.toggle('d-none', !createActive);\n }\n function createTorrentPayload(){\n const fd = new FormData();\n fd.append('source_path', $('createSourcePath')?.value || '');\n fd.append('trackers', $('createTrackers')?.value || '');\n fd.append('comment', $('createComment')?.value || '');\n fd.append('source', $('createSourceName')?.value || '');\n fd.append('piece_size_kib', $('createPieceSize')?.value || '256');\n fd.append('private', $('createPrivate')?.checked ? '1' : '0');\n fd.append('share', $('createShare')?.checked ? '1' : '0');\n fd.append('label', $('createLabel')?.value || '');\n return fd;\n }\n function downloadCreatedTorrent(blob,name){\n const obj = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = obj;\n a.download = name;\n document.body.appendChild(a);\n a.click();\n a.remove();\n setTimeout(()=>URL.revokeObjectURL(obj), 1000);\n }\n async function createTorrentFromModal(){\n const btn = $('createTorrentBtn');\n const info = $('createTorrentInfo');\n buttonBusy(btn, true);\n setBusy(true, 'Creating torrent...');\n if(info) info.textContent = 'Creating .torrent file...';\n try{\n const res = await fetch('/api/torrents/create', {method: 'POST', body: createTorrentPayload()});\n if(!res.ok){\n const j = await res.json().catch(()=>({}));\n throw new Error(j.error || `Create failed (${res.status})`);\n }\n const name = filenameFromResponse(res, 'created.torrent');\n const message = res.headers.get('X-PyTorrent-Create-Message') || 'Torrent created';\n const blob = await res.blob();\n downloadCreatedTorrent(blob, name);\n if(info) info.textContent = message;\n toast(message, 'success');\n }catch(e){\n if(info) info.textContent = e.message;\n toast(e.message, 'danger');\n }finally{\n setBusy(false);\n buttonBusy(btn, false);\n }\n }\n $('addModal')?.addEventListener('shown.bs.modal', syncAddAndCreateActions);\n document.querySelectorAll('#addModal [data-bs-toggle=\"pill\"]').forEach(tab => tab.addEventListener('shown.bs.tab', syncAddAndCreateActions));\n $('createTorrentBtn')?.addEventListener('click', createTorrentFromModal);\n";
|
export const createTorrentSource = " function isCreateTorrentTabActive(){\n return $('createTorrentPane')?.classList.contains('active');\n }\n function syncAddAndCreateActions(){\n // Note: Keeps footer actions scoped to the currently selected Add/Create tab.\n const createActive = isCreateTorrentTabActive();\n $('addBtn')?.classList.toggle('d-none', !!createActive);\n $('clearAddTorrentBtn')?.classList.toggle('d-none', !!createActive);\n $('createTorrentBtn')?.classList.toggle('d-none', !createActive);\n }\n function createTorrentPayload(){\n const fd = new FormData();\n fd.append('source_path', $('createSourcePath')?.value || '');\n fd.append('trackers', $('createTrackers')?.value || '');\n fd.append('comment', $('createComment')?.value || '');\n fd.append('source', $('createSourceName')?.value || '');\n fd.append('piece_size_kib', $('createPieceSize')?.value || '256');\n fd.append('private', $('createPrivate')?.checked ? '1' : '0');\n fd.append('share', $('createShare')?.checked ? '1' : '0');\n fd.append('label', $('createLabel')?.value || '');\n return fd;\n }\n function downloadCreatedTorrent(blob,name){\n const obj = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = obj;\n a.download = name;\n document.body.appendChild(a);\n a.click();\n a.remove();\n setTimeout(()=>URL.revokeObjectURL(obj), 1000);\n }\n async function createTorrentFromModal(){\n const btn = $('createTorrentBtn');\n const info = $('createTorrentInfo');\n buttonBusy(btn, true);\n setBusy(true, 'Creating torrent...');\n if(info) info.textContent = 'Creating .torrent file...';\n try{\n const res = await fetch('/api/torrents/create', {method: 'POST', body: createTorrentPayload()});\n if(!res.ok){\n const j = await res.json().catch(()=>({}));\n throw new Error(j.error || `Create failed (${res.status})`);\n }\n const name = filenameFromResponse(res, 'created.torrent');\n const message = res.headers.get('X-PyTorrent-Create-Message') || 'Torrent created';\n const blob = await res.blob();\n downloadCreatedTorrent(blob, name);\n if(info) info.textContent = message;\n toast(message, 'success');\n }catch(e){\n if(info) info.textContent = e.message;\n toast(e.message, 'danger');\n }finally{\n setBusy(false);\n buttonBusy(btn, false);\n }\n }\n $('addModal')?.addEventListener('shown.bs.modal', syncAddAndCreateActions);\n document.querySelectorAll('#addModal [data-bs-toggle=\"pill\"]').forEach(tab => tab.addEventListener('shown.bs.tab', syncAddAndCreateActions));\n $('createTorrentBtn')?.addEventListener('click', createTorrentFromModal);\n";
|
||||||
|
|||||||
@@ -1 +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').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";
|
export const diagnosticsDashboardSource = "function diagnosticsSection(title, cards){\n return `<section class=\"diagnostics-section\"><div class=\"section-title\"><i class=\"fa-solid fa-stethoscope\"></i> ${esc(title)}</div><div class=\"diag-grid\">${cards.join('')}</div></section>`;\n}\nasync function loadDiagnosticsPage(){\n const box=$('diagnosticsPageManager');\n if(!box) return;\n box.innerHTML='<span class=\"spinner-border spinner-border-sm\"></span> Loading diagnostics...';\n try{\n const [status,poller,planner,smart]=await Promise.all([\n fetch('/api/app/status?cleanup=1').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({})),\n fetch('/api/download-planner/preview').then(r=>r.json()).catch(()=>({})),\n fetch('/api/smart-queue?history_limit=100').then(r=>r.json()).catch(()=>({ok:false})),\n ]);\n if(status && status.ok===false) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, profile=st.profile||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, cleanup=st.cleanup||{}, db=cleanup.database||{}, pc=st.port_check||{};\n const rt=poller.runtime||{}, ps=poller.settings||{}, pv=planner.preview||{}, smartStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null;\n const profileCards=[diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), diagCard('Incoming port', pc.port||'-'), diagCard('Port status', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':'')];\n const rtCards=[diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('Connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('First byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('Total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'), diagCard('Request bytes', scgi.request_bytes), diagCard('Response bytes', scgi.response_bytes), diagCard('XML bytes', scgi.xml_bytes), diagCard('rTorrent version', scgi.client_version||'-')];\n const pollerReady=rt.runtime_ready!==false && (Number(rt.tick_count||0)>0 || Number(rt.live_poll_count||0)>0 || Number(rt.list_poll_count||0)>0);\n const pollerWaiting=!pollerReady && rt.runtime_ready===false;\n const pollerCards=[diagCard('Adaptive', (rt.adaptive_enabled ?? ps.adaptive_enabled)===false?'off':'on'), diagCard('Mode', pollerWaiting?'waiting':(rt.adaptive_mode||'-')), diagCard('Effective interval', `${rt.effective_interval_seconds??rt.live_stats_interval_seconds??ps.live_stats_interval_seconds??'-'}s`), diagCard('Minimum interval', `${rt.configured_min_interval_seconds??'-'}s`), diagCard('Tick duration', pollerWaiting?'waiting':`${rt.duration_ms||rt.last_tick_ms||0} ms`), diagCard('Tick gap', pollerWaiting?'waiting':`${rt.last_tick_gap_ms||0} ms`), diagCard('Payload', pollerWaiting?'waiting':fmtBytes(rt.emitted_payload_size||0)), diagCard('rTorrent calls', pollerWaiting?'waiting':(rt.rtorrent_call_count||0)), diagCard('Skipped emissions', rt.skipped_emissions||0), diagCard('Ticks', rt.tick_count||0)];\n const plannerCards=[diagCard('Matched rule', pv.matched_rule||'-'), diagCard('Next change', pv.next_change_at||'-'), diagCard('Planner state', pv.enabled===false?'disabled':'enabled')];\n const databaseCards=[diagCard('DB size', db.size_h||'-'), diagCard('Job logs', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-')];\n const workerCards=[diagCard('Worker threads', py.worker_threads ?? '-'), diagCard('Jobs total', py.jobs_total ?? '-'), diagCard('Threads', py.threads ?? '-'), diagCard('CPU', `${py.cpu_percent ?? '-'}%`)];\n const smartBlock=`<section class=\"diagnostics-section\"><div class=\"section-title\"><i class=\"fa-solid fa-list-check\"></i> Smart Queue decisions</div>${renderSmartQueueNerdStats(smartStats)}</section>`;\n box.innerHTML=[diagnosticsSection('Profile and port',profileCards), diagnosticsSection('rTorrent connection',rtCards), diagnosticsSection('Adaptive poller',pollerCards), diagnosticsSection('Planner',plannerCards), diagnosticsSection('Database and cleanup',databaseCards), diagnosticsSection('Worker state',workerCards), smartBlock, scgi.error?`<div class=\"alert alert-danger mt-3 mb-0\">${esc(scgi.error)}</div>`:''].join('');\n }catch(e){ box.innerHTML=`<div class=\"alert alert-danger mb-0\">${esc(e.message)}</div>`; }\n}\n";
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +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><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)}\"><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";
|
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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +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'); }, 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";
|
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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user