Compare commits

..

75 Commits

Author SHA1 Message Date
gru
3256ae34fe Update scripts/stack_installers/install_rtorrent_rhel.py 2026-05-29 23:18:23 +02:00
gru
56a29c7a97 Update scripts/stack_installers/install_rtorrent.py 2026-05-29 23:17:45 +02:00
gru
4c8debb103 Merge pull request 'ux changes' (#10) from ux_small_fixes1 into master
Reviewed-on: #10
2026-05-29 11:35:01 +02:00
Mateusz Gruszczyński
15078c30da ux changes 2026-05-29 11:26:28 +02:00
gru
5eeb0da092 Merge pull request 'easteregg' (#9) from easteregg_marcin into master
Reviewed-on: #9
2026-05-28 22:48:05 +02:00
Mateusz Gruszczyński
8c1cc23a8d easteregg 2026-05-28 22:44:06 +02:00
Mateusz Gruszczyński
0408f7859e prank 2026-05-28 22:24:39 +02:00
gru
05a26a6cfe Merge pull request 'multilang_and_other' (#8) from multilang_and_other into master
Reviewed-on: #8
2026-05-28 22:08:32 +02:00
Mateusz Gruszczyński
76ffe32319 states fix 2026-05-28 15:41:41 +02:00
Mateusz Gruszczyński
e4310797c8 small changes 2026-05-28 15:35:34 +02:00
Mateusz Gruszczyński
1651075f40 chages in profiles 2026-05-28 09:01:14 +02:00
Mateusz Gruszczyński
a611113d2a chages in smart queue 2026-05-28 07:21:44 +02:00
Mateusz Gruszczyński
46fec57ab8 revert planner js 2026-05-27 23:01:31 +02:00
Mateusz Gruszczyński
1768b30df6 revert planner js 2026-05-27 22:56:12 +02:00
Mateusz Gruszczyński
31895f9783 rebuild rtorrent config 2026-05-27 22:53:22 +02:00
Mateusz Gruszczyński
01c5c54c10 rebuild rtorrent config 2026-05-27 22:42:37 +02:00
Mateusz Gruszczyński
80c71c8d79 rebuild rtorrent config 2026-05-27 22:31:22 +02:00
Mateusz Gruszczyński
a8adee0f2f light poller commit3 2026-05-27 15:13:22 +02:00
Mateusz Gruszczyński
054c9122f8 light poller commit1 2026-05-27 14:58:26 +02:00
gru
4075e934eb Merge pull request 'profles_and_ux' (#7) from profles_and_ux into master
Reviewed-on: #7
2026-05-27 14:38:06 +02:00
Mateusz Gruszczyński
1eb3aeff6c ux, and themes 2026-05-26 22:42:14 +02:00
Mateusz Gruszczyński
869af8756f ux, and themes 2026-05-26 22:31:48 +02:00
Mateusz Gruszczyński
f0da24f484 db cleanup 2026-05-26 09:43:38 +02:00
Mateusz Gruszczyński
514482f0b5 changes in db 2026-05-26 09:38:12 +02:00
Mateusz Gruszczyński
70a9344cdd changes in db 2026-05-26 09:25:47 +02:00
Mateusz Gruszczyński
8268ad87cf changes in ux 2026-05-26 09:15:37 +02:00
Mateusz Gruszczyński
32c780793b fix in js's 2026-05-26 09:07:34 +02:00
Mateusz Gruszczyński
92d870878f big changes in profiles and users 2026-05-26 09:00:29 +02:00
Mateusz Gruszczyński
629b06a9df fix for commit1 2026-05-26 08:28:41 +02:00
Mateusz Gruszczyński
5ab750226a commit_1 2026-05-26 08:25:17 +02:00
gru
77a161a7f6 Merge pull request 'tiny_auth_support' (#6) from tiny_auth_support into master
Reviewed-on: #6
2026-05-26 08:04:52 +02:00
Mateusz Gruszczyński
81d9556443 urgent fixes2 2026-05-25 22:32:36 +02:00
Mateusz Gruszczyński
0ee0f3424c urgent fixes 2026-05-25 22:21:29 +02:00
Mateusz Gruszczyński
680a673a9a allow health without auth 2026-05-25 21:50:29 +02:00
Mateusz Gruszczyński
1df01e8cc6 changes in auth logic 2026-05-25 15:44:35 +02:00
Mateusz Gruszczyński
109811c024 bypass profile select 2026-05-25 10:22:14 +02:00
Mateusz Gruszczyński
2e2d747fa2 bypass profile select 2026-05-25 10:15:04 +02:00
Mateusz Gruszczyński
ff7d836b77 logout inactive on exteranal auth 2026-05-25 10:07:51 +02:00
Mateusz Gruszczyński
9021b09bc5 auth providers 2026-05-25 09:58:02 +02:00
Mateusz Gruszczyński
58d1c7a761 auth providers 2026-05-25 09:21:06 +02:00
Mateusz Gruszczyński
93aaca553b auth providers 2026-05-25 09:09:41 +02:00
Mateusz Gruszczyński
352c53617c auth providers 2026-05-25 08:38:08 +02:00
gru
f79e072610 Merge pull request 'compact_1' (#5) from compact_v into master
Reviewed-on: #5
2026-05-25 08:09:47 +02:00
Mateusz Gruszczyński
e298edd1e3 compact_1 2026-05-25 07:21:38 +02:00
gru
17b497a32b Merge pull request 'mobile_torrent_details' (#4) from mobile_torrent_details into master
Reviewed-on: #4
2026-05-25 07:11:29 +02:00
Mateusz Gruszczyński
80bb921148 mobile torrent details 2026-05-24 13:43:45 +02:00
Mateusz Gruszczyński
f8eddd6fd5 mobile torrent details 2026-05-24 13:40:54 +02:00
Mateusz Gruszczyński
778717d8b3 mobile torrent details 2026-05-24 13:32:46 +02:00
Mateusz Gruszczyński
d44cbe2429 mobile torrent details 2026-05-24 13:23:28 +02:00
gru
0398dd9d39 Merge pull request 'fixes' (#3) from fixes into master
Reviewed-on: #3
2026-05-24 12:54:07 +02:00
Mateusz Gruszczyński
a9ebf901ab select all fix 2026-05-24 12:48:22 +02:00
Mateusz Gruszczyński
173ac3951a fix select/unselect 2026-05-24 12:25:45 +02:00
Mateusz Gruszczyński
5a11730ee0 harder post-ceheck 2026-05-24 11:24:26 +02:00
Mateusz Gruszczyński
9caa155324 post-check 2026-05-24 11:04:42 +02:00
gru
8553615fbf Merge pull request 'themes' (#2) from themes into master
Reviewed-on: #2
2026-05-23 00:32:07 +02:00
Mateusz Gruszczyński
953616e126 autorefresh files logic 2026-05-22 23:24:40 +02:00
Mateusz Gruszczyński
57e45ea858 block file info for incomplete files 2026-05-22 23:17:28 +02:00
Mateusz Gruszczyński
c69142e328 block file info for incomplete files 2026-05-22 23:03:30 +02:00
Mateusz Gruszczyński
7c0a4ff703 fix in download torrents 2026-05-22 14:14:07 +02:00
Mateusz Gruszczyński
00a3831386 fix in download torrents 2026-05-22 14:04:26 +02:00
Mateusz Gruszczyński
6aea0c1ad9 fix in download torrents 2026-05-22 13:53:10 +02:00
Mateusz Gruszczyński
0a0ee9e8e5 fix in download torrents 2026-05-22 13:43:06 +02:00
Mateusz Gruszczyński
d383d89994 fix in download torrents 2026-05-22 13:32:36 +02:00
Mateusz Gruszczyński
cae6d4163b new themes 2026-05-22 09:02:07 +02:00
Mateusz Gruszczyński
4956322677 new themes 2026-05-22 08:56:27 +02:00
Mateusz Gruszczyński
c62640ba99 fix ion import 2026-05-21 22:12:33 +02:00
Mateusz Gruszczyński
e27ffbb6e2 temporary_link feature 2026-05-21 22:06:52 +02:00
Mateusz Gruszczyński
b772c97d50 temporary_link feature 2026-05-21 22:05:08 +02:00
Mateusz Gruszczyński
cb48735178 better pdf ux 2026-05-21 12:34:18 +02:00
Mateusz Gruszczyński
9142590c79 media info - read pdf 2026-05-21 10:39:19 +02:00
Mateusz Gruszczyński
c2948ea277 media info - read txt and images 2026-05-21 10:18:24 +02:00
Mateusz Gruszczyński
d0026ab7f9 media info - fix file read 2026-05-21 10:05:55 +02:00
Mateusz Gruszczyński
b0b3497eec media info - better imports 2026-05-21 10:01:29 +02:00
Mateusz Gruszczyński
4e009ccf05 media info 2026-05-21 09:52:25 +02:00
Mateusz Gruszczyński
bd9be0d11c media info 2026-05-21 09:49:50 +02:00
71 changed files with 7081 additions and 860 deletions

View File

@@ -3,8 +3,8 @@ PYTORRENT_DB_PATH=data/pytorrent.sqlite3
PYTORRENT_HOST=0.0.0.0 PYTORRENT_HOST=0.0.0.0
PYTORRENT_PORT=8090 PYTORRENT_PORT=8090
PYTORRENT_DEBUG=0 PYTORRENT_DEBUG=0
PYTORRENT_POLL_INTERVAL=0.5 PYTORRENT_POLL_INTERVAL=1
MIN_POLL_INTERVAL_SECONDS=0.5 MIN_POLL_INTERVAL_SECONDS=1
PYTORRENT_WORKERS=16 PYTORRENT_WORKERS=16
PYTORRENT_GEOIP_DB=data/GeoLite2-City.mmdb PYTORRENT_GEOIP_DB=data/GeoLite2-City.mmdb
PYTORRENT_ALLOW_UNSAFE_WERKZEUG=0 PYTORRENT_ALLOW_UNSAFE_WERKZEUG=0
@@ -13,14 +13,6 @@ PYTORRENT_SCGI_RETRIES=8
# css/js libs # css/js libs
PYTORRENT_USE_OFFLINE_LIBS=true PYTORRENT_USE_OFFLINE_LIBS=true
# python -m pytorrent.cli reset-password admin new_Pass
PYTORRENT_AUTH_ENABLE=false
# Reverse proxy / HTTPS
PYTORRENT_PROXY_FIX_ENABLE=false
PYTORRENT_SESSION_COOKIE_SECURE=false
# PYTORRENT_SOCKETIO_CORS_ALLOWED_ORIGINS=https://your-domain.com
# Retention / Smart Queue # Retention / Smart Queue
PYTORRENT_TRAFFIC_HISTORY_RETENTION_DAYS=90 PYTORRENT_TRAFFIC_HISTORY_RETENTION_DAYS=90
PYTORRENT_JOBS_RETENTION_DAYS=30 PYTORRENT_JOBS_RETENTION_DAYS=30
@@ -42,3 +34,38 @@ PYTORRENT_LOG_RETENTION_HOURS=24
PYTORRENT_GUNICORN_ACCESS_LOG=data/logs/gunicorn-access.log PYTORRENT_GUNICORN_ACCESS_LOG=data/logs/gunicorn-access.log
PYTORRENT_GUNICORN_ERROR_LOG=data/logs/gunicorn-error.log PYTORRENT_GUNICORN_ERROR_LOG=data/logs/gunicorn-error.log
PYTORRENT_GUNICORN_LOG_LEVEL=info PYTORRENT_GUNICORN_LOG_LEVEL=info
#### AUTH
# python -m pytorrent.cli reset-password admin new_Pass
PYTORRENT_AUTH_ENABLE=false
# Authentication provider
# Available variants:
# - local = built-in login screen with username/password
# - tinyauth = external auth via Tinyauth / reverse proxy headers
# - proxy = generic external reverse proxy auth
PYTORRENT_AUTH_PROVIDER=tinyauth
# Headers passed by Tinyauth
PYTORRENT_AUTH_PROXY_USER_HEADER=Remote-User
# Headers passed by external reverse proxy
#PYTORRENT_AUTH_PROXY_USER_HEADER=X-Forwarded-User
# Auto-create user when authenticated externally but missing in DB
PYTORRENT_AUTH_PROXY_AUTO_CREATE=true
# Defaults for auto-created users
PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=admin
PYTORRENT_AUTH_PROXY_AUTO_CREATE_PERMISSION=rw
# Reverse proxy / HTTPS
PYTORRENT_PROXY_FIX_ENABLE=true
PYTORRENT_SESSION_COOKIE_SECURE=false
#PYTORRENT_SOCKETIO_CORS_ALLOWED_ORIGINS=https://pytorrent.domain.com
#PYTORRENT_API_ALLOWED_ORIGINS=https://pytorrent.domain.com
# bypass auth on specific hosts (ex. local ip)
PYTORRENT_AUTH_BYPASS_HOSTS=10.11.1.11:8090,10.11.1.11
PYTORRENT_AUTH_BYPASS_USER=admin

2
.gitignore vendored
View File

@@ -43,3 +43,5 @@ data/logs/*
todo.txt todo.txt
pytorrent/static/libs/* pytorrent/static/libs/*
!pytorrent/static/libs/pytorrent-themes/
!pytorrent/static/libs/pytorrent-themes/**

233
auth.md Normal file
View File

@@ -0,0 +1,233 @@
# 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.

View File

@@ -29,6 +29,22 @@ DEBUG = _env_bool("PYTORRENT_DEBUG", False)
USE_OFFLINE_LIBS = _env_bool("PYTORRENT_USE_OFFLINE_LIBS", False) USE_OFFLINE_LIBS = _env_bool("PYTORRENT_USE_OFFLINE_LIBS", False)
# Note: Optional authentication remains disabled unless explicitly enabled in .env. # Note: Optional authentication remains disabled unless explicitly enabled in .env.
AUTH_ENABLE = _env_bool("PYTORRENT_AUTH_ENABLE", False) AUTH_ENABLE = _env_bool("PYTORRENT_AUTH_ENABLE", False)
AUTH_PROVIDER = os.getenv("PYTORRENT_AUTH_PROVIDER", "local").strip().lower() or "local"
if AUTH_PROVIDER not in {"local", "proxy", "tinyauth"}:
AUTH_PROVIDER = "local"
# Note: External auth reads only one identity value from the trusted reverse proxy.
AUTH_PROXY_USER_HEADER = os.getenv("PYTORRENT_AUTH_PROXY_USER_HEADER", "Remote-User").strip() or "Remote-User"
AUTH_PROXY_AUTO_CREATE = _env_bool("PYTORRENT_AUTH_PROXY_AUTO_CREATE", False)
AUTH_PROXY_AUTO_CREATE_ROLE = os.getenv("PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE", "user").strip().lower()
AUTH_PROXY_AUTO_CREATE_PERMISSION = os.getenv("PYTORRENT_AUTH_PROXY_AUTO_CREATE_PERMISSION", "ro").strip().lower()
if AUTH_PROXY_AUTO_CREATE_ROLE not in {"user", "admin"}:
AUTH_PROXY_AUTO_CREATE_ROLE = "user"
# Note: Keep rw as an operator-friendly alias while storing full internally.
if AUTH_PROXY_AUTO_CREATE_PERMISSION == "rw":
AUTH_PROXY_AUTO_CREATE_PERMISSION = "full"
if AUTH_PROXY_AUTO_CREATE_PERMISSION not in {"none", "ro", "full"}:
AUTH_PROXY_AUTO_CREATE_PERMISSION = "ro"
if AUTH_ENABLE and (not _SECRET_KEY_ENV or SECRET_KEY == "dev-change-me"): if AUTH_ENABLE and (not _SECRET_KEY_ENV or SECRET_KEY == "dev-change-me"):
# Note: Auth mode cannot use Flask's development secret; persist a local random session key instead. # Note: Auth mode cannot use Flask's development secret; persist a local random session key instead.
_secret_file = BASE_DIR / "data" / ".session_secret" _secret_file = BASE_DIR / "data" / ".session_secret"
@@ -70,8 +86,18 @@ PROXY_FIX_X_HOST = _env_int("PYTORRENT_PROXY_FIX_X_HOST", 1, 0)
PROXY_FIX_X_PORT = _env_int("PYTORRENT_PROXY_FIX_X_PORT", 1, 0) PROXY_FIX_X_PORT = _env_int("PYTORRENT_PROXY_FIX_X_PORT", 1, 0)
PROXY_FIX_X_PREFIX = _env_int("PYTORRENT_PROXY_FIX_X_PREFIX", 1, 0) PROXY_FIX_X_PREFIX = _env_int("PYTORRENT_PROXY_FIX_X_PREFIX", 1, 0)
def _env_csv(name: str) -> list[str]:
return [item.strip().rstrip("/") for item in os.getenv(name, "").split(",") if item.strip()]
_SOCKETIO_CORS = os.getenv("PYTORRENT_SOCKETIO_CORS_ALLOWED_ORIGINS", "").strip() _SOCKETIO_CORS = os.getenv("PYTORRENT_SOCKETIO_CORS_ALLOWED_ORIGINS", "").strip()
SOCKETIO_CORS_ALLOWED_ORIGINS = None if not _SOCKETIO_CORS else [item.strip() for item in _SOCKETIO_CORS.split(",") if item.strip()] SOCKETIO_CORS_ALLOWED_ORIGINS = None if not _SOCKETIO_CORS else [item.strip() for item in _SOCKETIO_CORS.split(",") if item.strip()]
# Note: API origin checks are separate from Socket.IO CORS. When unset, reuse the Socket.IO allowlist for operator-friendly reverse proxy setups.
_API_ALLOWED_ORIGINS = _env_csv("PYTORRENT_API_ALLOWED_ORIGINS")
API_ALLOWED_ORIGINS = _API_ALLOWED_ORIGINS or _env_csv("PYTORRENT_SOCKETIO_CORS_ALLOWED_ORIGINS")
# Note: Optional auth bypass for trusted direct-IP/local access. Values can be hosts or host:port pairs.
AUTH_BYPASS_HOSTS = {item.lower() for item in _env_csv("PYTORRENT_AUTH_BYPASS_HOSTS")}
# Note: Trusted auth-bypass requests act as this existing active user.
AUTH_BYPASS_USER = os.getenv("PYTORRENT_AUTH_BYPASS_USER", "admin").strip() or "admin"
TRAFFIC_HISTORY_RETENTION_DAYS = _env_int("PYTORRENT_TRAFFIC_HISTORY_RETENTION_DAYS", 90, 1) TRAFFIC_HISTORY_RETENTION_DAYS = _env_int("PYTORRENT_TRAFFIC_HISTORY_RETENTION_DAYS", 90, 1)
JOBS_RETENTION_DAYS = _env_int("PYTORRENT_JOBS_RETENTION_DAYS", 30, 1) JOBS_RETENTION_DAYS = _env_int("PYTORRENT_JOBS_RETENTION_DAYS", 30, 1)

View File

@@ -10,6 +10,10 @@ CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL,
password_hash TEXT, password_hash TEXT,
email TEXT,
display_name TEXT,
external_auth_provider TEXT,
external_subject TEXT,
role TEXT DEFAULT 'user', role TEXT DEFAULT 'user',
is_active INTEGER DEFAULT 1, is_active INTEGER DEFAULT 1,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
@@ -51,32 +55,42 @@ CREATE TABLE IF NOT EXISTS user_preferences (
bootstrap_theme TEXT DEFAULT 'default', bootstrap_theme TEXT DEFAULT 'default',
font_family TEXT DEFAULT 'default', font_family TEXT DEFAULT 'default',
active_rtorrent_id INTEGER, active_rtorrent_id INTEGER,
table_columns_json TEXT,
keyboard_json TEXT, keyboard_json TEXT,
mobile_mode INTEGER DEFAULT 0, mobile_mode INTEGER DEFAULT 0,
peers_refresh_seconds INTEGER DEFAULT 0, compact_torrent_list_enabled INTEGER DEFAULT 0,
port_check_enabled INTEGER DEFAULT 0,
footer_items_json TEXT, footer_items_json TEXT,
title_speed_enabled INTEGER DEFAULT 0, title_speed_enabled INTEGER DEFAULT 0,
tracker_favicons_enabled INTEGER DEFAULT 0,
reverse_dns_enabled INTEGER DEFAULT 0,
automation_toasts_enabled INTEGER DEFAULT 1, automation_toasts_enabled INTEGER DEFAULT 1,
smart_queue_toasts_enabled INTEGER DEFAULT 1, smart_queue_toasts_enabled INTEGER DEFAULT 1,
disk_monitor_paths_json TEXT, easter_egg_enabled INTEGER DEFAULT 0,
disk_monitor_mode TEXT DEFAULT 'default', easter_egg_loading_image_url TEXT DEFAULT '',
disk_monitor_selected_path TEXT, easter_egg_click_image_url TEXT DEFAULT '',
disk_monitor_stop_enabled INTEGER DEFAULT 0,
disk_monitor_stop_threshold INTEGER DEFAULT 98,
interface_scale INTEGER DEFAULT 100, interface_scale INTEGER DEFAULT 100,
detail_panel_height INTEGER DEFAULT 255, detail_panel_height INTEGER DEFAULT 255,
torrent_sort_json TEXT,
active_filter TEXT DEFAULT 'all',
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) FOREIGN KEY(user_id) REFERENCES users(id)
); );
CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id); CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id);
CREATE TABLE IF NOT EXISTS profile_preferences (
user_id INTEGER NOT NULL,
profile_id INTEGER NOT NULL,
table_columns_json TEXT,
torrent_sort_json TEXT,
active_filter TEXT DEFAULT 'all',
peers_refresh_seconds INTEGER DEFAULT 0,
port_check_enabled INTEGER DEFAULT 0,
tracker_favicons_enabled INTEGER DEFAULT 0,
reverse_dns_enabled INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY(user_id, profile_id),
FOREIGN KEY(user_id) REFERENCES users(id),
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id)
);
CREATE INDEX IF NOT EXISTS idx_profile_preferences_user_profile ON profile_preferences(user_id, profile_id);
CREATE TABLE IF NOT EXISTS rtorrent_profiles ( CREATE TABLE IF NOT EXISTS rtorrent_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
@@ -170,8 +184,7 @@ CREATE TABLE IF NOT EXISTS ratio_groups (
CREATE TABLE IF NOT EXISTS rss_feeds ( CREATE TABLE IF NOT EXISTS rss_feeds (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL,
profile_id INTEGER,
name TEXT NOT NULL, name TEXT NOT NULL,
url TEXT NOT NULL, url TEXT NOT NULL,
enabled INTEGER DEFAULT 1, enabled INTEGER DEFAULT 1,
@@ -185,8 +198,7 @@ CREATE TABLE IF NOT EXISTS rss_feeds (
CREATE TABLE IF NOT EXISTS rss_rules ( CREATE TABLE IF NOT EXISTS rss_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL,
profile_id INTEGER,
name TEXT NOT NULL, name TEXT NOT NULL,
pattern TEXT NOT NULL, pattern TEXT NOT NULL,
exclude_pattern TEXT, exclude_pattern TEXT,
@@ -203,13 +215,12 @@ CREATE TABLE IF NOT EXISTS rss_rules (
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_rss_feeds_user_profile_enabled_next ON rss_feeds(user_id, profile_id, enabled, next_check_at); CREATE INDEX IF NOT EXISTS idx_rss_feeds_profile_enabled_next ON rss_feeds(profile_id, enabled, next_check_at);
CREATE INDEX IF NOT EXISTS idx_rss_rules_user_profile_enabled ON rss_rules(user_id, profile_id, enabled); CREATE INDEX IF NOT EXISTS idx_rss_rules_profile_enabled ON rss_rules(profile_id, enabled);
CREATE TABLE IF NOT EXISTS rss_history ( CREATE TABLE IF NOT EXISTS rss_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL,
profile_id INTEGER,
feed_id INTEGER, feed_id INTEGER,
rule_id INTEGER, rule_id INTEGER,
title TEXT, title TEXT,
@@ -219,8 +230,7 @@ CREATE TABLE IF NOT EXISTS rss_history (
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_rss_history_profile_created ON rss_history(profile_id, created_at); CREATE INDEX IF NOT EXISTS idx_rss_history_profile_created ON rss_history(profile_id, created_at);
CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_created ON rss_history(user_id, profile_id, created_at); CREATE INDEX IF NOT EXISTS idx_rss_history_profile_status ON rss_history(profile_id, status);
CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_status ON rss_history(user_id, profile_id, status);
CREATE UNIQUE INDEX IF NOT EXISTS idx_rss_history_unique_success ON rss_history(profile_id, COALESCE(rule_id,0), link) WHERE status IN ('queued','added'); CREATE 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 ( CREATE TABLE IF NOT EXISTS ratio_assignments (
@@ -257,12 +267,13 @@ CREATE TABLE IF NOT EXISTS app_backups (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
backup_type TEXT DEFAULT 'app',
profile_id INTEGER,
payload_json TEXT NOT NULL, payload_json TEXT NOT NULL,
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS smart_queue_settings ( CREATE TABLE IF NOT EXISTS smart_queue_settings (
user_id INTEGER NOT NULL,
profile_id INTEGER NOT NULL, profile_id INTEGER NOT NULL,
enabled INTEGER DEFAULT 0, enabled INTEGER DEFAULT 0,
max_active_downloads INTEGER DEFAULT 5, max_active_downloads INTEGER DEFAULT 5,
@@ -283,7 +294,7 @@ CREATE TABLE IF NOT EXISTS smart_queue_settings (
protect_active_below_cap INTEGER DEFAULT 1, protect_active_below_cap INTEGER DEFAULT 1,
auto_stop_idle INTEGER DEFAULT 0, auto_stop_idle INTEGER DEFAULT 0,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
PRIMARY KEY(user_id, profile_id) PRIMARY KEY(profile_id)
); );
CREATE TABLE IF NOT EXISTS smart_queue_stalled ( CREATE TABLE IF NOT EXISTS smart_queue_stalled (
@@ -304,19 +315,17 @@ CREATE TABLE IF NOT EXISTS smart_queue_start_grace (
); );
CREATE TABLE IF NOT EXISTS smart_queue_exclusions ( CREATE TABLE IF NOT EXISTS smart_queue_exclusions (
user_id INTEGER NOT NULL,
profile_id INTEGER NOT NULL, profile_id INTEGER NOT NULL,
torrent_hash TEXT NOT NULL, torrent_hash TEXT NOT NULL,
reason TEXT, reason TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
PRIMARY KEY(user_id, profile_id, torrent_hash) PRIMARY KEY(profile_id, torrent_hash)
); );
CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_user_profile_created ON smart_queue_exclusions(user_id, profile_id, created_at); CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_profile_created ON smart_queue_exclusions(profile_id, created_at);
CREATE TABLE IF NOT EXISTS smart_queue_history ( CREATE TABLE IF NOT EXISTS smart_queue_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
profile_id INTEGER NOT NULL, profile_id INTEGER NOT NULL,
event TEXT NOT NULL, event TEXT NOT NULL,
paused_count INTEGER DEFAULT 0, paused_count INTEGER DEFAULT 0,
@@ -327,7 +336,7 @@ CREATE TABLE IF NOT EXISTS smart_queue_history (
); );
CREATE INDEX IF NOT EXISTS idx_smart_queue_history_profile_created ON smart_queue_history(profile_id, created_at); CREATE INDEX IF NOT EXISTS idx_smart_queue_history_profile_created ON smart_queue_history(profile_id, created_at);
CREATE INDEX IF NOT EXISTS idx_smart_queue_history_user_profile_created ON smart_queue_history(user_id, profile_id, created_at);
CREATE TABLE IF NOT EXISTS smart_queue_auto_labels ( CREATE TABLE IF NOT EXISTS smart_queue_auto_labels (
profile_id INTEGER NOT NULL, profile_id INTEGER NOT NULL,
@@ -405,14 +414,13 @@ CREATE INDEX IF NOT EXISTS idx_automation_history_profile_created ON automation_
CREATE INDEX IF NOT EXISTS idx_automation_history_user_profile_created ON automation_history(user_id, profile_id, created_at); CREATE INDEX IF NOT EXISTS idx_automation_history_user_profile_created ON automation_history(user_id, profile_id, created_at);
CREATE TABLE IF NOT EXISTS rtorrent_config_overrides ( CREATE TABLE IF NOT EXISTS rtorrent_config_overrides (
user_id INTEGER NOT NULL,
profile_id INTEGER NOT NULL, profile_id INTEGER NOT NULL,
key TEXT NOT NULL, key TEXT NOT NULL,
value TEXT, value TEXT,
baseline_value TEXT, baseline_value TEXT,
apply_on_start INTEGER DEFAULT 0, apply_on_start INTEGER DEFAULT 0,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
PRIMARY KEY(user_id, profile_id, key) PRIMARY KEY(profile_id, key)
); );
CREATE INDEX IF NOT EXISTS idx_rtorrent_config_overrides_profile ON rtorrent_config_overrides(profile_id, apply_on_start); CREATE INDEX IF NOT EXISTS idx_rtorrent_config_overrides_profile ON rtorrent_config_overrides(profile_id, apply_on_start);
@@ -421,6 +429,13 @@ CREATE TABLE IF NOT EXISTS app_settings (
value TEXT value TEXT
); );
CREATE TABLE IF NOT EXISTS poller_settings (
profile_id INTEGER PRIMARY KEY,
settings_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id)
);
CREATE TABLE IF NOT EXISTS download_plan_settings ( CREATE TABLE IF NOT EXISTS download_plan_settings (
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
@@ -500,22 +515,24 @@ CREATE TABLE IF NOT EXISTS tracker_favicon_cache (
MIGRATIONS = [ MIGRATIONS = [
"ALTER TABLE api_tokens ADD COLUMN last_used_at TEXT", "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 role TEXT DEFAULT 'user'",
"ALTER TABLE users ADD COLUMN is_active INTEGER DEFAULT 1", "ALTER TABLE users ADD COLUMN is_active INTEGER DEFAULT 1",
"ALTER TABLE users ADD COLUMN updated_at TEXT", "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 mobile_mode INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN peers_refresh_seconds INTEGER DEFAULT 0", "ALTER TABLE user_preferences ADD COLUMN compact_torrent_list_enabled INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN port_check_enabled INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN bootstrap_theme TEXT DEFAULT 'default'", "ALTER TABLE user_preferences ADD COLUMN bootstrap_theme TEXT DEFAULT 'default'",
"ALTER TABLE user_preferences ADD COLUMN font_family 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 footer_items_json TEXT",
"ALTER TABLE user_preferences ADD COLUMN title_speed_enabled INTEGER DEFAULT 0", "ALTER TABLE user_preferences ADD COLUMN title_speed_enabled INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN tracker_favicons_enabled INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN reverse_dns_enabled INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN interface_scale INTEGER DEFAULT 100", "ALTER TABLE user_preferences ADD COLUMN interface_scale INTEGER DEFAULT 100",
"ALTER TABLE user_preferences ADD COLUMN detail_panel_height INTEGER DEFAULT 255", "ALTER TABLE user_preferences ADD COLUMN detail_panel_height INTEGER DEFAULT 255",
"ALTER TABLE user_preferences ADD COLUMN torrent_sort_json TEXT", "ALTER TABLE user_preferences ADD COLUMN easter_egg_enabled INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN active_filter TEXT DEFAULT 'all'", "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 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_parallel_jobs INTEGER DEFAULT 4",
"ALTER TABLE rtorrent_profiles ADD COLUMN light_job_timeout_seconds INTEGER DEFAULT 300", "ALTER TABLE rtorrent_profiles ADD COLUMN light_job_timeout_seconds INTEGER DEFAULT 300",
@@ -550,11 +567,6 @@ MIGRATIONS = [
"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)", "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 automation_toasts_enabled INTEGER DEFAULT 1",
"ALTER TABLE user_preferences ADD COLUMN smart_queue_toasts_enabled INTEGER DEFAULT 1", "ALTER TABLE user_preferences ADD COLUMN smart_queue_toasts_enabled INTEGER DEFAULT 1",
"ALTER TABLE user_preferences ADD COLUMN disk_monitor_paths_json TEXT",
"ALTER TABLE user_preferences ADD COLUMN disk_monitor_mode TEXT DEFAULT 'default'",
"ALTER TABLE user_preferences ADD COLUMN disk_monitor_selected_path TEXT",
"ALTER TABLE user_preferences ADD COLUMN disk_monitor_stop_enabled INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN disk_monitor_stop_threshold INTEGER DEFAULT 98",
"ALTER TABLE smart_queue_settings ADD COLUMN cooldown_minutes INTEGER DEFAULT 10", "ALTER TABLE smart_queue_settings ADD COLUMN cooldown_minutes INTEGER DEFAULT 10",
"ALTER TABLE smart_queue_settings ADD COLUMN last_run_at TEXT", "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_enabled INTEGER DEFAULT 1",
@@ -584,7 +596,7 @@ MIGRATIONS = [
"ALTER TABLE automation_history ADD COLUMN rule_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 actions_json TEXT",
"ALTER TABLE automation_history ADD COLUMN torrent_hash TEXT", "ALTER TABLE automation_history ADD COLUMN torrent_hash TEXT",
"CREATE TABLE IF NOT EXISTS rss_history (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, profile_id INTEGER, feed_id INTEGER, rule_id INTEGER, title TEXT, link TEXT, status TEXT NOT NULL, message TEXT, created_at TEXT NOT NULL)", "CREATE 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 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 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_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))",
@@ -597,15 +609,15 @@ MIGRATIONS = [
"CREATE INDEX IF NOT EXISTS idx_download_plan_paused_profile ON download_plan_paused(profile_id, updated_at)", "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_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 INDEX IF NOT EXISTS idx_rss_feeds_user_profile_enabled_next ON rss_feeds(user_id, profile_id, enabled, next_check_at)", "CREATE INDEX IF NOT EXISTS idx_rss_feeds_profile_enabled_next ON rss_feeds(profile_id, enabled, next_check_at)",
"CREATE INDEX IF NOT EXISTS idx_rss_rules_user_profile_enabled ON rss_rules(user_id, profile_id, enabled)", "CREATE INDEX IF NOT EXISTS idx_rss_rules_profile_enabled ON rss_rules(profile_id, enabled)",
"CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_created ON rss_history(user_id, profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_rss_history_profile_status ON rss_history(profile_id, status)",
"CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_status ON rss_history(user_id, profile_id, status)", "CREATE INDEX IF NOT EXISTS idx_rss_history_profile_status ON rss_history(profile_id, status)",
"CREATE 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_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_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_smart_queue_exclusions_user_profile_created ON smart_queue_exclusions(user_id, profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_profile_created ON smart_queue_exclusions(profile_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_smart_queue_history_user_profile_created ON smart_queue_history(user_id, profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_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_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_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_user_preferences_user ON user_preferences(user_id)",
@@ -614,6 +626,10 @@ MIGRATIONS = [
"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_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_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 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))", "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))",
] ]
@@ -629,6 +645,81 @@ POST_MIGRATION_INDEXES = [
"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_user_profile_created ON operation_logs(user_id, profile_id, created_at)",
] ]
PROFILE_ONLY_TABLES = {
"rss_feeds": {
"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",
"copy": ["id", "profile_id", "name", "url", "enabled", "interval_minutes", "last_error", "last_checked_at", "next_check_at", "created_at", "updated_at"],
"indexes": ["CREATE INDEX IF NOT EXISTS idx_rss_feeds_profile_enabled_next ON rss_feeds(profile_id, enabled, next_check_at)"],
},
"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",
"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"],
"indexes": ["CREATE INDEX IF NOT EXISTS idx_rss_rules_profile_enabled ON rss_rules(profile_id, enabled)"],
},
"rss_history": {
"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",
"copy": ["id", "profile_id", "feed_id", "rule_id", "title", "link", "status", "message", "created_at"],
"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')"],
},
"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, stop_batch_size INTEGER DEFAULT 50, start_grace_seconds INTEGER DEFAULT 900, protect_active_below_cap 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", "stop_batch_size", "start_grace_seconds", "protect_active_below_cap", "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")
@@ -669,6 +760,7 @@ def init_db():
conn.execute(sql) conn.execute(sql)
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass pass
_normalize_profile_only_tables(conn)
now = utcnow() now = utcnow()
conn.execute( conn.execute(
"INSERT OR IGNORE INTO users(id, username, password_hash, role, is_active, created_at, updated_at) VALUES(1, 'default', NULL, 'admin', 1, ?, ?)", "INSERT OR IGNORE INTO users(id, username, password_hash, role, is_active, created_at, updated_at) VALUES(1, 'default', NULL, 'admin', 1, ?, ?)",

View File

@@ -2111,6 +2111,28 @@
"type": "object" "type": "object"
} }
] ]
},
"TemporaryLinkResponse": {
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"example": true
},
"url": {
"type": "string",
"example": "/download/r4nd0mTemporaryToken"
},
"expires_in": {
"type": "integer",
"example": 600
}
},
"required": [
"ok",
"url",
"expires_in"
]
} }
}, },
"securitySchemes": { "securitySchemes": {
@@ -7101,6 +7123,362 @@
}, },
"summary": "Traffic history" "summary": "Traffic history"
} }
},
"/preview/pdf/{token}": {
"get": {
"summary": "Open temporary PDF preview",
"description": "Streams a PDF through an in-app temporary preview URL created by the API. The browser-visible URL does not expose the stable /api download route.",
"parameters": [
{
"in": "path",
"name": "token",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "PDF stream",
"content": {
"application/pdf": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"403": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/download/{token}": {
"get": {
"summary": "Open temporary download link",
"description": "Resolves a short-lived in-app download token created by an API endpoint and streams the requested file or ZIP.",
"parameters": [
{
"in": "path",
"name": "token",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "File stream",
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
},
"application/zip": {
"schema": {
"type": "string",
"format": "binary"
}
},
"application/x-bittorrent": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"403": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/torrents/{torrent_hash}/files/{file_index}/download-link": {
"post": {
"summary": "Create temporary torrent file download link",
"description": "Validates the selected torrent file through the API and returns a short-lived /download URL for the UI.",
"parameters": [
{
"in": "path",
"name": "torrent_hash",
"required": true,
"schema": {
"type": "string"
}
},
{
"in": "path",
"name": "file_index",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TemporaryLinkResponse"
}
}
}
},
"400": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/torrents/{torrent_hash}/files/download-link": {
"post": {
"summary": "Create temporary torrent file download link from body",
"description": "Body-based alias that validates a selected torrent file and returns a short-lived /download URL.",
"parameters": [
{
"in": "path",
"name": "torrent_hash",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"file_index": {
"type": "integer"
}
},
"required": [
"file_index"
]
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TemporaryLinkResponse"
}
}
}
},
"400": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/torrents/{torrent_hash}/files/download.zip/link": {
"post": {
"summary": "Create temporary torrent files ZIP download link",
"description": "Validates selected torrent files and returns a short-lived /download URL for a ZIP archive. If indexes is omitted or null, all files are included.",
"parameters": [
{
"in": "path",
"name": "torrent_hash",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"indexes": {
"type": "array",
"items": {
"type": "integer"
},
"nullable": true
}
}
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TemporaryLinkResponse"
}
}
}
},
"400": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/torrents/{torrent_hash}/torrent-file/link": {
"get": {
"summary": "Create temporary .torrent export download link",
"description": "Validates .torrent export availability and returns a short-lived /download URL for the UI.",
"parameters": [
{
"in": "path",
"name": "torrent_hash",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TemporaryLinkResponse"
}
}
}
},
"400": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/torrents/torrent-files.zip/link": {
"post": {
"summary": "Create temporary .torrent files ZIP download link",
"description": "Validates selected torrents and returns a short-lived /download URL for a ZIP of exported .torrent files.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"hashes": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"hashes"
]
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TemporaryLinkResponse"
}
}
}
},
"400": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
} }
} }
} }

View File

@@ -18,11 +18,12 @@ import queue
import threading 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 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
from ..services import preferences, rtorrent, torrent_stats, speed_peaks, tracker_cache, rss as rss_service, ratio_rules, backup as backup_service, download_planner, operation_logs from ..services 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.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
@@ -289,6 +290,7 @@ def cleanup_summary() -> dict:
(profile_id,), (profile_id,),
) if profile_id else _table_count("operation_logs") ) if profile_id else _table_count("operation_logs")
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 {}
return { return {
"jobs_total": _table_count("jobs"), "jobs_total": _table_count("jobs"),
"jobs_clearable": _table_count("jobs", "WHERE status NOT IN ('pending', 'running')"), "jobs_clearable": _table_count("jobs", "WHERE status NOT IN ('pending', 'running')"),
@@ -297,6 +299,7 @@ def cleanup_summary() -> dict:
"automation_history_total": _table_count("automation_history"), "automation_history_total": _table_count("automation_history"),
"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": _active_profile_cache_summary(profile_id if profile_id else None),
"poller_runtime": poller_runtime,
"retention_days": { "retention_days": {
"jobs": JOBS_RETENTION_DAYS, "jobs": JOBS_RETENTION_DAYS,
"smart_queue_history": SMART_QUEUE_HISTORY_RETENTION_DAYS, "smart_queue_history": SMART_QUEUE_HISTORY_RETENTION_DAYS,

View File

@@ -2,7 +2,7 @@ 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, list_api_tokens, create_api_token, revoke_api_token from ..services.auth import current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, provider as auth_provider, uses_external_provider, external_auth_summary, list_api_tokens, create_api_token, revoke_api_token
def _ok(payload=None): def _ok(payload=None):
@@ -21,18 +21,20 @@ def register_auth_routes(bp):
user = login_user(str(data.get("username") or ""), str(data.get("password") or "")) user = login_user(str(data.get("username") or ""), str(data.get("password") or ""))
if not user: if not user:
return jsonify({"ok": False, "error": "Invalid username or password"}), 401 return jsonify({"ok": False, "error": "Invalid username or password"}), 401
return _ok({"user": user, "auth_enabled": auth_enabled()}) return _ok({"user": user, "auth_enabled": auth_enabled(), "auth_provider": auth_provider()})
@bp.get("/auth/me") @bp.get("/auth/me")
def auth_me(): def auth_me():
if not auth_enabled(): if not auth_enabled():
abort(404) abort(404)
return _ok({"user": current_user(), "auth_enabled": auth_enabled()}) return _ok({"user": current_user(), "auth_enabled": auth_enabled(), "auth_provider": auth_provider()})
@bp.post("/auth/logout") @bp.post("/auth/logout")
def auth_logout(): def auth_logout():
if not auth_enabled(): if not auth_enabled():
abort(404) abort(404)
if uses_external_provider():
return _ok({"logout_managed_by_provider": True, "auth_provider": auth_provider()})
logout_user() logout_user()
return _ok() return _ok()
@@ -40,7 +42,7 @@ def register_auth_routes(bp):
def auth_users_list(): def auth_users_list():
if not auth_enabled(): if not auth_enabled():
abort(404) abort(404)
return _ok({"users": list_users()}) return _ok({"users": list_users(), "auth": external_auth_summary()})
@bp.post("/auth/users") @bp.post("/auth/users")
def auth_users_create(): def auth_users_create():

View File

@@ -1,31 +1,96 @@
from __future__ import annotations from __future__ import annotations
from ._shared import * from ._shared import *
from ..services import auth
def _active_profile_id() -> int | None:
profile = preferences.active_profile()
return int(profile["id"]) if profile else None
@bp.get("/backup") @bp.get("/backup")
def backup_list(): def backup_list():
return ok({"backups": backup_service.list_backups(default_user_id()), "auto": backup_service.get_auto_backup_settings(default_user_id())}) uid = default_user_id()
pid = _active_profile_id()
can_app = auth.is_admin()
return ok({
"profile_backups": backup_service.list_backups(uid, "profile", pid) if pid else [],
"app_backups": backup_service.list_backups(uid, "app") if can_app else [],
"profile_auto": backup_service.get_auto_backup_settings(uid, "profile", pid) if pid else None,
"app_auto": backup_service.get_auto_backup_settings(uid, "app") if can_app else None,
"auto": backup_service.get_auto_backup_settings(uid, "app") if can_app else None,
"can_app_backup": can_app,
})
@bp.post("/backup/profile")
def backup_create_profile():
data = request.get_json(silent=True) or {}
pid = _active_profile_id()
if not pid:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
return ok({
"backup": backup_service.create_profile_backup(str(data.get("name") or "Profile backup"), pid, default_user_id()),
"profile_backups": backup_service.list_backups(default_user_id(), "profile", pid),
})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.post("/backup/app")
def backup_create_app():
data = request.get_json(silent=True) or {}
try:
return ok({
"backup": backup_service.create_app_backup(str(data.get("name") or "Application backup"), default_user_id()),
"app_backups": backup_service.list_backups(default_user_id(), "app"),
})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400
@bp.post("/backup") @bp.post("/backup")
def backup_create(): def backup_create():
data = request.get_json(silent=True) or {} # Note: Legacy endpoint now creates a profile backup so non-admin users cannot capture other users' settings.
return ok({"backup": backup_service.create_backup(str(data.get("name") or "Manual backup"), default_user_id()), "backups": backup_service.list_backups(default_user_id())}) return backup_create_profile()
@bp.get("/backup/settings") @bp.get("/backup/settings")
def backup_settings_get(): def backup_settings_get():
return ok({"settings": backup_service.get_auto_backup_settings(default_user_id())}) if not auth.is_admin():
return jsonify({"ok": False, "error": "Application backup settings are admin-only"}), 403
return ok({"settings": backup_service.get_auto_backup_settings(default_user_id(), "app")})
@bp.post("/backup/settings") @bp.post("/backup/settings")
def backup_settings_save(): def backup_settings_save():
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
try: try:
return ok({"settings": backup_service.save_auto_backup_settings(data, default_user_id())}) return ok({"settings": backup_service.save_auto_backup_settings(data, default_user_id(), "app")})
except Exception as exc: except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400 return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400
@bp.get("/backup/profile/settings")
def profile_backup_settings_get():
pid = _active_profile_id()
if not pid:
return jsonify({"ok": False, "error": "No profile"}), 400
return ok({"settings": backup_service.get_auto_backup_settings(default_user_id(), "profile", pid)})
@bp.post("/backup/profile/settings")
def profile_backup_settings_save():
data = request.get_json(silent=True) or {}
pid = _active_profile_id()
if not pid:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
return ok({"settings": backup_service.save_auto_backup_settings(data, default_user_id(), "profile", pid)})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400
@bp.get("/backup/<int:backup_id>/preview") @bp.get("/backup/<int:backup_id>/preview")
@@ -36,14 +101,13 @@ def backup_preview(backup_id: int):
return jsonify({"ok": False, "error": str(exc)}), 400 return jsonify({"ok": False, "error": str(exc)}), 400
@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:
return ok({"result": backup_service.restore_backup(backup_id, default_user_id())}) pid = _active_profile_id()
return ok({"result": backup_service.restore_backup(backup_id, default_user_id(), profile_id=pid)})
except Exception as exc: except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400 return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400
@bp.delete("/backup/<int:backup_id>") @bp.delete("/backup/<int:backup_id>")
@@ -54,7 +118,6 @@ def backup_delete(backup_id: int):
return jsonify({"ok": False, "error": str(exc)}), 400 return jsonify({"ok": False, "error": str(exc)}), 400
@bp.get("/backup/<int:backup_id>/download") @bp.get("/backup/<int:backup_id>/download")
def backup_download(backup_id: int): def backup_download(backup_id: int):
try: try:
@@ -62,8 +125,6 @@ def backup_download(backup_id: int):
tmp = tempfile.NamedTemporaryFile(prefix="pytorrent-backup-", suffix=".json", delete=False, mode="w", encoding="utf-8") tmp = tempfile.NamedTemporaryFile(prefix="pytorrent-backup-", suffix=".json", delete=False, mode="w", encoding="utf-8")
json.dump(payload, tmp, ensure_ascii=False, indent=2) json.dump(payload, tmp, ensure_ascii=False, indent=2)
tmp.close() tmp.close()
return send_file(tmp.name, as_attachment=True, download_name=f"pytorrent-backup-{backup_id}.json") return send_file(tmp.name, as_attachment=True, download_name=f"pytorrent-{payload.get('backup_type') or 'backup'}-{backup_id}.json")
except Exception as exc: except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400 return jsonify({"ok": False, "error": str(exc)}), 400

View File

@@ -1,10 +1,16 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from urllib.parse import quote
import queue
import tempfile
import threading
import zipfile
from flask import Blueprint, render_template, Response, request, redirect, url_for, abort, send_file from 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, BOOTSTRAP_THEMES, FONT_FAMILIES from ..services.preferences import get_preferences, list_profiles, active_profile, get_profile, BOOTSTRAP_THEMES, FONT_FAMILIES
from ..services import auth from ..services import auth, pdf_preview_links, rtorrent
from ..config import PYTORRENT_TMP_DIR
from ..services.frontend_assets import asset_path from ..services.frontend_assets import asset_path
# for favicon # for favicon
@@ -18,6 +24,141 @@ 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:
safe = Path(download_name or "download.bin").name or "download.bin"
safe_disposition = "inline" if disposition == "inline" else "attachment"
return {
"Content-Type": content_type,
"Content-Disposition": f"{safe_disposition}; filename*=UTF-8''{quote(safe)}",
"X-Content-Type-Options": "nosniff",
}
def _cleanup_staged_file(profile: dict, path: str, local: bool = False) -> None:
if local:
try:
Path(path).unlink()
except Exception:
pass
return
rtorrent._remote_remove_staged(profile, path)
try:
tmp_prefix = str(PYTORRENT_TMP_DIR).rstrip("/") + "/pytorrent-download-"
if str(path).startswith(tmp_prefix) and Path(path).exists():
Path(path).unlink()
except Exception:
pass
def _read_staged_file(profile: dict, path: str, local: bool = False) -> bytes:
if local:
return Path(path).read_bytes()
return b"".join(bytes(chunk) for chunk in rtorrent.iter_remote_file_chunks(profile, path) if chunk)
def _safe_zip_name(name: str, fallback: str) -> str:
value = str(name or fallback).replace("\\", "/").lstrip("/")
parts = [part for part in value.split("/") if part not in ("", ".", "..")]
return "/".join(parts) or fallback
class _ZipStream:
def __init__(self):
self.queue: queue.Queue[bytes | None] = queue.Queue(maxsize=16)
self.closed = False
def write(self, data):
if not data:
return 0
payload = bytes(data)
self.queue.put(payload)
return len(payload)
def flush(self):
return None
def close(self):
if not self.closed:
self.closed = True
self.queue.put(None)
def writable(self):
return True
def _stream_torrent_files_zip(profile: dict, items: list[dict]):
writer = _ZipStream()
errors: list[BaseException] = []
def produce():
try:
with zipfile.ZipFile(writer, "w", compression=zipfile.ZIP_STORED, allowZip64=True) as archive:
used = set()
for item in items:
arcname = _safe_zip_name(str(item.get("path") or ""), f"file-{item.get('index', 0)}")
base = arcname
counter = 2
while arcname in used:
stem = Path(base).stem or "file"
suffix = Path(base).suffix
parent = str(Path(base).parent).replace(".", "", 1).strip("/")
candidate = f"{stem}-{counter}{suffix}"
arcname = f"{parent}/{candidate}" if parent else candidate
counter += 1
used.add(arcname)
info = zipfile.ZipInfo(arcname)
info.compress_type = zipfile.ZIP_STORED
info.file_size = int(item.get("size") or 0)
with archive.open(info, "w", force_zip64=True) as dest:
for chunk in rtorrent.iter_remote_file_chunks(profile, item["remote_path"], size=int(item.get("size") or 0) or None):
dest.write(chunk)
except BaseException as exc:
errors.append(exc)
finally:
writer.close()
threading.Thread(target=produce, name="pytorrent-token-zip-stream", daemon=True).start()
while True:
chunk = writer.queue.get()
if chunk is None:
break
yield chunk
if errors:
raise errors[0]
def _send_staged_torrent_file(profile: dict, path: str, download_name: str, local: bool = False):
headers = _attachment_headers(download_name, "application/x-bittorrent")
if local:
data = Path(path).read_bytes()
_cleanup_staged_file(profile, path, local=True)
headers["Content-Length"] = str(len(data))
return Response(data, headers=headers)
def generate():
try:
yield from rtorrent.iter_remote_file_chunks(profile, path)
finally:
_cleanup_staged_file(profile, path, local=False)
return Response(stream_with_context(generate()), headers=headers, direct_passthrough=True)
def _profile_for_temporary_target(target: dict):
profile_id = int(target.get("profile_id") or 0)
owner_user_id = int(target.get("user_id") or 0)
if auth.enabled() and owner_user_id != auth.current_user_id():
abort(403)
if not auth.can_access_profile(profile_id):
abort(403)
profile = active_profile() if not profile_id else get_profile(profile_id)
if not profile:
abort(404)
return profile
@bp.get("/favicon.ico") @bp.get("/favicon.ico")
def favicon_ico(): def favicon_ico():
response = send_from_directory( response = send_from_directory(
@@ -33,17 +174,30 @@ def login():
# Note: When optional authentication is disabled, /login is intentionally unavailable. # Note: When optional authentication is disabled, /login is intentionally unavailable.
if not auth.enabled(): if not auth.enabled():
abort(404) abort(404)
next_url = request.args.get("next") or url_for("main.index")
if auth.uses_external_provider():
user = auth.authenticate_external_user()
if user:
return redirect(next_url)
return render_template(
"login.html",
error="External authentication headers were not accepted by pyTorrent.",
external_provider=auth.provider(),
), 401
error = "" error = ""
if request.method == "POST": if request.method == "POST":
user = auth.login_user(request.form.get("username", ""), request.form.get("password", "")) user = auth.login_user(request.form.get("username", ""), request.form.get("password", ""))
if user: if user:
return redirect(request.args.get("next") or url_for("main.index")) return redirect(next_url)
error = "Invalid username or password" error = "Invalid username or password"
return render_template("login.html", error=error) return render_template("login.html", error=error, external_provider=None)
@bp.get("/logout") @bp.get("/logout")
def logout(): def logout():
# Note: External providers such as Tinyauth own the login session, so pyTorrent must not pretend to log the user out locally.
if auth.uses_external_provider():
return redirect(url_for("main.index"))
auth.logout_user() auth.logout_user()
if not auth.enabled(): if not auth.enabled():
return redirect(url_for("main.index")) return redirect(url_for("main.index"))
@@ -61,10 +215,126 @@ def index():
bootstrap_themes=BOOTSTRAP_THEMES, bootstrap_themes=BOOTSTRAP_THEMES,
font_families=FONT_FAMILIES, font_families=FONT_FAMILIES,
auth_enabled=auth.enabled(), auth_enabled=auth.enabled(),
auth_provider=auth.provider(),
external_auth=auth.uses_external_provider(),
current_user=auth.current_user(), current_user=auth.current_user(),
) )
@bp.get("/preview/pdf/<token>")
def pdf_preview(token: str):
# Note: This route keeps browser-visible PDF links inside the app and delegates streaming to the existing rTorrent file reader.
target = pdf_preview_links.get_pdf_preview_link(token)
if not target:
abort(404)
profile_id = int(target.get("profile_id") or 0)
owner_user_id = int(target.get("user_id") or 0)
if auth.enabled() and owner_user_id != auth.current_user_id():
abort(403)
if not auth.can_access_profile(profile_id):
abort(403)
profile = active_profile() if not profile_id else get_profile(profile_id)
if not profile:
abort(404)
item = rtorrent.torrent_download_file_info(profile, target["torrent_hash"], int(target["file_index"]))
filename = Path(item.get("download_name") or "preview.pdf").name or "preview.pdf"
if Path(filename).suffix.lower() != ".pdf":
abort(404)
size = int(item.get("size") or 0)
headers = {
"Content-Disposition": f"inline; filename*=UTF-8''{quote(filename)}",
"Content-Type": "application/pdf",
"X-Content-Type-Options": "nosniff",
}
if size > 0:
headers["Content-Length"] = str(size)
def generate():
yield from rtorrent.iter_remote_file_chunks(profile, item["remote_path"], size=size or None)
return Response(stream_with_context(generate()), headers=headers, direct_passthrough=True)
@bp.get("/download/<token>")
def temporary_download(token: str):
# Note: UI download actions resolve API-created temporary tokens here, keeping browser-visible URLs outside /api/.
target = pdf_preview_links.get_temporary_link(token)
if not target:
abort(404)
profile = _profile_for_temporary_target(target)
kind = str(target.get("kind") or "")
if kind == "file_download":
item = rtorrent.torrent_download_file_info(profile, target["torrent_hash"], int(target["file_index"]))
size = int(item.get("size") or 0)
headers = _attachment_headers(item.get("download_name") or "file.bin")
if size > 0:
headers["Content-Length"] = str(size)
def generate_file():
yield from rtorrent.iter_remote_file_chunks(profile, item["remote_path"], size=size or None)
return Response(stream_with_context(generate_file()), headers=headers, direct_passthrough=True)
if kind == "file_zip_download":
items = rtorrent.torrent_download_zip_items(profile, target["torrent_hash"], target.get("indexes"))
headers = _attachment_headers(f"{str(target['torrent_hash'])[:12]}-files.zip", "application/zip")
headers["X-PyTorrent-Download-Mode"] = "temporary-token"
return Response(stream_with_context(_stream_torrent_files_zip(profile, items)), headers=headers, direct_passthrough=True)
if kind == "torrent_file_download":
item = rtorrent.export_torrent_file(profile, target["torrent_hash"])
return _send_staged_torrent_file(profile, item["path"], item["download_name"], bool(item.get("local")))
if kind == "torrent_files_zip_download":
hashes = [str(item) for item in (target.get("hashes") or []) if str(item).strip()]
if not hashes:
abort(404)
staged_paths = []
PYTORRENT_TMP_DIR.mkdir(parents=True, exist_ok=True)
tmp = tempfile.NamedTemporaryFile(prefix="pytorrent-torrents-", suffix=".zip", delete=False, dir=str(PYTORRENT_TMP_DIR))
tmp.close()
try:
with zipfile.ZipFile(tmp.name, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
used_names = set()
for torrent_hash in hashes:
item = rtorrent.export_torrent_file(profile, torrent_hash)
staged_paths.append((item["path"], bool(item.get("local"))))
name = Path(item["download_name"]).name or f"{torrent_hash}.torrent"
base_name = name
counter = 2
while name in used_names:
stem = Path(base_name).stem
name = f"{stem}-{counter}.torrent"
counter += 1
used_names.add(name)
archive.writestr(name, _read_staged_file(profile, item["path"], bool(item.get("local"))))
response = send_file(tmp.name, as_attachment=True, download_name="pytorrent-torrents.zip")
def cleanup():
for path, is_local in staged_paths:
_cleanup_staged_file(profile, path, is_local)
try:
Path(tmp.name).unlink()
except Exception:
pass
response.call_on_close(cleanup)
return response
except Exception:
for path, is_local in staged_paths:
_cleanup_staged_file(profile, path, is_local)
try:
Path(tmp.name).unlink()
except Exception:
pass
raise
abort(404)
@bp.get("/docs") @bp.get("/docs")
def docs(): def docs():
html = f"""<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>pyTorrent API Docs</title><link rel="stylesheet" href="{_asset_url('swagger_css')}"></head><body><div id="swagger-ui"></div><script src="{_asset_url('swagger_js')}"></script><script>window.onload=()=>SwaggerUIBundle({{url:'/api/openapi.json',dom_id:'#swagger-ui',deepLinking:true,persistAuthorization:true}});</script></body></html>""" html = f"""<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>pyTorrent API Docs</title><link rel="stylesheet" href="{_asset_url('swagger_css')}"></head><body><div id="swagger-ui"></div><script src="{_asset_url('swagger_js')}"></script><script>window.onload=()=>SwaggerUIBundle({{url:'/api/openapi.json',dom_id:'#swagger-ui',deepLinking:true,persistAuthorization:true}});</script></body></html>"""

View File

@@ -2,65 +2,109 @@ from __future__ import annotations
from ._shared import * from ._shared import *
def _active_profile_or_400():
profile = preferences.active_profile()
if not profile:
return None
return profile
@bp.get("/rss") @bp.get("/rss")
def rss_list(): def rss_list():
profile = preferences.active_profile() profile = _active_profile_or_400()
pid = profile["id"] if profile else None if not profile:
return ok({"feeds": [], "rules": [], "history": []})
pid = int(profile["id"])
with connect() as conn: with connect() as conn:
feeds = conn.execute("SELECT * FROM rss_feeds WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY name", (default_user_id(), pid)).fetchall() feeds = conn.execute("SELECT * FROM rss_feeds WHERE profile_id=? ORDER BY name", (pid,)).fetchall()
rules = conn.execute("SELECT * FROM rss_rules WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY name", (default_user_id(), pid)).fetchall() rules = conn.execute("SELECT * FROM rss_rules WHERE profile_id=? ORDER BY name", (pid,)).fetchall()
history = conn.execute("SELECT * FROM rss_history WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY id DESC LIMIT 80", (default_user_id(), pid)).fetchall() history = conn.execute("SELECT * FROM rss_history WHERE profile_id=? ORDER BY id DESC LIMIT 80", (pid,)).fetchall()
return ok({"feeds": feeds, "rules": rules, "history": history}) return ok({"feeds": feeds, "rules": rules, "history": history})
@bp.post("/rss/feeds") @bp.post("/rss/feeds")
def rss_feed_save(): def rss_feed_save():
profile = preferences.active_profile() profile = _active_profile_or_400()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
now = utcnow() now = utcnow()
feed_id = data.get("id") feed_id = data.get("id")
pid = int(profile["id"])
with connect() as conn: with connect() as conn:
if feed_id: if feed_id:
conn.execute("UPDATE rss_feeds SET name=?,url=?,enabled=?,interval_minutes=?,updated_at=? WHERE id=? AND user_id=?", (data.get("name") or "RSS", data.get("url") or "", 1 if data.get("enabled", True) else 0, int(data.get("interval_minutes") or 30), now, feed_id, default_user_id())) conn.execute(
"UPDATE rss_feeds SET name=?,url=?,enabled=?,interval_minutes=?,updated_at=? WHERE id=? AND profile_id=?",
(data.get("name") or "RSS", data.get("url") or "", 1 if data.get("enabled", True) else 0, int(data.get("interval_minutes") or 30), now, feed_id, pid),
)
else: else:
conn.execute("INSERT INTO rss_feeds(user_id,profile_id,name,url,enabled,interval_minutes,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)", (default_user_id(), profile["id"] if profile else None, data.get("name") or "RSS", data.get("url") or "", 1 if data.get("enabled", True) else 0, int(data.get("interval_minutes") or 30), now, now)) conn.execute(
"INSERT INTO rss_feeds(profile_id,name,url,enabled,interval_minutes,created_at,updated_at) VALUES(?,?,?,?,?,?,?)",
(pid, data.get("name") or "RSS", data.get("url") or "", 1 if data.get("enabled", True) else 0, int(data.get("interval_minutes") or 30), now, now),
)
return rss_list() return rss_list()
@bp.delete("/rss/feeds/<int:feed_id>") @bp.delete("/rss/feeds/<int:feed_id>")
def rss_feed_delete(feed_id: int): def rss_feed_delete(feed_id: int):
profile = _active_profile_or_400()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
with connect() as conn: with connect() as conn:
conn.execute("DELETE FROM rss_feeds WHERE id=? AND user_id=?", (feed_id, default_user_id())) conn.execute("DELETE FROM rss_feeds WHERE id=? AND profile_id=?", (feed_id, int(profile["id"])))
return rss_list() return rss_list()
@bp.post("/rss/rules") @bp.post("/rss/rules")
def rss_rule_save(): def rss_rule_save():
profile = preferences.active_profile() profile = _active_profile_or_400()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
now = utcnow() now = utcnow()
rule_id = data.get("id") rule_id = data.get("id")
values = (data.get("name") or "Rule", data.get("pattern") or ".*", data.get("exclude_pattern") or "", int(data.get("min_size_mb") or 0), int(data.get("max_size_mb") or 0), data.get("category") or "", data.get("quality") or "", data.get("season") or None, data.get("episode") or None, data.get("save_path") or active_default_download_path(profile), data.get("label") or "", 1 if data.get("start", True) else 0, 1 if data.get("enabled", True) else 0, now) pid = int(profile["id"])
values = (
data.get("name") or "Rule",
data.get("pattern") or ".*",
data.get("exclude_pattern") or "",
int(data.get("min_size_mb") or 0),
int(data.get("max_size_mb") or 0),
data.get("category") or "",
data.get("quality") or "",
data.get("season") or None,
data.get("episode") or None,
data.get("save_path") or active_default_download_path(profile),
data.get("label") or "",
1 if data.get("start", True) else 0,
1 if data.get("enabled", True) else 0,
now,
)
with connect() as conn: with connect() as conn:
if rule_id: if rule_id:
conn.execute("UPDATE rss_rules SET name=?,pattern=?,exclude_pattern=?,min_size_mb=?,max_size_mb=?,category=?,quality=?,season=?,episode=?,save_path=?,label=?,start=?,enabled=?,updated_at=? WHERE id=? AND user_id=?", (*values, rule_id, default_user_id())) conn.execute(
"UPDATE rss_rules SET name=?,pattern=?,exclude_pattern=?,min_size_mb=?,max_size_mb=?,category=?,quality=?,season=?,episode=?,save_path=?,label=?,start=?,enabled=?,updated_at=? WHERE id=? AND profile_id=?",
(*values, rule_id, pid),
)
else: else:
conn.execute("INSERT INTO rss_rules(user_id,profile_id,name,pattern,exclude_pattern,min_size_mb,max_size_mb,category,quality,season,episode,save_path,label,start,enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (default_user_id(), profile["id"] if profile else None, *values, now)) conn.execute(
"INSERT INTO rss_rules(profile_id,name,pattern,exclude_pattern,min_size_mb,max_size_mb,category,quality,season,episode,save_path,label,start,enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(pid, *values, now),
)
return rss_list() return rss_list()
@bp.delete("/rss/rules/<int:rule_id>") @bp.delete("/rss/rules/<int:rule_id>")
def rss_rule_delete(rule_id: int): def rss_rule_delete(rule_id: int):
profile = _active_profile_or_400()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
with connect() as conn: with connect() as conn:
conn.execute("DELETE FROM rss_rules WHERE id=? AND user_id=?", (rule_id, default_user_id())) conn.execute("DELETE FROM rss_rules WHERE id=? AND profile_id=?", (rule_id, int(profile["id"])))
return rss_list() return rss_list()
@bp.post("/rss/rules/test") @bp.post("/rss/rules/test")
def rss_rule_test(): def rss_rule_test():
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
@@ -71,12 +115,9 @@ def rss_rule_test():
return jsonify({"ok": False, "error": str(exc)}), 400 return jsonify({"ok": False, "error": str(exc)}), 400
@bp.post("/rss/check") @bp.post("/rss/check")
def rss_check(): def rss_check():
profile = preferences.active_profile() profile = preferences.active_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, default_user_id(), only_due=False)) return ok(rss_service.check(profile, only_due=False))

View File

@@ -198,12 +198,17 @@ def cleanup_jobs():
@bp.post("/cleanup/smart-queue") @bp.post("/cleanup/smart-queue")
def cleanup_smart_queue(): def cleanup_smart_queue():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
profile_id = int(profile["id"])
with connect() as conn: with connect() as conn:
exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='smart_queue_history'").fetchone() exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='smart_queue_history'").fetchone()
if not exists: if not exists:
deleted = 0 deleted = 0
else: else:
cur = conn.execute("DELETE FROM smart_queue_history") # Note: Cleanup is limited to the active profile so read/write permissions never affect other profiles.
cur = conn.execute("DELETE FROM smart_queue_history WHERE profile_id=?", (profile_id,))
deleted = int(cur.rowcount or 0) deleted = int(cur.rowcount or 0)
return ok({"deleted": deleted, "cleanup": cleanup_summary()}) return ok({"deleted": deleted, "cleanup": cleanup_summary()})
@@ -232,18 +237,34 @@ def cleanup_planner():
@bp.post("/cleanup/automations") @bp.post("/cleanup/automations")
def cleanup_automations(): def cleanup_automations():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
profile_id = int(profile["id"])
with connect() as conn: with connect() as conn:
exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='automation_history'").fetchone() exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='automation_history'").fetchone()
if not exists: if not exists:
deleted = 0 deleted = 0
else: else:
# Note: Cleanup panel removes only automation logs, not saved automation rules. # Note: Cleanup panel removes only active profile automation logs, not saved automation rules.
cur = conn.execute("DELETE FROM automation_history") 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()})
@bp.post("/cleanup/poller-diagnostics")
def cleanup_poller_diagnostics():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
profile_id = int(profile["id"])
# Note: This cleanup clears only in-memory poller diagnostics; polling, settings and torrent state are preserved.
runtime = poller_control.reset_runtime_stats(profile_id)
return ok({"deleted": {"poller_runtime_counters": 1}, "runtime": runtime, "cleanup": cleanup_summary()})
@bp.post("/cleanup/all") @bp.post("/cleanup/all")
def cleanup_all(): def cleanup_all():
deleted_jobs = clear_jobs() deleted_jobs = clear_jobs()
@@ -256,13 +277,13 @@ def cleanup_all():
if not exists: if not exists:
deleted_smart = 0 deleted_smart = 0
else: else:
cur = conn.execute("DELETE FROM smart_queue_history") cur = conn.execute("DELETE FROM smart_queue_history WHERE profile_id=?", (active_profile_id,))
deleted_smart = int(cur.rowcount or 0) deleted_smart = int(cur.rowcount or 0)
exists_auto = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='automation_history'").fetchone() exists_auto = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='automation_history'").fetchone()
if not exists_auto: if not exists_auto:
deleted_auto = 0 deleted_auto = 0
else: else:
cur = conn.execute("DELETE FROM automation_history") 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()})

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from ._shared import * from ._shared import *
from ..services import 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")
@@ -98,6 +98,29 @@ def torrent_files(torrent_hash: str):
@bp.get("/torrents/<torrent_hash>/files/<int:file_index>/mediainfo")
def torrent_file_media_info(torrent_hash: str, file_index: int):
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
# Note: The route is additive and keeps all existing file endpoints unchanged.
media_info = rtorrent.torrent_file_media_info(profile, torrent_hash, file_index)
if media_info.get("kind") == "pdf":
link = pdf_preview_links.create_pdf_preview_link(
torrent_hash,
file_index,
int(profile.get("id") or 0),
int(default_user_id() or 0),
)
# Note: The frontend receives an in-app temporary URL instead of exposing the API download endpoint in the new-tab action.
media_info["preview_url"] = url_for("main.pdf_preview", token=link["token"])
media_info["preview_expires_in"] = link["expires_in"]
return ok({"media_info": media_info})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.post("/torrents/<torrent_hash>/files/priority") @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 = preferences.active_profile()
@@ -133,11 +156,12 @@ def torrent_folder_priority(torrent_hash: str):
return ok(result), status return ok(result), status
def _attachment_headers(download_name: str, content_type: str = "application/octet-stream") -> dict: def _attachment_headers(download_name: str, content_type: str = "application/octet-stream", disposition: str = "attachment") -> dict:
safe = Path(download_name or "download.bin").name or "download.bin" safe = Path(download_name or "download.bin").name or "download.bin"
safe_disposition = "inline" if disposition == "inline" else "attachment"
return { return {
"Content-Type": content_type, "Content-Type": content_type,
"Content-Disposition": f"attachment; filename*=UTF-8''{quote(safe)}", "Content-Disposition": f"{safe_disposition}; filename*=UTF-8''{quote(safe)}",
"X-Content-Type-Options": "nosniff", "X-Content-Type-Options": "nosniff",
} }
@@ -186,6 +210,77 @@ def _send_staged_file(profile: dict, path: str, download_name: str, local: bool
@bp.post("/torrents/<torrent_hash>/files/<int:file_index>/download-link")
def torrent_file_download_link(torrent_hash: str, file_index: int):
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
# Note: The API validates the file selection before returning a short-lived in-app /download URL to the UI.
rtorrent.torrent_download_file_info(profile, torrent_hash, file_index)
link = pdf_preview_links.create_file_download_link(torrent_hash, file_index, int(profile.get("id") or 0), int(default_user_id() or 0))
return ok({"url": url_for("main.temporary_download", token=link["token"]), "expires_in": link["expires_in"]})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.post("/torrents/<torrent_hash>/files/download-link")
def torrent_file_download_link_from_body(torrent_hash: str):
data = request.get_json(silent=True) or {}
try:
file_index = int(data.get("file_index"))
except Exception:
return jsonify({"ok": False, "error": "file_index is required"}), 400
return torrent_file_download_link(torrent_hash, file_index)
@bp.post("/torrents/<torrent_hash>/files/download.zip/link")
def torrent_files_download_zip_link(torrent_hash: str):
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {}
try:
indexes = data.get("indexes") or None
# Note: ZIP link creation validates the requested files through the same service used by the direct download endpoint.
rtorrent.torrent_download_zip_items(profile, torrent_hash, indexes)
link = pdf_preview_links.create_file_zip_download_link(torrent_hash, indexes, int(profile.get("id") or 0), int(default_user_id() or 0))
return ok({"url": url_for("main.temporary_download", token=link["token"]), "expires_in": link["expires_in"]})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.get("/torrents/<torrent_hash>/torrent-file/link")
def torrent_file_export_link(torrent_hash: str):
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
# Note: Create only a short-lived link here; the actual .torrent export runs once when the browser opens /download/<token>.
link = pdf_preview_links.create_torrent_file_download_link(torrent_hash, int(profile.get("id") or 0), int(default_user_id() or 0))
return ok({"url": url_for("main.temporary_download", token=link["token"]), "expires_in": link["expires_in"]})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.post("/torrents/torrent-files.zip/link")
def torrent_files_export_zip_link():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {}
hashes = [str(h) for h in (data.get("hashes") or []) if str(h).strip()]
if not hashes:
return jsonify({"ok": False, "error": "No torrents selected"}), 400
try:
# Note: Store only the selected hashes in the temporary token; exporting each .torrent now happens once during the real ZIP download.
link = pdf_preview_links.create_torrent_files_zip_download_link(hashes, int(profile.get("id") or 0), int(default_user_id() or 0))
return ok({"url": url_for("main.temporary_download", token=link["token"]), "expires_in": link["expires_in"]})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.get("/torrents/<torrent_hash>/files/<int:file_index>/download") @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 = preferences.active_profile()
@@ -194,7 +289,10 @@ def torrent_file_download(torrent_hash: str, file_index: int):
try: try:
item = rtorrent.torrent_download_file_info(profile, torrent_hash, file_index) item = rtorrent.torrent_download_file_info(profile, torrent_hash, file_index)
size = int(item.get("size") or 0) size = int(item.get("size") or 0)
headers = _attachment_headers(item.get("download_name") or "file.bin") download_name = item.get("download_name") or "file.bin"
inline_pdf = str(request.args.get("disposition") or "").lower() == "inline" and Path(download_name).suffix.lower() == ".pdf"
# Note: Inline mode is limited to PDFs so the existing download behavior remains unchanged for every other file type.
headers = _attachment_headers(download_name, "application/pdf" if inline_pdf else "application/octet-stream", "inline" if inline_pdf else "attachment")
if size > 0: if size > 0:
headers["Content-Length"] = str(size) headers["Content-Length"] = str(size)
def generate(): def generate():

View File

@@ -6,10 +6,20 @@ import secrets
from urllib.parse import urlparse from urllib.parse import urlparse
from flask import abort, g, jsonify, redirect, request, session, url_for from flask import abort, g, has_request_context, jsonify, redirect, request, session, url_for
from werkzeug.security import check_password_hash, generate_password_hash from werkzeug.security import check_password_hash, generate_password_hash
from ..config import AUTH_ENABLE from ..config import (
AUTH_ENABLE,
AUTH_PROVIDER,
AUTH_PROXY_AUTO_CREATE,
AUTH_PROXY_AUTO_CREATE_PERMISSION,
AUTH_PROXY_AUTO_CREATE_ROLE,
AUTH_PROXY_USER_HEADER,
API_ALLOWED_ORIGINS,
AUTH_BYPASS_HOSTS,
AUTH_BYPASS_USER,
)
from ..db import connect, default_user_id, utcnow from ..db import connect, default_user_id, utcnow
PUBLIC_ENDPOINTS = {"main.login", "main.logout", "api.auth_login", "api.auth_me", "static"} PUBLIC_ENDPOINTS = {"main.login", "main.logout", "api.auth_login", "api.auth_me", "static"}
@@ -21,11 +31,16 @@ RTORRENT_WRITE_PREFIXES = (
"/api/rss", "/api/rss",
"/api/smart-queue", "/api/smart-queue",
"/api/automations", "/api/automations",
"/api/download-planner",
"/api/poller/settings",
"/api/operation-logs",
"/api/jobs", "/api/jobs",
"/api/cleanup",
) )
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: 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",
@@ -40,6 +55,9 @@ PROFILE_READ_PREFIXES = (
"/api/smart-queue", "/api/smart-queue",
"/api/traffic/history", "/api/traffic/history",
"/api/automations", "/api/automations",
"/api/download-planner",
"/api/poller/settings",
"/api/operation-logs",
) )
@@ -47,16 +65,77 @@ def enabled() -> bool:
return bool(AUTH_ENABLE) return bool(AUTH_ENABLE)
def provider() -> str:
return AUTH_PROVIDER if AUTH_PROVIDER in {"local", "proxy", "tinyauth"} else "local"
def uses_external_provider() -> bool:
return enabled() and provider() in {"proxy", "tinyauth"}
def external_auth_summary() -> dict[str, Any]:
# Note: Exposes safe auth-mode facts for the Users panel without leaking secrets.
return {
"enabled": enabled(),
"provider": provider(),
"external": uses_external_provider(),
"auto_create": bool(AUTH_PROXY_AUTO_CREATE) if uses_external_provider() else False,
"auto_create_role": AUTH_PROXY_AUTO_CREATE_ROLE,
"auto_create_permission": AUTH_PROXY_AUTO_CREATE_PERMISSION,
"bypass_enabled": bool(AUTH_BYPASS_HOSTS),
"bypass_hosts": sorted(AUTH_BYPASS_HOSTS),
"bypass_user": AUTH_BYPASS_USER,
"password_editable": not uses_external_provider(),
}
def password_hash(password: str) -> str: def password_hash(password: str) -> str:
return generate_password_hash(password or "") return generate_password_hash(password or "")
def _host_matches_bypass(host: str) -> bool:
clean = str(host or "").strip().lower()
if not clean:
return False
return clean in AUTH_BYPASS_HOSTS or clean.split(":", 1)[0] in AUTH_BYPASS_HOSTS
def auth_bypassed_request() -> bool:
# Note: Allows trusted direct-IP access to keep auth enabled for reverse-proxy traffic.
if not enabled() or not AUTH_BYPASS_HOSTS or not has_request_context():
return False
return _host_matches_bypass(request.host)
def bypass_user_id() -> int:
"""Return the configured active user id used for trusted auth-bypass requests."""
username = str(AUTH_BYPASS_USER or "admin").strip() or "admin"
with connect() as conn:
row = conn.execute("SELECT id FROM users WHERE username=? AND is_active=1", (username,)).fetchone()
if row:
return int(row["id"])
# Note: Keep direct-IP access usable after old installs, but never choose an inactive fallback.
row = conn.execute("SELECT id FROM users WHERE username='admin' AND is_active=1").fetchone()
if row:
return int(row["id"])
row = conn.execute("SELECT id FROM users WHERE id=? AND is_active=1", (default_user_id(),)).fetchone()
return int(row["id"]) if row else 0
def current_user_id() -> int: def current_user_id() -> int:
if not enabled(): if not enabled():
return default_user_id() return default_user_id()
if not has_request_context():
# Note: Background jobs and schedulers do not have Flask request/session state.
return 0
if auth_bypassed_request():
return bypass_user_id()
api_user_id = getattr(g, "api_user_id", None) api_user_id = getattr(g, "api_user_id", None)
if api_user_id: if api_user_id:
return int(api_user_id) return int(api_user_id)
external_user_id = getattr(g, "external_user_id", None)
if external_user_id:
return int(external_user_id)
try: try:
return int(session.get("user_id") or 0) return int(session.get("user_id") or 0)
except Exception: except Exception:
@@ -69,7 +148,7 @@ def current_user() -> dict[str, Any] | None:
return None return None
with connect() as conn: with connect() as conn:
return conn.execute( return conn.execute(
"SELECT id, username, role, is_active, created_at, updated_at FROM users WHERE id=?", "SELECT id, username, email, display_name, external_auth_provider, external_subject, role, is_active, created_at, updated_at FROM users WHERE id=?",
(uid,), (uid,),
).fetchone() ).fetchone()
@@ -153,14 +232,29 @@ def visible_profile_ids(user_id: int | None = None) -> set[int] | None:
def _origin_key(value: str) -> str:
parsed = urlparse(str(value or "").strip())
if not parsed.scheme or not parsed.netloc:
return ""
return f"{parsed.scheme.lower()}://{parsed.netloc.lower()}"
def _request_origin() -> str:
return _origin_key(f"{request.scheme}://{request.host}")
def same_origin_request() -> bool: def same_origin_request() -> bool:
"""Return False only when an unsafe request clearly comes from another origin.""" """Return False only when an unsafe API request clearly comes from an untrusted origin."""
origin = request.headers.get("Origin") or request.headers.get("Referer") origin = request.headers.get("Origin") or request.headers.get("Referer")
if not origin: if not origin:
return True return True
try: try:
parsed = urlparse(origin) source_origin = _origin_key(origin)
return parsed.scheme == request.scheme and parsed.netloc == request.host if not source_origin:
return False
if source_origin == _request_origin():
return True
return source_origin in set(API_ALLOWED_ORIGINS)
except Exception: except Exception:
return False return False
@@ -200,6 +294,8 @@ def require_profile_write(profile_id: int | None) -> None:
def login_user(username: str, password: str) -> dict[str, Any] | None: def login_user(username: str, password: str) -> dict[str, Any] | None:
if not enabled(): if not enabled():
return {"id": default_user_id(), "username": "default", "role": "admin", "is_active": 1} return {"id": default_user_id(), "username": "default", "role": "admin", "is_active": 1}
if uses_external_provider():
return None
with connect() as conn: with connect() as conn:
user = conn.execute("SELECT * FROM users WHERE username=?", (username.strip(),)).fetchone() user = conn.execute("SELECT * FROM users WHERE username=?", (username.strip(),)).fetchone()
if not user or not int(user.get("is_active") or 0): if not user or not int(user.get("is_active") or 0):
@@ -213,6 +309,139 @@ def login_user(username: str, password: str) -> dict[str, Any] | None:
return current_user() return current_user()
def _clean_header_value(name: str) -> str:
if not name:
return ""
value = request.headers.get(name) or request.headers.get(name.lower()) or request.headers.get(name.upper()) or ""
return str(value).strip()
def _safe_username(value: str, fallback: str = "external-user") -> str:
raw = str(value or "").strip()
if "@" in raw:
raw = raw.split("@", 1)[0]
clean = "".join(ch for ch in raw if ch.isalnum() or ch in {".", "_", "-"}).strip("._-")
return (clean or fallback)[:80]
def _external_identity_from_headers() -> dict[str, str] | None:
# Note: Tinyauth and generic proxy auth use a single trusted username header.
username = _clean_header_value(AUTH_PROXY_USER_HEADER)
if not username:
return None
safe_username = _safe_username(username)
return {
"provider": provider(),
"username": safe_username,
"subject": safe_username,
}
def _grant_default_external_permissions(conn, user_id: int, now: str) -> None:
# Note: Admins can see and write all profiles through role-based access.
if AUTH_PROXY_AUTO_CREATE_PERMISSION == "none" or AUTH_PROXY_AUTO_CREATE_ROLE == "admin":
return
conn.execute(
"INSERT OR IGNORE INTO user_profile_permissions(user_id,profile_id,access_level,created_at,updated_at) VALUES(?,?,?,?,?)",
(user_id, 0, AUTH_PROXY_AUTO_CREATE_PERMISSION, now, now),
)
def _sync_external_auto_created_user(conn, user: dict[str, Any], now: str) -> None:
# Note: Passwordless external users follow the external auto-create defaults on login.
if not AUTH_PROXY_AUTO_CREATE or user.get("password_hash"):
return
if user.get("external_auth_provider") and user.get("external_auth_provider") != provider():
return
user_id = int(user["id"])
conn.execute("UPDATE users SET role=?, updated_at=? WHERE id=?", (AUTH_PROXY_AUTO_CREATE_ROLE, now, user_id))
if AUTH_PROXY_AUTO_CREATE_ROLE == "admin" or AUTH_PROXY_AUTO_CREATE_PERMISSION == "none":
conn.execute("DELETE FROM user_profile_permissions WHERE user_id=?", (user_id,))
return
conn.execute(
"INSERT OR REPLACE INTO user_profile_permissions(user_id,profile_id,access_level,created_at,updated_at) VALUES(?,?,?,?,?)",
(user_id, 0, AUTH_PROXY_AUTO_CREATE_PERMISSION, now, now),
)
def authenticate_external_user() -> dict[str, Any] | None:
if not uses_external_provider():
return None
identity = _external_identity_from_headers()
if not identity:
return None
now = utcnow()
with connect() as conn:
user = None
if identity["subject"]:
user = conn.execute(
"SELECT * FROM users WHERE external_auth_provider=? AND external_subject=?",
(identity["provider"], identity["subject"]),
).fetchone()
if not user:
user = conn.execute("SELECT * FROM users WHERE username=?", (identity["username"],)).fetchone()
if not user:
if not AUTH_PROXY_AUTO_CREATE:
return None
cur = conn.execute(
"""INSERT INTO users(username,password_hash,email,display_name,external_auth_provider,external_subject,role,is_active,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)""",
(
identity["username"],
None,
None,
None,
identity["provider"],
identity["subject"] or identity["username"],
AUTH_PROXY_AUTO_CREATE_ROLE,
1,
now,
now,
),
)
user_id = int(cur.lastrowid)
_grant_default_external_permissions(conn, user_id, now)
user = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
else:
user_id = int(user["id"])
conn.execute(
"""UPDATE users
SET external_auth_provider=?,
external_subject=COALESCE(NULLIF(?, ''), external_subject),
updated_at=?
WHERE id=?""",
(identity["provider"], identity["subject"], now, user_id),
)
user = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
if user:
_sync_external_auto_created_user(conn, user, now)
user = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
if not user or not int(user.get("is_active") or 0):
return None
g.external_user_id = int(user["id"])
session["user_id"] = int(user["id"])
session["username"] = user.get("username")
session["role"] = user.get("role") or "user"
return _public_user(user)
def ensure_request_user() -> int:
# Note: Socket.IO events do not go through Flask before_request like normal REST calls,
# so external proxy auth must be resolved explicitly during the Socket.IO handshake/events.
if not enabled():
return default_user_id()
if auth_bypassed_request():
return bypass_user_id()
uid = current_user_id()
if uid:
return uid
if uses_external_provider():
authenticate_external_user()
return current_user_id()
def logout_user() -> None: def logout_user() -> None:
session.clear() session.clear()
@@ -236,7 +465,7 @@ def list_users() -> list[dict[str, Any]]:
require_admin() require_admin()
with connect() as conn: with connect() as conn:
users = conn.execute( users = conn.execute(
"SELECT id, username, role, is_active, created_at, updated_at FROM users ORDER BY username COLLATE NOCASE" "SELECT id, username, email, display_name, external_auth_provider, external_subject, role, is_active, created_at, updated_at FROM users ORDER BY username COLLATE NOCASE"
).fetchall() ).fetchall()
perms = conn.execute( perms = conn.execute(
"SELECT user_id, profile_id, access_level FROM user_profile_permissions ORDER BY user_id, profile_id" "SELECT user_id, profile_id, access_level FROM user_profile_permissions ORDER BY user_id, profile_id"
@@ -263,6 +492,7 @@ def save_user(data: dict[str, Any], user_id: int | None = None) -> dict[str, Any
username = str(data.get("username") or "").strip() username = str(data.get("username") or "").strip()
role = "admin" if data.get("role") == "admin" else "user" role = "admin" if data.get("role") == "admin" else "user"
is_active = 1 if data.get("is_active", True) else 0 is_active = 1 if data.get("is_active", True) else 0
password_editable = not uses_external_provider()
if not username: if not username:
raise ValueError("Username is required") raise ValueError("Username is required")
with connect() as conn: with connect() as conn:
@@ -271,16 +501,19 @@ def save_user(data: dict[str, Any], user_id: int | None = None) -> dict[str, Any
if not row: if not row:
raise ValueError("User does not exist") raise ValueError("User does not exist")
conn.execute( conn.execute(
"UPDATE users SET username=?, role=?, is_active=?, updated_at=? WHERE id=?", "UPDATE users SET username=?, email=?, display_name=?, role=?, is_active=?, updated_at=? WHERE id=?",
(username, role, is_active, now, user_id), (username, str(data.get("email") or "").strip() or None, str(data.get("display_name") or "").strip() or None, role, is_active, now, user_id),
) )
else: else:
initial_password_hash = password_hash(str(data.get("password") or username)) if password_editable else None
# Note: TinyAuth/proxy users are passwordless in pyTorrent; credentials stay with the auth provider.
cur = conn.execute( cur = conn.execute(
"INSERT INTO users(username,password_hash,role,is_active,created_at,updated_at) VALUES(?,?,?,?,?,?)", "INSERT INTO users(username,password_hash,email,display_name,role,is_active,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)",
(username, password_hash(str(data.get("password") or username)), role, is_active, now, now), (username, initial_password_hash, str(data.get("email") or "").strip() or None, str(data.get("display_name") or "").strip() or None, role, is_active, now, now),
) )
user_id = int(cur.lastrowid) user_id = int(cur.lastrowid)
if data.get("password"): if data.get("password") and password_editable:
# Note: Password changes are intentionally disabled for external auth providers.
conn.execute("UPDATE users SET password_hash=?, updated_at=? WHERE id=?", (password_hash(str(data.get("password"))), now, user_id)) conn.execute("UPDATE users SET password_hash=?, updated_at=? WHERE id=?", (password_hash(str(data.get("password"))), now, user_id))
if role != "admin": if role != "admin":
conn.execute("DELETE FROM user_profile_permissions WHERE user_id=?", (user_id,)) conn.execute("DELETE FROM user_profile_permissions WHERE user_id=?", (user_id,))
@@ -293,7 +526,7 @@ def save_user(data: dict[str, Any], user_id: int | None = None) -> dict[str, Any
) )
else: else:
conn.execute("DELETE FROM user_profile_permissions WHERE user_id=?", (user_id,)) conn.execute("DELETE FROM user_profile_permissions WHERE user_id=?", (user_id,))
return conn.execute("SELECT id, username, role, is_active, created_at, updated_at FROM users WHERE id=?", (user_id,)).fetchone() return conn.execute("SELECT id, username, email, display_name, external_auth_provider, external_subject, role, is_active, created_at, updated_at FROM users WHERE id=?", (user_id,)).fetchone()
def delete_user(user_id: int) -> None: def delete_user(user_id: int) -> None:
@@ -323,6 +556,10 @@ def _public_user(row: dict[str, Any] | None) -> dict[str, Any] | None:
return { return {
"id": int(row["id"]), "id": int(row["id"]),
"username": row.get("username"), "username": row.get("username"),
"email": row.get("email"),
"display_name": row.get("display_name"),
"external_auth_provider": row.get("external_auth_provider"),
"external_subject": row.get("external_subject"),
"role": row.get("role") or "user", "role": row.get("role") or "user",
"is_active": int(row.get("is_active") or 0), "is_active": int(row.get("is_active") or 0),
"created_at": row.get("created_at"), "created_at": row.get("created_at"),
@@ -352,7 +589,7 @@ def list_api_tokens(user_id: int) -> list[dict[str, Any]]:
abort(403) abort(403)
with connect() as conn: with connect() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT id,user_id,name,token_prefix,last_used_at,created_at,updated_at,revoked_at FROM api_tokens WHERE user_id=? ORDER BY created_at DESC", "SELECT id,user_id,name,token_prefix,last_used_at,created_at,updated_at,revoked_at FROM api_tokens WHERE user_id=? AND revoked_at IS NULL ORDER BY created_at DESC",
(uid,), (uid,),
).fetchall() ).fetchall()
return [_token_response(row) for row in rows] return [_token_response(row) for row in rows]
@@ -396,10 +633,13 @@ def revoke_api_token(user_id: int, token_id: int) -> None:
abort(403) abort(403)
now = utcnow() now = utcnow()
with connect() as conn: with connect() as conn:
conn.execute( # Note: Report missing/already revoked tokens instead of showing a false success in the UI.
"UPDATE api_tokens SET revoked_at=COALESCE(revoked_at, ?), updated_at=? WHERE id=? AND user_id=?", cur = conn.execute(
"UPDATE api_tokens SET revoked_at=COALESCE(revoked_at, ?), updated_at=? WHERE id=? AND user_id=? AND revoked_at IS NULL",
(now, now, tid, uid), (now, now, tid, uid),
) )
if cur.rowcount <= 0:
raise ValueError("Active API token not found")
def authenticate_api_token(token: str) -> dict[str, Any] | None: def authenticate_api_token(token: str) -> dict[str, Any] | None:
@@ -439,12 +679,22 @@ def install_guards(app) -> None:
def _auth_guard(): def _auth_guard():
if not enabled(): if not enabled():
return None return None
# Allow unauthenticated health checks for monitoring.
if request.path == "/api/health" or request.path.startswith("/api/health/"):
return None
g.api_token_authenticated = False g.api_token_authenticated = False
if auth_bypassed_request():
return None
if request.path.startswith("/api/"): if request.path.startswith("/api/"):
token_user = authenticate_api_token(_request_api_token()) token_user = authenticate_api_token(_request_api_token())
if token_user: if token_user:
g.api_user_id = int(token_user["id"]) g.api_user_id = int(token_user["id"])
g.api_token_authenticated = True g.api_token_authenticated = True
if not getattr(g, "api_user_id", None):
authenticate_external_user()
endpoint = request.endpoint or "" endpoint = request.endpoint or ""
if endpoint in PUBLIC_ENDPOINTS or endpoint.startswith("static"): if endpoint in PUBLIC_ENDPOINTS or endpoint.startswith("static"):
return None return None

View File

@@ -5,15 +5,39 @@ import threading
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from ..db import connect, utcnow, default_user_id from ..db import connect, utcnow, default_user_id
from . import auth
# Note: Settings backups include persistent configuration tables only; volatile queues, caches, histories and tokens are intentionally skipped. # Note: Application backups are admin-only because they include users, permissions and all profiles.
BACKUP_TABLES = [ APP_BACKUP_TABLES = [
"users", "user_profile_permissions", "user_preferences", "rtorrent_profiles", "users", "user_profile_permissions", "user_preferences", "profile_preferences", "rtorrent_profiles",
"disk_monitor_preferences", "labels", "ratio_groups", "rss_feeds", "rss_rules", "disk_monitor_preferences", "labels", "ratio_groups", "rss_feeds", "rss_rules",
"smart_queue_settings", "smart_queue_exclusions", "automation_rules", "smart_queue_settings", "smart_queue_exclusions", "automation_rules",
"rtorrent_config_overrides", "app_settings", "download_plan_settings", "rtorrent_config_overrides", "poller_settings", "app_settings", "download_plan_settings",
] ]
# Note: Profile backups contain active profile data. User-specific preferences remain scoped to the current user.
PROFILE_BACKUP_TABLES = [
"rtorrent_profiles", "profile_preferences", "disk_monitor_preferences", "labels", "ratio_groups",
"rss_feeds", "rss_rules", "smart_queue_settings", "smart_queue_exclusions",
"automation_rules", "rtorrent_config_overrides", "poller_settings", "download_plan_settings",
]
PROFILE_TABLE_FILTERS = {
"rtorrent_profiles": "id=?",
"profile_preferences": "user_id=? AND profile_id=?",
"disk_monitor_preferences": "user_id=? AND profile_id=?",
"labels": "user_id=? AND profile_id=?",
"ratio_groups": "user_id=? AND profile_id=?",
"rss_feeds": "profile_id=?",
"rss_rules": "profile_id=?",
"smart_queue_settings": "profile_id=?",
"smart_queue_exclusions": "profile_id=?",
"automation_rules": "user_id=? AND profile_id=?",
"rtorrent_config_overrides": "profile_id=?",
"poller_settings": "profile_id=?",
"download_plan_settings": "user_id=? AND profile_id=?",
}
DEFAULT_AUTO_BACKUP_SETTINGS = { DEFAULT_AUTO_BACKUP_SETTINGS = {
"enabled": False, "enabled": False,
"interval_hours": 24, "interval_hours": 24,
@@ -22,101 +46,26 @@ DEFAULT_AUTO_BACKUP_SETTINGS = {
} }
BACKUP_PREVIEW_VALUE_LIMIT = 80 BACKUP_PREVIEW_VALUE_LIMIT = 80
BACKUP_PREVIEW_ROW_LIMIT = 3 BACKUP_PREVIEW_ROW_LIMIT = 3
BACKUP_PREVIEW_SENSITIVE_KEYS = { BACKUP_PREVIEW_SENSITIVE_KEYS = {"password", "password_hash", "token", "token_hash", "api_key", "secret"}
"password",
"password_hash",
"token",
"token_hash",
"api_key",
"secret",
}
AUTO_BACKUP_SETTINGS_KEY = "backup:auto" AUTO_BACKUP_SETTINGS_KEY = "backup:auto"
_scheduler_started = False _scheduler_started = False
_scheduler_lock = threading.Lock() _scheduler_lock = threading.Lock()
def create_backup(name: str, user_id: int | None = None, automatic: bool = False) -> dict: def _is_admin_user(user_id: int | None = None) -> bool:
"""Create a settings backup and return a table-count summary. if not auth.enabled():
return True
Note: The automatic flag is metadata only; restore/download behavior remains unchanged. uid = user_id or auth.current_user_id()
""" if not uid:
user_id = user_id or default_user_id() return False
payload = {"version": 1, "created_at": utcnow(), "automatic": bool(automatic), "tables": {}}
with connect() as conn: with connect() as conn:
for table in BACKUP_TABLES: row = conn.execute("SELECT role,is_active FROM users WHERE id=?", (uid,)).fetchone()
try: return bool(row and row.get("role") == "admin" and int(row.get("is_active") or 0))
payload["tables"][table] = conn.execute(f"SELECT * FROM {table}").fetchall()
except Exception:
payload["tables"][table] = []
cur = conn.execute(
"INSERT INTO app_backups(user_id,name,payload_json,created_at) VALUES(?,?,?,?)",
(user_id, name or f"Backup {payload['created_at']}", json.dumps(payload), payload["created_at"]),
)
backup_id = cur.lastrowid
return {"id": backup_id, "name": name, "created_at": payload["created_at"], "automatic": bool(automatic), "tables": {k: len(v) for k, v in payload["tables"].items()}}
def list_backups(user_id: int | None = None) -> list[dict]:
user_id = user_id or default_user_id()
with connect() as conn:
rows = conn.execute("SELECT id,name,created_at,payload_json FROM app_backups WHERE user_id=? ORDER BY id DESC", (user_id,)).fetchall()
result = []
for row in rows:
payload = _loads(row.get("payload_json") or "{}")
tables = payload.get("tables") or {}
result.append({
"id": row.get("id"),
"name": row.get("name"),
"created_at": row.get("created_at"),
"automatic": bool(payload.get("automatic")),
"tables": {key: len(value or []) for key, value in tables.items()},
})
return result
def payload_for_backup(backup_id: int, user_id: int | None = None) -> dict:
user_id = user_id or default_user_id()
with connect() as conn:
row = conn.execute("SELECT payload_json FROM app_backups WHERE id=? AND user_id=?", (backup_id, user_id)).fetchone()
if not row:
raise ValueError("Backup not found")
return json.loads(row["payload_json"] or "{}")
def restore_backup(backup_id: int, user_id: int | None = None) -> dict:
user_id = user_id or default_user_id()
payload = payload_for_backup(backup_id, user_id)
tables = payload.get("tables") or {}
restored = {}
with connect() as conn:
conn.execute("PRAGMA foreign_keys = OFF")
try:
for table in BACKUP_TABLES:
rows = tables.get(table) or []
if not rows:
continue
columns = list(rows[0].keys())
placeholders = ",".join("?" for _ in columns)
conn.execute(f"DELETE FROM {table}")
for row in rows:
conn.execute(f"INSERT INTO {table}({','.join(columns)}) VALUES({placeholders})", [row.get(col) for col in columns])
restored[table] = len(rows)
finally:
conn.execute("PRAGMA foreign_keys = ON")
return {"restored": restored}
def delete_backup(backup_id: int, user_id: int | None = None) -> dict:
user_id = user_id or default_user_id()
with connect() as conn:
cur = conn.execute(
"DELETE FROM app_backups WHERE id=? AND user_id=?",
(backup_id, user_id),
)
if not cur.rowcount:
raise ValueError("Backup not found")
return {"deleted": backup_id}
def _require_admin(user_id: int | None = None) -> None:
if not _is_admin_user(user_id):
raise PermissionError("Application backups are available only to admins")
def _loads(value: str) -> dict: def _loads(value: str) -> dict:
@@ -127,26 +76,240 @@ def _loads(value: str) -> dict:
return {} return {}
def _settings_row_key(user_id: int | None = None) -> str: def _table_columns(conn, table: str) -> set[str]:
return f"{AUTO_BACKUP_SETTINGS_KEY}:{user_id or default_user_id()}" try:
return {str(row["name"]) for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
except Exception:
return set()
def _latest_backup_created_at(user_id: int) -> str | None: def _table_rows(conn, table: str, where: str | None = None, params: tuple = ()) -> list[dict]:
"""Return the newest persisted backup timestamp for scheduler recovery after restarts. try:
sql = f"SELECT * FROM {table}" + (f" WHERE {where}" if where else "")
return [dict(row) for row in conn.execute(sql, params).fetchall()]
except Exception:
return []
Note: Automatic scheduling is based on the latest database backup record, so process
restarts cannot create repeated backups before the configured interval elapses. def _store_backup(user_id: int, name: str, backup_type: str, profile_id: int | None, payload: dict) -> dict:
""" with connect() as conn:
cur = conn.execute(
"INSERT INTO app_backups(user_id,name,backup_type,profile_id,payload_json,created_at) VALUES(?,?,?,?,?,?)",
(user_id, name or f"Backup {payload['created_at']}", backup_type, profile_id, json.dumps(payload), payload["created_at"]),
)
backup_id = cur.lastrowid
return {
"id": backup_id,
"name": name,
"backup_type": backup_type,
"profile_id": profile_id,
"created_at": payload["created_at"],
"automatic": bool(payload.get("automatic")),
"tables": {k: len(v) for k, v in (payload.get("tables") or {}).items()},
}
def create_app_backup(name: str, user_id: int | None = None, automatic: bool = False) -> dict:
user_id = user_id or auth.current_user_id() or default_user_id()
_require_admin(user_id)
payload = {"version": 2, "backup_type": "app", "created_at": utcnow(), "automatic": bool(automatic), "tables": {}}
with connect() as conn:
for table in APP_BACKUP_TABLES:
payload["tables"][table] = _table_rows(conn, table)
return _store_backup(user_id, name, "app", None, payload)
def create_profile_backup(name: str, profile_id: int, user_id: int | None = None, automatic: bool = False) -> dict:
user_id = user_id or auth.current_user_id() or default_user_id()
if not auth.can_access_profile(profile_id, user_id):
raise PermissionError("No access to profile")
payload = {"version": 2, "backup_type": "profile", "source_profile_id": int(profile_id), "created_at": utcnow(), "automatic": bool(automatic), "tables": {}}
with connect() as conn:
for table in PROFILE_BACKUP_TABLES:
where = PROFILE_TABLE_FILTERS.get(table)
if where == "id=?" or where == "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)
def create_backup(name: str, user_id: int | None = None, automatic: bool = False) -> dict:
return create_app_backup(name, user_id, automatic)
def list_backups(user_id: int | None = None, backup_type: str | None = None, profile_id: int | None = None) -> list[dict]:
user_id = user_id or auth.current_user_id() or default_user_id()
clauses = ["user_id=?"]
params: list[object] = [user_id]
if backup_type:
clauses.append("COALESCE(backup_type,'app')=?")
params.append(backup_type)
if profile_id is not None:
clauses.append("profile_id=?")
params.append(int(profile_id))
with connect() as conn:
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",
tuple(params),
).fetchall()
result = []
for row in rows:
payload = _loads(row.get("payload_json") or "{}")
tables = payload.get("tables") or {}
result.append({
"id": row.get("id"),
"name": row.get("name"),
"created_at": row.get("created_at"),
"backup_type": row.get("backup_type") or payload.get("backup_type") or "app",
"profile_id": row.get("profile_id") or payload.get("source_profile_id"),
"automatic": bool(payload.get("automatic")),
"tables": {key: len(value or []) for key, value in tables.items()},
})
return result
def payload_for_backup(backup_id: int, user_id: int | None = None) -> dict:
user_id = user_id or auth.current_user_id() or default_user_id()
with connect() as conn:
row = conn.execute("SELECT payload_json FROM app_backups WHERE id=? AND user_id=?", (backup_id, user_id)).fetchone()
if not row:
raise ValueError("Backup not found")
return json.loads(row["payload_json"] or "{}")
def _backup_type(payload: dict) -> str:
return str(payload.get("backup_type") or ("profile" if payload.get("source_profile_id") else "app"))
def restore_app_backup(backup_id: int, user_id: int | None = None) -> dict:
user_id = user_id or auth.current_user_id() or default_user_id()
_require_admin(user_id)
payload = payload_for_backup(backup_id, user_id)
if _backup_type(payload) != "app":
raise ValueError("This is not an application backup")
tables = payload.get("tables") or {}
restored = {}
with connect() as conn:
conn.execute("PRAGMA foreign_keys = OFF")
try:
for table in APP_BACKUP_TABLES:
rows = tables.get(table) or []
if not rows:
continue
available = _table_columns(conn, table)
columns = [col for col in rows[0].keys() if col in available]
if not columns:
continue
placeholders = ",".join("?" for _ in columns)
conn.execute(f"DELETE FROM {table}")
for row in rows:
conn.execute(f"INSERT INTO {table}({','.join(columns)}) VALUES({placeholders})", [row.get(col) for col in columns])
restored[table] = len(rows)
finally:
conn.execute("PRAGMA foreign_keys = ON")
return {"restored": restored, "backup_type": "app"}
def _rewrite_profile_row(table: str, row: dict, user_id: int, target_profile_id: int) -> dict:
clean = dict(row)
if table == "rtorrent_profiles":
clean["id"] = target_profile_id
clean["user_id"] = user_id
clean["is_default"] = int(clean.get("is_default") or 0)
return clean
if "profile_id" in clean:
clean["profile_id"] = target_profile_id
if "user_id" in clean:
clean["user_id"] = user_id
if table == "poller_settings":
clean["profile_id"] = target_profile_id
if "id" in clean and table != "rtorrent_profiles":
clean.pop("id", None)
return clean
def restore_profile_backup(backup_id: int, target_profile_id: int, user_id: int | None = None) -> dict:
user_id = user_id or auth.current_user_id() or default_user_id()
if not auth.can_write_profile(target_profile_id, user_id):
raise PermissionError("No write access to profile")
payload = payload_for_backup(backup_id, user_id)
if _backup_type(payload) != "profile":
raise ValueError("This is not a profile backup")
tables = payload.get("tables") or {}
restored = {}
with connect() as conn:
conn.execute("PRAGMA foreign_keys = OFF")
try:
for table in PROFILE_BACKUP_TABLES:
rows = tables.get(table) or []
where = PROFILE_TABLE_FILTERS.get(table)
if where == "id=?" or where == "profile_id=?":
params = (int(target_profile_id),)
else:
params = (user_id, int(target_profile_id))
conn.execute(f"DELETE FROM {table} WHERE {where}", params)
if not rows:
continue
count = 0
for row in rows:
clean = _rewrite_profile_row(table, dict(row), user_id, int(target_profile_id))
available = _table_columns(conn, table)
columns = [col for col in clean.keys() if col in available]
if not columns:
continue
placeholders = ",".join("?" for _ in columns)
conn.execute(f"INSERT INTO {table}({','.join(columns)}) VALUES({placeholders})", [clean.get(col) for col in columns])
count += 1
restored[table] = count
finally:
conn.execute("PRAGMA foreign_keys = ON")
return {"restored": restored, "backup_type": "profile", "profile_id": int(target_profile_id)}
def restore_backup(backup_id: int, user_id: int | None = None, profile_id: int | None = None) -> dict:
payload = payload_for_backup(backup_id, user_id)
if _backup_type(payload) == "profile":
target = profile_id or payload.get("source_profile_id")
if not target:
raise ValueError("Missing target profile")
return restore_profile_backup(backup_id, int(target), user_id)
return restore_app_backup(backup_id, user_id)
def delete_backup(backup_id: int, user_id: int | None = None) -> dict:
user_id = user_id or auth.current_user_id() or default_user_id()
with connect() as conn:
cur = conn.execute("DELETE FROM app_backups WHERE id=? AND user_id=?", (backup_id, user_id))
if not cur.rowcount:
raise ValueError("Backup not found")
return {"deleted": backup_id}
def _settings_row_key(user_id: int | None = None, backup_type: str = "app", profile_id: int | None = None) -> str:
uid = user_id or auth.current_user_id() or default_user_id()
scope = "profile" if backup_type == "profile" else "app"
if scope == "profile":
return f"{AUTO_BACKUP_SETTINGS_KEY}:profile:{uid}:{int(profile_id or 0)}"
return f"{AUTO_BACKUP_SETTINGS_KEY}:app:{uid}"
def _latest_backup_created_at(user_id: int, backup_type: str = "app", profile_id: int | None = None) -> str | None:
clauses = ["user_id=?", "COALESCE(backup_type,'app')=?"]
params: list[object] = [user_id, backup_type]
if backup_type == "profile":
clauses.append("profile_id=?")
params.append(int(profile_id or 0))
with connect() as conn: with connect() as conn:
row = conn.execute( row = conn.execute(
"SELECT created_at FROM app_backups WHERE user_id=? 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",
(user_id,), tuple(params),
).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:
"""Return a safe, compact value for backup previews without exposing secrets."""
if value is None or isinstance(value, (int, float, bool)): if value is None or isinstance(value, (int, float, bool)):
return value return value
text = str(value) text = str(value)
@@ -157,34 +320,34 @@ def _preview_row(row: dict) -> dict:
output = {} output = {}
for key, value in row.items(): for key, value in row.items():
lowered = str(key).lower() lowered = str(key).lower()
if any(secret in lowered for secret in BACKUP_PREVIEW_SENSITIVE_KEYS): output[key] = "[hidden]" if any(secret in lowered for secret in BACKUP_PREVIEW_SENSITIVE_KEYS) else _preview_value(value)
output[key] = "[hidden]"
else:
output[key] = _preview_value(value)
return output return output
def get_auto_backup_settings(user_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:
"""Return automatic backup schedule settings for the current user. key = _settings_row_key(user_id, backup_type, profile_id)
Note: The UI uses this as the single source for interval and retention controls.
"""
key = _settings_row_key(user_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()
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))
settings["retention_days"] = max(1, int(settings.get("retention_days") or 30)) settings["retention_days"] = max(1, int(settings.get("retention_days") or 30))
settings["backup_type"] = "profile" if backup_type == "profile" else "app"
if backup_type == "profile":
settings["profile_id"] = int(profile_id or 0)
return settings return settings
def save_auto_backup_settings(data: dict, user_id: int | None = None) -> dict: def save_auto_backup_settings(data: dict, user_id: int | None = None, backup_type: str = "app", profile_id: int | None = None) -> dict:
"""Persist automatic backup schedule settings after validating UI input. user_id = user_id or auth.current_user_id() or default_user_id()
backup_type = "profile" if backup_type == "profile" else "app"
Note: Minimum interval is one hour to avoid creating excessive database rows. if backup_type == "app":
""" _require_admin(user_id)
current = get_auto_backup_settings(user_id) else:
# Note: Profile backup schedules affect profile operations, so read-only users may view/export backups but cannot change automation.
if not profile_id or not auth.can_write_profile(int(profile_id), user_id):
raise PermissionError("No write access to profile")
current = get_auto_backup_settings(user_id, backup_type, profile_id)
settings = { settings = {
**current, **current,
"enabled": bool(data.get("enabled")), "enabled": bool(data.get("enabled")),
@@ -192,22 +355,20 @@ def save_auto_backup_settings(data: dict, user_id: int | None = None) -> dict:
"retention_days": max(1, int(data.get("retention_days") or current["retention_days"])), "retention_days": max(1, int(data.get("retention_days") or current["retention_days"])),
"last_run_at": data.get("last_run_at", current.get("last_run_at")), "last_run_at": data.get("last_run_at", current.get("last_run_at")),
} }
key = _settings_row_key(user_id) key = _settings_row_key(user_id, backup_type, profile_id)
with connect() as conn: with connect() as conn:
conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (key, json.dumps(settings))) conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (key, json.dumps(settings)))
return settings return settings
def preview_backup(backup_id: int, user_id: int | None = None) -> dict: def preview_backup(backup_id: int, user_id: int | None = None) -> dict:
"""Return a compact backup preview without exposing the full JSON payload in the list view.
Note: The preview shows included tables and example keys so users can verify settings coverage.
"""
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 {}
return { return {
"version": payload.get("version"), "version": payload.get("version"),
"created_at": payload.get("created_at"), "created_at": payload.get("created_at"),
"backup_type": _backup_type(payload),
"source_profile_id": payload.get("source_profile_id"),
"automatic": bool(payload.get("automatic")), "automatic": bool(payload.get("automatic")),
"tables": [ "tables": [
{ {
@@ -221,50 +382,70 @@ def preview_backup(backup_id: int, user_id: int | None = None) -> dict:
} }
def prune_old_backups(user_id: int | None = None, retention_days: int = 30) -> int: def prune_old_backups(user_id: int | None = None, retention_days: int = 30, backup_type: str = "app", profile_id: int | None = None) -> int:
"""Delete backups older than the configured retention window for the selected user. user_id = user_id or auth.current_user_id() or default_user_id()
Note: Retention is applied only to backup records, not to restored application settings.
"""
user_id = 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<?"]
params: list[object] = [user_id, backup_type, cutoff]
if backup_type == "profile":
clauses.append("profile_id=?")
params.append(int(profile_id or 0))
with connect() as conn: with connect() as conn:
cur = conn.execute("DELETE FROM app_backups WHERE user_id=? AND created_at<?", (user_id, cutoff)) cur = conn.execute(f"DELETE FROM app_backups WHERE {' AND '.join(clauses)}", tuple(params))
return int(cur.rowcount or 0) return int(cur.rowcount or 0)
def maybe_create_automatic_backup(user_id: int | None = None) -> dict | None: def _should_run(settings: dict, last_value: str | None) -> bool:
"""Create an automatic backup when the saved interval has elapsed.
Note: The scheduler calls this periodically, while the UI controls the interval and retention values.
"""
user_id = user_id or default_user_id()
settings = get_auto_backup_settings(user_id)
if not settings.get("enabled"):
return None
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
last_value = settings.get("last_run_at") or _latest_backup_created_at(user_id)
try: try:
last = datetime.fromisoformat(str(last_value).replace("Z", "+00:00")) if last_value else None last = datetime.fromisoformat(str(last_value).replace("Z", "+00:00")) if last_value else None
except Exception: except Exception:
last = None last = None
if last and now - last < timedelta(hours=settings["interval_hours"]): return not last or now - last >= timedelta(hours=settings["interval_hours"])
def maybe_create_automatic_backup(user_id: int | None = None, backup_type: str = "app", profile_id: int | None = None) -> dict | None:
user_id = user_id or default_user_id()
backup_type = "profile" if backup_type == "profile" else "app"
if backup_type == "app" and not _is_admin_user(user_id):
return None
if backup_type == "profile" and (not profile_id or not auth.can_access_profile(int(profile_id), user_id)):
return None
settings = get_auto_backup_settings(user_id, backup_type, profile_id)
if not settings.get("enabled"):
return None
last_value = settings.get("last_run_at") or _latest_backup_created_at(user_id, backup_type, profile_id)
if not _should_run(settings, last_value):
if settings.get("last_run_at") != last_value: if settings.get("last_run_at") != last_value:
settings["last_run_at"] = last_value settings["last_run_at"] = last_value
save_auto_backup_settings(settings, user_id) save_auto_backup_settings(settings, user_id, backup_type, profile_id)
return None return None
backup = create_backup(f"Automatic backup {now.isoformat(timespec='seconds')}", user_id, automatic=True) now = datetime.now(timezone.utc)
if backup_type == "profile":
backup = create_profile_backup(f"Automatic profile backup {now.isoformat(timespec='seconds')}", int(profile_id or 0), user_id, automatic=True)
else:
backup = create_app_backup(f"Automatic application backup {now.isoformat(timespec='seconds')}", user_id, automatic=True)
settings["last_run_at"] = backup.get("created_at") or now.isoformat(timespec="seconds") settings["last_run_at"] = backup.get("created_at") or now.isoformat(timespec="seconds")
save_auto_backup_settings(settings, user_id) save_auto_backup_settings(settings, user_id, backup_type, profile_id)
prune_old_backups(user_id, settings["retention_days"]) prune_old_backups(user_id, settings["retention_days"], backup_type, profile_id)
return backup return backup
def start_scheduler() -> None: def _profile_schedule_keys() -> list[tuple[int, int]]:
"""Start a lightweight automatic-backup scheduler. prefix = f"{AUTO_BACKUP_SETTINGS_KEY}:profile:"
keys: list[tuple[int, int]] = []
with connect() as conn:
rows = conn.execute("SELECT key FROM app_settings WHERE key LIKE ?", (prefix + "%",)).fetchall()
for row in rows:
parts = str(row.get("key") or "").split(":")
try:
keys.append((int(parts[-2]), int(parts[-1])))
except Exception:
continue
return keys
Note: It scans configured users and never blocks normal request handling.
""" def start_scheduler() -> None:
global _scheduler_started global _scheduler_started
with _scheduler_lock: with _scheduler_lock:
if _scheduler_started: if _scheduler_started:
@@ -275,10 +456,12 @@ def start_scheduler() -> None:
while True: while True:
try: try:
with connect() as conn: with connect() as conn:
rows = conn.execute("SELECT id FROM users WHERE is_active=1").fetchall() rows = conn.execute("SELECT id FROM users WHERE is_active=1 AND role='admin'").fetchall()
user_ids = [int(row["id"]) for row in rows] or [default_user_id()] user_ids = [int(row["id"]) for row in rows] or [default_user_id()]
for uid in user_ids: for uid in user_ids:
maybe_create_automatic_backup(uid) maybe_create_automatic_backup(uid, "app")
for uid, pid in _profile_schedule_keys():
maybe_create_automatic_backup(uid, "profile", pid)
except Exception: except Exception:
pass pass
time.sleep(300) time.sleep(300)

View File

@@ -443,11 +443,13 @@ def evaluate(profile: dict, settings: dict | None = None, now: datetime | None =
} }
def enforce(profile: dict, force: bool = False) -> dict: def enforce(profile: dict, force: bool = False, user_id: int | None = None) -> dict:
profile_id = int(profile.get("id") or 0) profile_id = int(profile.get("id") or 0)
settings = get_settings(profile_id) user_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.
settings = get_settings(profile_id, user_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)} return {"ok": True, "enabled": False, "profile_id": profile_id, "history": history(profile_id, 20), "history_total": history_count(profile_id), "preview": preview(profile, user_id=user_id)}
now = time.monotonic() 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:
@@ -497,13 +499,14 @@ def enforce(profile: dict, force: bool = False) -> dict:
_append_history(profile_id, "resumed_torrents", {"count": len(hashes), "dry_run": dry_run}) _append_history(profile_id, "resumed_torrents", {"count": len(hashes), "dry_run": dry_run})
result["history"] = history(profile_id, 20) result["history"] = history(profile_id, 20)
result["history_total"] = history_count(profile_id) result["history_total"] = history_count(profile_id)
result["preview"] = preview(profile) result["preview"] = preview(profile, user_id=user_id)
return result return result
def preview(profile: dict) -> dict: def preview(profile: dict, user_id: int | None = None) -> dict:
profile_id = int(profile.get("id") or 0) profile_id = int(profile.get("id") or 0)
settings = get_settings(profile_id) user_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,

View File

@@ -40,18 +40,76 @@ def google_fonts_css_url() -> str:
return f"https://fonts.googleapis.com/css2?{families}&display=swap" return f"https://fonts.googleapis.com/css2?{families}&display=swap"
BOOTSTRAP_THEMES = ( DEVEXPRESS_BOOTSTRAP_THEMES = {
"default", "blazing-berry": "Blazing Berry",
"flatly", "office-white": "Office White",
"litera", "purple": "Purple",
"lumen", }
"minty",
"sketchy", PYTORRENT_APP_THEMES = {
"solar", "adaptive": "pyTorrent Adaptive",
"spacelab", "ocean": "pyTorrent Ocean",
"united", "graphite": "pyTorrent Graphite",
"zephyr", "forest": "pyTorrent Forest",
) "amber": "pyTorrent Amber",
"nord": "pyTorrent Nord",
"crimson": "pyTorrent Crimson",
"sky": "pyTorrent Sky",
"bootstrap22": "Bootstrap 2 Classic",
"bootstrap22-inverse": "Bootstrap 2 Inverse",
"bootstrap3": "Bootstrap 3 Glyph",
"bootstrap3-inverse": "Bootstrap 3 Inverse",
}
BOOTSTRAP_THEME_DEFINITIONS = {
"default": {
"label": "Default Bootstrap",
"local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/css/bootstrap.min.css",
"cdn": f"https://cdn.jsdelivr.net/npm/bootstrap@{BOOTSTRAP_VERSION}/dist/css/bootstrap.min.css",
},
# Bootswatch themes.
"flatly": {"label": "Bootswatch: Flatly", "provider": "bootswatch"},
"litera": {"label": "Bootswatch: Litera", "provider": "bootswatch"},
"lumen": {"label": "Bootswatch: Lumen", "provider": "bootswatch"},
"minty": {"label": "Bootswatch: Minty", "provider": "bootswatch"},
"sketchy": {"label": "Bootswatch: Sketchy", "provider": "bootswatch"},
"spacelab": {"label": "Bootswatch: Spacelab", "provider": "bootswatch"},
"united": {"label": "Bootswatch: United", "provider": "bootswatch"},
"zephyr": {"label": "Bootswatch: Zephyr", "provider": "bootswatch"},
# Complete DevExpress Bootstrap v5 dist.v5 set.
**{
f"dx-{theme}": {
"label": f"DevExpress: {label}",
"provider": "devexpress",
"local": f"{LIBS_STATIC_DIR}/devexpress-bootstrap-themes/dist.v5/{theme}/bootstrap.min.css",
"cdn": f"https://cdn.jsdelivr.net/gh/DevExpress/bootstrap-themes@master/dist.v5/{theme}/bootstrap.min.css",
}
for theme, label in DEVEXPRESS_BOOTSTRAP_THEMES.items()
},
# App-specific Bootstrap variable overrides. These sit on top of default Bootstrap.
**{
f"pytorrent-{theme}": {
"label": f"Custom: {label}",
"provider": "pytorrent",
"local": f"{LIBS_STATIC_DIR}/pytorrent-themes/{theme}/bootstrap.min.css",
"cdn": f"/static/{LIBS_STATIC_DIR}/pytorrent-themes/{theme}/bootstrap.min.css",
}
for theme, label in PYTORRENT_APP_THEMES.items()
},
}
def _theme_definition(theme: str | None) -> dict[str, str]:
theme = theme if theme in BOOTSTRAP_THEME_DEFINITIONS else "default"
item = dict(BOOTSTRAP_THEME_DEFINITIONS[theme])
if item.get("provider") == "bootswatch":
item["local"] = f"{LIBS_STATIC_DIR}/bootswatch/{BOOTSWATCH_VERSION}/{theme}/bootstrap.min.css"
item["cdn"] = f"https://cdn.jsdelivr.net/npm/bootswatch@{BOOTSWATCH_VERSION}/dist/{theme}/bootstrap.min.css"
return item
BOOTSTRAP_THEMES = tuple(BOOTSTRAP_THEME_DEFINITIONS.keys())
BOOTSTRAP_THEME_LABELS = {key: value["label"] for key, value in BOOTSTRAP_THEME_DEFINITIONS.items()}
STATIC_ASSETS = { STATIC_ASSETS = {
"bootstrap_js": { "bootstrap_js": {
@@ -86,16 +144,8 @@ STATIC_ASSETS = {
def bootstrap_css_asset(theme: str | None = None) -> dict[str, str]: def bootstrap_css_asset(theme: str | None = None) -> dict[str, str]:
theme = theme if theme in BOOTSTRAP_THEMES else "default" item = _theme_definition(theme)
if theme == "default": return {"local": item["local"], "cdn": item["cdn"]}
return {
"local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/css/bootstrap.min.css",
"cdn": f"https://cdn.jsdelivr.net/npm/bootstrap@{BOOTSTRAP_VERSION}/dist/css/bootstrap.min.css",
}
return {
"local": f"{LIBS_STATIC_DIR}/bootswatch/{BOOTSWATCH_VERSION}/{theme}/bootstrap.min.css",
"cdn": f"https://cdn.jsdelivr.net/npm/bootswatch@{BOOTSWATCH_VERSION}/dist/{theme}/bootstrap.min.css",
}
def asset_path(key: str) -> str: def asset_path(key: str) -> str:

View File

@@ -98,7 +98,10 @@ def record_job_event(profile_id: int, action: str, status: str, payload: dict |
severity = "danger" if status == "failed" else "info" severity = "danger" if status == "failed" else "info"
if action in {"add_magnet", "add_torrent_raw"}: if action in {"add_magnet", "add_torrent_raw"}:
name = str(payload.get("name") or payload.get("filename") or payload.get("uri") or "torrent")[:300] name = str(payload.get("name") or payload.get("filename") or payload.get("uri") or "torrent")[:300]
msg = f"{action} {status}: {name}" # Note: Keep the internal action name stable, but show a user-facing label instead of raw worker identifiers.
source_label = "Torrent file" if action == "add_torrent_raw" else "Magnet link"
status_label = {"started": "queued", "done": "added", "failed": "failed"}.get(str(status), str(status))
msg = f"{source_label} {status_label}: {name}"
record(profile_id, "torrent_added" if status == "done" else event_type, msg, severity=severity, source="job", action=action, details={"job_id": job_id, "status": status, "directory": payload.get("directory"), "label": payload.get("label"), "error": error, "result": result}, user_id=user_id) record(profile_id, "torrent_added" if status == "done" else event_type, msg, severity=severity, source="job", action=action, details={"job_id": job_id, "status": status, "directory": payload.get("directory"), "label": payload.get("label"), "error": error, "result": result}, user_id=user_id)
return return
if not hashes: if not hashes:

View File

@@ -0,0 +1,109 @@
from __future__ import annotations
import secrets
import threading
import time
_LINK_TTL_SECONDS = 10 * 60
_TEMPORARY_LINKS: dict[str, dict] = {}
_TEMPORARY_LINK_LOCK = threading.Lock()
def _cleanup_expired(now: float | None = None) -> None:
now = time.time() if now is None else float(now)
expired = [token for token, item in _TEMPORARY_LINKS.items() if float(item.get("expires_at") or 0) <= now]
for token in expired:
_TEMPORARY_LINKS.pop(token, None)
def _create_temporary_link(kind: str, profile_id: int, user_id: int, payload: dict) -> dict:
"""Create a short-lived in-app link target used by preview and download routes."""
# Note: API routes validate the request first, then return an app URL token instead of exposing stable download URLs in the UI.
now = time.time()
token = secrets.token_urlsafe(24)
with _TEMPORARY_LINK_LOCK:
_cleanup_expired(now)
_TEMPORARY_LINKS[token] = {
"kind": str(kind),
"profile_id": int(profile_id),
"user_id": int(user_id),
"expires_at": now + _LINK_TTL_SECONDS,
**payload,
}
return {"token": token, "expires_in": _LINK_TTL_SECONDS}
def create_pdf_preview_link(torrent_hash: str, file_index: int, profile_id: int, user_id: int) -> dict:
"""Create a short-lived in-app PDF preview link without exposing the API download URL."""
# Note: The public link is temporary and points to an app route, while streaming still reuses the existing file reader.
return _create_temporary_link(
"pdf_preview",
profile_id,
user_id,
{"torrent_hash": str(torrent_hash), "file_index": int(file_index)},
)
def create_file_download_link(torrent_hash: str, file_index: int, profile_id: int, user_id: int) -> dict:
"""Create a temporary in-app download link for one torrent file."""
# Note: File downloads use /download/<token> in the UI, but the backend keeps the same rTorrent streaming logic.
return _create_temporary_link(
"file_download",
profile_id,
user_id,
{"torrent_hash": str(torrent_hash), "file_index": int(file_index)},
)
def create_file_zip_download_link(torrent_hash: str, indexes: list[int] | None, profile_id: int, user_id: int) -> dict:
"""Create a temporary in-app download link for a ZIP of torrent files."""
# Note: Selected indexes are stored with the token so the final /download route does not need an API body.
clean_indexes = None if indexes is None else [int(index) for index in indexes]
return _create_temporary_link(
"file_zip_download",
profile_id,
user_id,
{"torrent_hash": str(torrent_hash), "indexes": clean_indexes},
)
def create_torrent_file_download_link(torrent_hash: str, profile_id: int, user_id: int) -> dict:
"""Create a temporary in-app download link for an exported .torrent file."""
# Note: The token hides the stable export API URL from browser-visible download actions.
return _create_temporary_link(
"torrent_file_download",
profile_id,
user_id,
{"torrent_hash": str(torrent_hash)},
)
def create_torrent_files_zip_download_link(hashes: list[str], profile_id: int, user_id: int) -> dict:
"""Create a temporary in-app download link for a ZIP of exported .torrent files."""
# Note: Hashes are copied into the token target after the API validates that the request is non-empty.
return _create_temporary_link(
"torrent_files_zip_download",
profile_id,
user_id,
{"hashes": [str(item) for item in hashes]},
)
def get_temporary_link(token: str) -> dict | None:
"""Return a temporary target if the link is still valid."""
# Note: Expired links are removed on read so stale browser tabs stop resolving automatically.
clean = str(token or "").strip()
if not clean:
return None
with _TEMPORARY_LINK_LOCK:
_cleanup_expired()
item = _TEMPORARY_LINKS.get(clean)
return dict(item) if item else None
def get_pdf_preview_link(token: str) -> dict | None:
"""Return a temporary PDF preview target if the link is still valid."""
item = get_temporary_link(token)
if not item or item.get("kind") != "pdf_preview":
return None
return item

View File

@@ -11,10 +11,11 @@ from ..config import POLL_INTERVAL, MIN_POLL_INTERVAL_SECONDS
DEFAULTS = { DEFAULTS = {
"adaptive_enabled": True, "adaptive_enabled": True,
"safe_fallback_enabled": True, "safe_fallback_enabled": True,
"active_interval_seconds": 5.0, "active_interval_seconds": 3.0,
"idle_interval_seconds": 15.0, "idle_interval_seconds": 15.0,
"error_interval_seconds": 30.0, "error_interval_seconds": 30.0,
"torrent_list_interval_seconds": 5.0, "live_stats_interval_seconds": 3.0,
"torrent_list_interval_seconds": 30.0,
"system_stats_interval_seconds": 5.0, "system_stats_interval_seconds": 5.0,
"tracker_stats_interval_seconds": 300.0, "tracker_stats_interval_seconds": 300.0,
"disk_stats_interval_seconds": 60.0, "disk_stats_interval_seconds": 60.0,
@@ -27,6 +28,20 @@ DEFAULTS = {
"recovery_after_errors": 3, "recovery_after_errors": 3,
} }
SAFE_FALLBACK_MINIMUMS = {
"active_interval_seconds": 3.0,
"idle_interval_seconds": 15.0,
"error_interval_seconds": 30.0,
"live_stats_interval_seconds": 3.0,
"torrent_list_interval_seconds": 30.0,
"system_stats_interval_seconds": 5.0,
"tracker_stats_interval_seconds": 300.0,
"disk_stats_interval_seconds": 60.0,
"queue_stats_interval_seconds": 15.0,
"slow_stats_interval_seconds": 60.0,
"heartbeat_interval_seconds": 15.0,
}
def _key(profile_id: int) -> str: def _key(profile_id: int) -> str:
return f"poller.settings.{int(profile_id)}" return f"poller.settings.{int(profile_id)}"
@@ -52,6 +67,7 @@ def normalize_settings(data: dict | None) -> dict:
"active_interval_seconds": _coerce_float(raw.get("active_interval_seconds"), DEFAULTS["active_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 30.0), "active_interval_seconds": _coerce_float(raw.get("active_interval_seconds"), DEFAULTS["active_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 30.0),
"idle_interval_seconds": _coerce_float(raw.get("idle_interval_seconds"), DEFAULTS["idle_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 120.0), "idle_interval_seconds": _coerce_float(raw.get("idle_interval_seconds"), DEFAULTS["idle_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 120.0),
"error_interval_seconds": _coerce_float(raw.get("error_interval_seconds"), DEFAULTS["error_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 300.0), "error_interval_seconds": _coerce_float(raw.get("error_interval_seconds"), DEFAULTS["error_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 300.0),
"live_stats_interval_seconds": _coerce_float(raw.get("live_stats_interval_seconds"), DEFAULTS["live_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 60.0),
"torrent_list_interval_seconds": _coerce_float(raw.get("torrent_list_interval_seconds"), DEFAULTS["torrent_list_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 120.0), "torrent_list_interval_seconds": _coerce_float(raw.get("torrent_list_interval_seconds"), DEFAULTS["torrent_list_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 120.0),
"system_stats_interval_seconds": _coerce_float(raw.get("system_stats_interval_seconds"), DEFAULTS["system_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 120.0), "system_stats_interval_seconds": _coerce_float(raw.get("system_stats_interval_seconds"), DEFAULTS["system_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 120.0),
"tracker_stats_interval_seconds": _coerce_float(raw.get("tracker_stats_interval_seconds"), DEFAULTS["tracker_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 1800.0), "tracker_stats_interval_seconds": _coerce_float(raw.get("tracker_stats_interval_seconds"), DEFAULTS["tracker_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 1800.0),
@@ -65,17 +81,27 @@ def normalize_settings(data: dict | None) -> dict:
"recovery_after_errors": int(_coerce_float(raw.get("recovery_after_errors"), 3, 1, 20)), "recovery_after_errors": int(_coerce_float(raw.get("recovery_after_errors"), 3, 1, 20)),
} }
if settings["safe_fallback_enabled"]: if settings["safe_fallback_enabled"]:
for key in ("active_interval_seconds", "idle_interval_seconds", "error_interval_seconds", "torrent_list_interval_seconds", "system_stats_interval_seconds", "queue_stats_interval_seconds"): # Note: Safe fallback keeps existing functionality, but prevents very aggressive polling from overloading rTorrent or the browser.
if settings[key] <= 0: for key, minimum in SAFE_FALLBACK_MINIMUMS.items():
settings[key] = DEFAULTS[key] settings[key] = max(float(settings.get(key) or DEFAULTS[key]), float(minimum))
return settings return settings
def get_settings(profile_id: int) -> dict: def get_settings(profile_id: int) -> dict:
with connect() as conn: with connect() as conn:
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (_key(profile_id),)).fetchone() row = conn.execute("SELECT settings_json FROM poller_settings WHERE profile_id=?", (int(profile_id),)).fetchone()
if not row:
# Note: Existing installs stored profile poller settings in app_settings; migrate lazily on first read.
legacy = conn.execute("SELECT value FROM app_settings WHERE key=?", (_key(profile_id),)).fetchone()
if legacy:
try:
settings = normalize_settings(json.loads(legacy.get("value") or "{}"))
except Exception:
settings = normalize_settings({})
conn.execute("INSERT OR REPLACE INTO poller_settings(profile_id,settings_json,updated_at) VALUES(?,?,?)", (int(profile_id), json.dumps(settings), utcnow()))
return settings
try: try:
data = json.loads(row.get("value") or "{}") if row else {} data = json.loads(row.get("settings_json") or "{}") if row else {}
except Exception: except Exception:
data = {} data = {}
return normalize_settings(data) return normalize_settings(data)
@@ -84,7 +110,7 @@ def get_settings(profile_id: int) -> dict:
def save_settings(profile_id: int, data: dict) -> dict: def save_settings(profile_id: int, data: dict) -> dict:
settings = normalize_settings(data) settings = normalize_settings(data)
with connect() as conn: with connect() as conn:
conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (_key(profile_id), json.dumps(settings))) conn.execute("INSERT OR REPLACE INTO poller_settings(profile_id,settings_json,updated_at) VALUES(?,?,?)", (int(profile_id), json.dumps(settings), utcnow()))
return settings return settings
@@ -92,6 +118,8 @@ def save_settings(profile_id: int, data: dict) -> dict:
class ProfilePollState: class ProfilePollState:
profile_id: int profile_id: int
last_fast_at: float = 0.0 last_fast_at: float = 0.0
last_live_at: float = 0.0
last_list_at: float = 0.0
last_system_at: float = 0.0 last_system_at: float = 0.0
last_slow_at: float = 0.0 last_slow_at: float = 0.0
last_tracker_at: float = 0.0 last_tracker_at: float = 0.0
@@ -112,6 +140,24 @@ class ProfilePollState:
skipped_emissions: int = 0 skipped_emissions: int = 0
emitted_payload_size: int = 0 emitted_payload_size: int = 0
rtorrent_call_count: int = 0 rtorrent_call_count: int = 0
live_poll_count: int = 0
list_poll_count: int = 0
live_updated_total: int = 0
live_full_refresh_requested_total: int = 0
list_added_total: int = 0
list_updated_total: int = 0
list_removed_total: int = 0
last_live_duration_ms: float = 0.0
last_list_duration_ms: float = 0.0
last_live_updated_count: int = 0
last_list_added_count: int = 0
last_list_updated_count: int = 0
last_list_removed_count: int = 0
last_live_ok: bool = True
last_list_ok: bool = True
last_live_error: str = ""
last_list_error: str = ""
last_live_requires_full_refresh: bool = False
adaptive_mode: str = "normal" adaptive_mode: str = "normal"
slow_task_running: bool = False slow_task_running: bool = False
system_task_running: bool = False system_task_running: bool = False
@@ -141,12 +187,29 @@ def interval_for(settings: dict, state: ProfilePollState) -> float:
return base return base
def effective_live_interval(settings: dict, state: ProfilePollState) -> float:
return max(MIN_POLL_INTERVAL_SECONDS, interval_for(settings, state), float(settings.get("live_stats_interval_seconds") or DEFAULTS["live_stats_interval_seconds"]))
def effective_list_interval(settings: dict, state: ProfilePollState) -> float:
return max(MIN_POLL_INTERVAL_SECONDS, float(settings.get("torrent_list_interval_seconds") or DEFAULTS["torrent_list_interval_seconds"]))
def effective_fast_interval(settings: dict, state: ProfilePollState) -> float: def effective_fast_interval(settings: dict, state: ProfilePollState) -> float:
return max(MIN_POLL_INTERVAL_SECONDS, interval_for(settings, state), float(settings.get("torrent_list_interval_seconds") or DEFAULTS["torrent_list_interval_seconds"])) # Note: Kept for compatibility with older diagnostics; the fast interval now means lightweight live stats.
return effective_live_interval(settings, state)
def should_live_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
return (now - state.last_live_at) >= effective_live_interval(settings, state)
def should_list_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
return (now - state.last_list_at) >= effective_list_interval(settings, state)
def should_fast_poll(now: float, settings: dict, state: ProfilePollState) -> bool: def should_fast_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
return (now - state.last_fast_at) >= effective_fast_interval(settings, state) return should_live_poll(now, settings, state)
def should_system_poll(now: float, settings: dict, state: ProfilePollState) -> bool: def should_system_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
@@ -175,6 +238,69 @@ def should_heartbeat(now: float, settings: dict, state: ProfilePollState, change
return (now - state.last_heartbeat_at) >= float(settings["heartbeat_interval_seconds"]) return (now - state.last_heartbeat_at) >= float(settings["heartbeat_interval_seconds"])
def mark_live_poll(state: ProfilePollState, started_at: float, ok: bool, error: str = "", updated_count: int = 0, requires_full_refresh: bool = False) -> None:
now = time.monotonic()
# Note: Live poller diagnostics track only lightweight speed/status refreshes, not the full torrent snapshot loop.
state.live_poll_count += 1
state.last_live_duration_ms = round((now - started_at) * 1000.0, 2)
state.last_live_updated_count = int(updated_count or 0)
state.live_updated_total += int(updated_count or 0)
state.last_live_requires_full_refresh = bool(requires_full_refresh)
if requires_full_refresh:
state.live_full_refresh_requested_total += 1
state.last_live_ok = bool(ok)
state.last_live_error = str(error or "")
def mark_list_poll(state: ProfilePollState, started_at: float, ok: bool, error: str = "", added_count: int = 0, updated_count: int = 0, removed_count: int = 0) -> None:
now = time.monotonic()
# Note: List poller diagnostics are separate because this slower loop runs full torrent snapshot reconciliation.
state.list_poll_count += 1
state.last_list_duration_ms = round((now - started_at) * 1000.0, 2)
state.last_list_added_count = int(added_count or 0)
state.last_list_updated_count = int(updated_count or 0)
state.last_list_removed_count = int(removed_count or 0)
state.list_added_total += int(added_count or 0)
state.list_updated_total += int(updated_count or 0)
state.list_removed_total += int(removed_count or 0)
state.last_list_ok = bool(ok)
state.last_list_error = str(error or "")
def reset_runtime_stats(profile_id: int) -> dict:
state = state_for(profile_id)
# Note: Cleanup resets diagnostic counters only; poller timers and saved settings keep running unchanged.
state.tick_count = 0
state.last_tick_ms = 0.0
state.last_tick_gap_ms = 0.0
state.last_tick_started_at = 0.0
state.error_count = 0
state.slow_count = 0
state.skipped_emissions = 0
state.emitted_payload_size = 0
state.rtorrent_call_count = 0
state.live_poll_count = 0
state.list_poll_count = 0
state.live_updated_total = 0
state.live_full_refresh_requested_total = 0
state.list_added_total = 0
state.list_updated_total = 0
state.list_removed_total = 0
state.last_live_duration_ms = 0.0
state.last_list_duration_ms = 0.0
state.last_live_updated_count = 0
state.last_list_added_count = 0
state.last_list_updated_count = 0
state.last_list_removed_count = 0
state.last_live_ok = True
state.last_list_ok = True
state.last_live_error = ""
state.last_list_error = ""
state.last_live_requires_full_refresh = False
state.stats = {}
return snapshot(profile_id)
def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool, error: str = "", emitted_payload_size: int = 0, rtorrent_call_count: int = 0, skipped_emissions: int = 0, settings: dict | None = None) -> dict: def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool, error: str = "", emitted_payload_size: int = 0, rtorrent_call_count: int = 0, skipped_emissions: int = 0, settings: dict | None = None) -> dict:
now = time.monotonic() now = time.monotonic()
effective_settings = normalize_settings(settings) if settings is not None else DEFAULTS effective_settings = normalize_settings(settings) if settings is not None else DEFAULTS
@@ -184,7 +310,7 @@ def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool
state.last_tick_gap_ms = round((started_at - previous_started_at) * 1000.0, 2) if previous_started_at else 0.0 state.last_tick_gap_ms = round((started_at - previous_started_at) * 1000.0, 2) if previous_started_at else 0.0
state.last_tick_started_at = started_at state.last_tick_started_at = started_at
state.last_active = bool(active) state.last_active = bool(active)
state.effective_interval_seconds = effective_fast_interval(effective_settings, state) state.effective_interval_seconds = effective_live_interval(effective_settings, state)
state.last_ok = bool(ok) state.last_ok = bool(ok)
state.last_error = str(error or "") state.last_error = str(error or "")
state.emitted_payload_size = int(emitted_payload_size or 0) state.emitted_payload_size = int(emitted_payload_size or 0)
@@ -224,6 +350,8 @@ def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool
"last_ok": state.last_ok, "last_ok": state.last_ok,
"last_tick_gap_ms": state.last_tick_gap_ms, "last_tick_gap_ms": state.last_tick_gap_ms,
"effective_interval_seconds": state.effective_interval_seconds, "effective_interval_seconds": state.effective_interval_seconds,
"live_stats_interval_seconds": effective_live_interval(effective_settings, state),
"torrent_list_interval_seconds": effective_list_interval(effective_settings, state),
"configured_min_interval_seconds": MIN_POLL_INTERVAL_SECONDS, "configured_min_interval_seconds": MIN_POLL_INTERVAL_SECONDS,
"last_error": state.last_error, "last_error": state.last_error,
"duration_ms": state.last_tick_ms, "duration_ms": state.last_tick_ms,
@@ -234,6 +362,24 @@ def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool
"adaptive_mode": state.adaptive_mode, "adaptive_mode": state.adaptive_mode,
"error_count": state.error_count, "error_count": state.error_count,
"slow_count": state.slow_count, "slow_count": state.slow_count,
"live_poll_count": state.live_poll_count,
"list_poll_count": state.list_poll_count,
"last_live_duration_ms": state.last_live_duration_ms,
"last_list_duration_ms": state.last_list_duration_ms,
"last_live_updated_count": state.last_live_updated_count,
"last_list_added_count": state.last_list_added_count,
"last_list_updated_count": state.last_list_updated_count,
"last_list_removed_count": state.last_list_removed_count,
"live_updated_total": state.live_updated_total,
"list_added_total": state.list_added_total,
"list_updated_total": state.list_updated_total,
"list_removed_total": state.list_removed_total,
"live_full_refresh_requested_total": state.live_full_refresh_requested_total,
"last_live_requires_full_refresh": state.last_live_requires_full_refresh,
"last_live_ok": state.last_live_ok,
"last_list_ok": state.last_list_ok,
"last_live_error": state.last_live_error,
"last_list_error": state.last_list_error,
"updated_at": utcnow(), "updated_at": utcnow(),
} }
return dict(state.stats) return dict(state.stats)
@@ -241,4 +387,26 @@ def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool
def snapshot(profile_id: int) -> dict: def snapshot(profile_id: int) -> dict:
state = state_for(profile_id) state = state_for(profile_id)
return dict(state.stats or {"profile_id": int(profile_id), "tick_count": state.tick_count}) data = dict(state.stats or {"profile_id": int(profile_id), "tick_count": state.tick_count})
# Note: Snapshot always exposes split-poller counters, even before the first post-cleanup tick rebuilds full stats.
data.update({
"live_poll_count": state.live_poll_count,
"list_poll_count": state.list_poll_count,
"last_live_duration_ms": state.last_live_duration_ms,
"last_list_duration_ms": state.last_list_duration_ms,
"last_live_updated_count": state.last_live_updated_count,
"last_list_added_count": state.last_list_added_count,
"last_list_updated_count": state.last_list_updated_count,
"last_list_removed_count": state.last_list_removed_count,
"live_updated_total": state.live_updated_total,
"list_added_total": state.list_added_total,
"list_updated_total": state.list_updated_total,
"list_removed_total": state.list_removed_total,
"live_full_refresh_requested_total": state.live_full_refresh_requested_total,
"last_live_requires_full_refresh": state.last_live_requires_full_refresh,
"last_live_ok": state.last_live_ok,
"last_list_ok": state.last_list_ok,
"last_live_error": state.last_live_error,
"last_list_error": state.last_list_error,
})
return data

View File

@@ -4,19 +4,9 @@ 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
BOOTSTRAP_THEMES = { BOOTSTRAP_THEMES = BOOTSTRAP_THEME_LABELS
"default": "Default Bootstrap",
"flatly": "Flatly",
"litera": "Litera",
"lumen": "Lumen",
"minty": "Minty",
"sketchy": "Sketchy",
"solar": "Solar",
"spacelab": "Spacelab",
"united": "United",
"zephyr": "Zephyr",
}
FONT_FAMILIES = { FONT_FAMILIES = {
"default": "Theme default", "default": "Theme default",
@@ -68,17 +58,21 @@ def recommended_table_columns_json() -> str:
return json.dumps(RECOMMENDED_TABLE_COLUMNS, separators=(",", ":")) return json.dumps(RECOMMENDED_TABLE_COLUMNS, separators=(",", ":"))
def apply_recommended_table_columns(user_id: int | None = None): def apply_recommended_table_columns(user_id: int | None = None, profile_id: int | None = None):
user_id = user_id or auth.current_user_id() or default_user_id() user_id = user_id or auth.current_user_id() or default_user_id()
get_preferences(user_id) profile_id = profile_id or _active_profile_id_for_user(user_id)
if not profile_id:
return get_preferences(user_id)
get_preferences(user_id, profile_id)
now = utcnow() now = utcnow()
value = recommended_table_columns_json() value = recommended_table_columns_json()
with connect() as conn: with connect() as conn:
conn.execute( conn.execute(
"UPDATE user_preferences SET table_columns_json=?, updated_at=? WHERE user_id=?", "INSERT INTO profile_preferences(user_id,profile_id,table_columns_json,created_at,updated_at) VALUES(?,?,?,?,?) "
(value, now, user_id), "ON CONFLICT(user_id,profile_id) DO UPDATE SET table_columns_json=excluded.table_columns_json, updated_at=excluded.updated_at",
(user_id, profile_id, value, now, now),
) )
return get_preferences(user_id) return get_preferences(user_id, profile_id)
def bootstrap_css_url(theme: str | None) -> str: def bootstrap_css_url(theme: str | None) -> str:
from .frontend_assets import bootstrap_css_path from .frontend_assets import bootstrap_css_path
@@ -94,6 +88,15 @@ def _int_setting(data: dict, key: str, default: int, minimum: int, maximum: int)
return max(minimum, min(maximum, value)) return max(minimum, min(maximum, value))
def _url_setting(data: dict, key: str, default: str = "") -> str:
value = str(data.get(key) if data.get(key) is not None else default).strip()
if len(value) > 2048:
value = value[:2048]
if value and not (value.startswith("https://") or value.startswith("http://")):
return ""
return value
def list_profiles(user_id: int | None = None): def list_profiles(user_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()
visible = auth.visible_profile_ids(user_id) visible = auth.visible_profile_ids(user_id)
@@ -128,6 +131,10 @@ def active_profile(user_id: int | None = None):
if row: if row:
return row return row
profiles = list_profiles(user_id) profiles = list_profiles(user_id)
# Note: Trusted auth-bypass access must choose a profile explicitly on first entry,
# instead of silently reusing the first configured profile.
if auth.auth_bypassed_request() and profiles:
return None
return profiles[0] if profiles else None return profiles[0] if profiles else None
@@ -323,42 +330,150 @@ def save_disk_monitor_preferences(profile_id: int | None, data: dict, user_id: i
return clean return clean
PROFILE_PREFERENCE_COLUMNS = {
"table_columns_json",
"torrent_sort_json",
"active_filter",
"peers_refresh_seconds",
"port_check_enabled",
"tracker_favicons_enabled",
"reverse_dns_enabled",
}
def _seed_profile_preferences(conn, user_id: int, profile_id: int) -> dict:
now = utcnow()
legacy = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone() or {}
row = conn.execute("SELECT * FROM profile_preferences WHERE user_id=? AND profile_id=?", (user_id, profile_id)).fetchone()
if row:
return dict(row)
# Note: First profile preference row is seeded from legacy user-level values so upgrades keep the current layout/filter behavior.
conn.execute(
"INSERT INTO profile_preferences(user_id,profile_id,table_columns_json,torrent_sort_json,active_filter,peers_refresh_seconds,port_check_enabled,tracker_favicons_enabled,reverse_dns_enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
(
user_id,
profile_id,
legacy.get("table_columns_json"),
legacy.get("torrent_sort_json"),
legacy.get("active_filter") or "all",
int(legacy.get("peers_refresh_seconds") or 0),
int(legacy.get("port_check_enabled") or 0),
int(legacy.get("tracker_favicons_enabled") or 0),
int(legacy.get("reverse_dns_enabled") or 0),
now,
now,
),
)
return dict(conn.execute("SELECT * FROM profile_preferences WHERE user_id=? AND profile_id=?", (user_id, profile_id)).fetchone() or {})
def get_profile_preferences(user_id: int, profile_id: int | None) -> dict:
if not profile_id:
return {}
with connect() as conn:
return _seed_profile_preferences(conn, user_id, int(profile_id))
def save_profile_preferences(user_id: int, profile_id: int | None, data: dict) -> None:
if not profile_id:
return
profile_id = int(profile_id)
now = utcnow()
with connect() as conn:
current = _seed_profile_preferences(conn, user_id, profile_id)
updates: dict[str, object] = {}
if data.get("table_columns_json") is not None:
updates["table_columns_json"] = str(data.get("table_columns_json"))
if data.get("peers_refresh_seconds") is not None:
sec = int(data.get("peers_refresh_seconds") or 0)
updates["peers_refresh_seconds"] = sec if sec in {0, 10, 15, 30, 60} else 0
if data.get("port_check_enabled") is not None:
updates["port_check_enabled"] = 1 if data.get("port_check_enabled") else 0
if data.get("tracker_favicons_enabled") is not None:
updates["tracker_favicons_enabled"] = 1 if data.get("tracker_favicons_enabled") else 0
if data.get("reverse_dns_enabled") is not None:
# Note: Reverse DNS is stored per profile because PTR lookups depend on swarm size and profile network latency.
updates["reverse_dns_enabled"] = 1 if data.get("reverse_dns_enabled") else 0
if data.get("torrent_sort_json") is not None:
value = data.get("torrent_sort_json") if isinstance(data.get("torrent_sort_json"), str) else json.dumps(data.get("torrent_sort_json"))
parsed = json.loads(value or "{}")
if not isinstance(parsed, dict):
parsed = {}
try:
direction = int(parsed.get("dir") or 1)
except (TypeError, ValueError):
direction = 1
allowed_sort_keys = {"name", "status", "size", "progress", "down_rate", "up_rate", "eta", "seeds", "peers", "ratio", "path", "label", "ratio_group", "down_total", "to_download", "up_total", "created", "priority", "state", "active", "complete", "hashing", "message", "hash"}
sort_key = str(parsed.get("key") or "name")
if sort_key not in allowed_sort_keys:
sort_key = "name"
updates["torrent_sort_json"] = json.dumps({"key": sort_key, "dir": 1 if direction >= 0 else -1})
if data.get("active_filter") is not None:
value = str(data.get("active_filter") or "all").strip()
if not value or len(value) > 180:
value = "all"
allowed_static_filters = {"all", "downloading", "seeding", "paused", "checking", "error", "stopped", "moving"}
if value not in allowed_static_filters and not value.startswith("label:") and not value.startswith("tracker:"):
value = "all"
updates["active_filter"] = value
if not updates:
return
merged = {**current, **updates}
conn.execute(
"INSERT INTO profile_preferences(user_id,profile_id,table_columns_json,torrent_sort_json,active_filter,peers_refresh_seconds,port_check_enabled,tracker_favicons_enabled,reverse_dns_enabled,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",
(
user_id,
profile_id,
merged.get("table_columns_json"),
merged.get("torrent_sort_json"),
merged.get("active_filter") or "all",
int(merged.get("peers_refresh_seconds") or 0),
int(merged.get("port_check_enabled") or 0),
int(merged.get("tracker_favicons_enabled") or 0),
int(merged.get("reverse_dns_enabled") or 0),
merged.get("created_at") or now,
now,
),
)
def get_preferences(user_id: int | None = None, profile_id: int | None = None): def get_preferences(user_id: int | None = None, profile_id: int | None = None):
user_id = user_id or auth.current_user_id() or default_user_id() user_id = user_id or auth.current_user_id() or default_user_id()
profile_id = profile_id or _active_profile_id_for_user(user_id)
with connect() as conn: with connect() as conn:
pref = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone() pref = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone()
if not pref: if not pref:
now = utcnow() now = utcnow()
conn.execute("INSERT INTO user_preferences(user_id, theme, created_at, updated_at) VALUES(?, 'dark', ?, ?)", (user_id, now, now)) conn.execute("INSERT INTO user_preferences(user_id, theme, created_at, updated_at) VALUES(?, 'dark', ?, ?)", (user_id, now, now))
pref = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone() pref = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone()
merged = dict(pref or {}) merged = dict(pref or {})
if profile_id:
merged.update(_seed_profile_preferences(conn, user_id, int(profile_id)))
merged.update(get_disk_monitor_preferences(profile_id, user_id)) 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):
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)
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
table_columns_json = data.get("table_columns_json")
peers_refresh_seconds = data.get("peers_refresh_seconds")
port_check_enabled = data.get("port_check_enabled")
footer_items_json = data.get("footer_items_json") footer_items_json = data.get("footer_items_json")
title_speed_enabled = data.get("title_speed_enabled") title_speed_enabled = data.get("title_speed_enabled")
tracker_favicons_enabled = data.get("tracker_favicons_enabled")
reverse_dns_enabled = data.get("reverse_dns_enabled")
automation_toasts_enabled = data.get("automation_toasts_enabled") automation_toasts_enabled = data.get("automation_toasts_enabled")
smart_queue_toasts_enabled = data.get("smart_queue_toasts_enabled") smart_queue_toasts_enabled = data.get("smart_queue_toasts_enabled")
easter_egg_enabled = data.get("easter_egg_enabled")
easter_egg_loading_image_url = data.get("easter_egg_loading_image_url")
easter_egg_click_image_url = data.get("easter_egg_click_image_url")
disk_monitor_paths_json = data.get("disk_monitor_paths_json") disk_monitor_paths_json = data.get("disk_monitor_paths_json")
disk_monitor_mode = data.get("disk_monitor_mode") disk_monitor_mode = data.get("disk_monitor_mode")
disk_monitor_selected_path = data.get("disk_monitor_selected_path") disk_monitor_selected_path = data.get("disk_monitor_selected_path")
disk_monitor_stop_enabled = data.get("disk_monitor_stop_enabled") disk_monitor_stop_enabled = data.get("disk_monitor_stop_enabled")
disk_monitor_stop_threshold = data.get("disk_monitor_stop_threshold") disk_monitor_stop_threshold = data.get("disk_monitor_stop_threshold")
interface_scale = data.get("interface_scale") interface_scale = data.get("interface_scale")
compact_torrent_list_enabled = data.get("compact_torrent_list_enabled")
detail_panel_height = data.get("detail_panel_height") detail_panel_height = data.get("detail_panel_height")
torrent_sort_json = data.get("torrent_sort_json")
active_filter = data.get("active_filter")
disk_payload = None disk_payload = None
if any(value is not None for value in (disk_monitor_paths_json, disk_monitor_mode, disk_monitor_selected_path, disk_monitor_stop_enabled, disk_monitor_stop_threshold)): if any(value is not None for value in (disk_monitor_paths_json, disk_monitor_mode, disk_monitor_selected_path, disk_monitor_stop_enabled, disk_monitor_stop_threshold)):
disk_payload = { disk_payload = {
@@ -376,32 +491,28 @@ def save_preferences(data: dict, user_id: int | None = None):
conn.execute("UPDATE user_preferences SET bootstrap_theme=?, updated_at=? WHERE user_id=?", (bootstrap_theme, now, user_id)) conn.execute("UPDATE user_preferences SET bootstrap_theme=?, updated_at=? WHERE user_id=?", (bootstrap_theme, now, user_id))
if font_family: if font_family:
conn.execute("UPDATE user_preferences SET font_family=?, updated_at=? WHERE user_id=?", (font_family, now, user_id)) conn.execute("UPDATE user_preferences SET font_family=?, updated_at=? WHERE user_id=?", (font_family, now, user_id))
if table_columns_json is not None:
conn.execute("UPDATE user_preferences SET table_columns_json=?, updated_at=? WHERE user_id=?", (str(table_columns_json), now, user_id))
if peers_refresh_seconds is not None:
sec = int(peers_refresh_seconds or 0)
if sec not in {0, 10, 15, 30, 60}: sec = 0
conn.execute("UPDATE user_preferences SET peers_refresh_seconds=?, updated_at=? WHERE user_id=?", (sec, now, user_id))
if port_check_enabled is not None:
conn.execute("UPDATE user_preferences SET port_check_enabled=?, updated_at=? WHERE user_id=?", (1 if port_check_enabled else 0, now, user_id))
if title_speed_enabled is not None: if title_speed_enabled is not None:
conn.execute("UPDATE user_preferences SET title_speed_enabled=?, updated_at=? WHERE user_id=?", (1 if title_speed_enabled else 0, now, user_id)) conn.execute("UPDATE user_preferences SET title_speed_enabled=?, updated_at=? WHERE user_id=?", (1 if title_speed_enabled else 0, now, user_id))
if tracker_favicons_enabled is not None:
conn.execute("UPDATE user_preferences SET tracker_favicons_enabled=?, updated_at=? WHERE user_id=?", (1 if tracker_favicons_enabled else 0, now, user_id))
if reverse_dns_enabled is not None:
# Note: Reverse DNS is optional because peer PTR lookups can add latency on busy swarms.
conn.execute("UPDATE user_preferences SET reverse_dns_enabled=?, updated_at=? WHERE user_id=?", (1 if reverse_dns_enabled else 0, now, user_id))
if automation_toasts_enabled is not None: if automation_toasts_enabled is not None:
# Note: Lets users silence automation-created toast noise without hiding job/history data. # Note: Lets users silence automation-created toast noise without hiding job/history data.
conn.execute("UPDATE user_preferences SET automation_toasts_enabled=?, updated_at=? WHERE user_id=?", (1 if automation_toasts_enabled else 0, now, user_id)) conn.execute("UPDATE user_preferences SET automation_toasts_enabled=?, updated_at=? WHERE user_id=?", (1 if automation_toasts_enabled else 0, now, user_id))
if smart_queue_toasts_enabled is not None: if smart_queue_toasts_enabled is not None:
# Note: Smart Queue toast noise can be disabled independently from automation notifications. # Note: Smart Queue toast noise can be disabled independently from automation notifications.
conn.execute("UPDATE user_preferences SET smart_queue_toasts_enabled=?, updated_at=? WHERE user_id=?", (1 if smart_queue_toasts_enabled else 0, now, user_id)) conn.execute("UPDATE user_preferences SET smart_queue_toasts_enabled=?, updated_at=? WHERE user_id=?", (1 if smart_queue_toasts_enabled else 0, now, user_id))
if easter_egg_enabled is not None:
conn.execute("UPDATE user_preferences SET easter_egg_enabled=?, updated_at=? WHERE user_id=?", (1 if easter_egg_enabled else 0, now, user_id))
if easter_egg_loading_image_url is not None:
conn.execute("UPDATE user_preferences SET easter_egg_loading_image_url=?, updated_at=? WHERE user_id=?", (_url_setting(data, "easter_egg_loading_image_url"), now, user_id))
if easter_egg_click_image_url is not None:
conn.execute("UPDATE user_preferences SET easter_egg_click_image_url=?, updated_at=? WHERE user_id=?", (_url_setting(data, "easter_egg_click_image_url"), now, user_id))
if interface_scale is not None: if interface_scale is not None:
scale = int(interface_scale or 100) scale = int(interface_scale or 100)
if scale < 80: scale = 80 if scale < 80: scale = 80
if scale > 140: scale = 140 if scale > 140: scale = 140
conn.execute("UPDATE user_preferences SET interface_scale=?, updated_at=? WHERE user_id=?", (scale, now, user_id)) conn.execute("UPDATE user_preferences SET interface_scale=?, updated_at=? WHERE user_id=?", (scale, now, user_id))
if compact_torrent_list_enabled is not None:
# Note: Compact torrent list is a visual-only preference for desktop and mobile list density.
conn.execute("UPDATE user_preferences SET compact_torrent_list_enabled=?, updated_at=? WHERE user_id=?", (1 if compact_torrent_list_enabled else 0, now, user_id))
if footer_items_json is not None: if footer_items_json is not None:
# Note: Store only JSON objects so footer visibility can be extended without schema churn. # Note: Store only JSON objects so footer visibility can be extended without schema churn.
value = footer_items_json if isinstance(footer_items_json, str) else json.dumps(footer_items_json) value = footer_items_json if isinstance(footer_items_json, str) else json.dumps(footer_items_json)
@@ -417,30 +528,7 @@ def save_preferences(data: dict, user_id: int | None = None):
if height < 160: height = 160 if height < 160: height = 160
if height > 720: height = 720 if height > 720: height = 720
conn.execute("UPDATE user_preferences SET detail_panel_height=?, updated_at=? WHERE user_id=?", (height, now, user_id)) conn.execute("UPDATE user_preferences SET detail_panel_height=?, updated_at=? WHERE user_id=?", (height, now, user_id))
if torrent_sort_json is not None: save_profile_preferences(user_id, profile_id, data)
# Note: Persist only a compact sort object; unknown keys are ignored on the client.
value = torrent_sort_json if isinstance(torrent_sort_json, str) else json.dumps(torrent_sort_json)
parsed = json.loads(value or "{}")
if not isinstance(parsed, dict):
parsed = {}
try:
direction = int(parsed.get("dir") or 1)
except (TypeError, ValueError):
direction = 1
allowed_sort_keys = {"name", "status", "size", "progress", "down_rate", "up_rate", "eta", "seeds", "peers", "ratio", "path", "label", "ratio_group", "down_total", "to_download", "up_total", "created", "priority", "state", "active", "complete", "hashing", "message", "hash"}
sort_key = str(parsed.get("key") or "name")
if sort_key not in allowed_sort_keys:
sort_key = "name"
clean = {"key": sort_key, "dir": 1 if direction >= 0 else -1}
conn.execute("UPDATE user_preferences SET torrent_sort_json=?, updated_at=? WHERE user_id=?", (json.dumps(clean), now, user_id))
if active_filter is not None:
value = str(active_filter or "all").strip()
if not value or len(value) > 180:
value = "all"
allowed_static_filters = {"all", "downloading", "seeding", "paused", "checking", "error", "stopped", "moving"}
if value not in allowed_static_filters and not value.startswith("label:") and not value.startswith("tracker:"):
value = "all"
conn.execute("UPDATE user_preferences SET active_filter=?, updated_at=? WHERE user_id=?", (value, now, user_id))
if disk_payload is not None: if disk_payload is not None:
save_disk_monitor_preferences(_active_profile_id_for_user(user_id), disk_payload, user_id) save_disk_monitor_preferences(profile_id, disk_payload, user_id)
return get_preferences(user_id) return get_preferences(user_id, profile_id)

View File

@@ -8,7 +8,7 @@ from datetime import datetime, timezone, timedelta
from email.utils import parsedate_to_datetime from email.utils import parsedate_to_datetime
from typing import Iterable from typing import Iterable
from ..db import connect, utcnow, default_user_id from ..db import connect, utcnow
from . import rtorrent from . import rtorrent
from .workers import enqueue from .workers import enqueue
@@ -122,12 +122,12 @@ def matches_rule(rule: dict, item: dict) -> tuple[bool, str]:
return True, "matched" return True, "matched"
def _log(user_id: int, profile_id: int, feed_id: int | None, rule_id: int | None, item: dict, status: str, message: str) -> None: def _log(profile_id: int, feed_id: int | None, rule_id: int | None, item: dict, status: str, message: str) -> None:
with connect() as conn: with connect() as conn:
try: try:
conn.execute( conn.execute(
"INSERT INTO rss_history(user_id,profile_id,feed_id,rule_id,title,link,status,message,created_at) VALUES(?,?,?,?,?,?,?,?,?)", "INSERT INTO rss_history(profile_id,feed_id,rule_id,title,link,status,message,created_at) VALUES(?,?,?,?,?,?,?,?)",
(user_id, profile_id, feed_id, rule_id, item.get("title"), item.get("link"), status, message, utcnow()), (profile_id, feed_id, rule_id, item.get("title"), item.get("link"), status, message, utcnow()),
) )
except Exception: except Exception:
# Note: Duplicate successful RSS matches are ignored to prevent recurring duplicate downloads. # Note: Duplicate successful RSS matches are ignored to prevent recurring duplicate downloads.
@@ -135,15 +135,14 @@ def _log(user_id: int, profile_id: int, feed_id: int | None, rule_id: int | None
def check(profile: dict, user_id: int | None = None, only_due: bool = False) -> dict: def check(profile: dict, user_id: int | None = None, only_due: bool = False) -> dict:
user_id = user_id or default_user_id()
profile_id = int(profile["id"]) profile_id = int(profile["id"])
now = utcnow() now = utcnow()
with connect() as conn: with connect() as conn:
if only_due: if only_due:
feeds = conn.execute("SELECT * FROM rss_feeds WHERE user_id=? AND profile_id=? AND enabled=1 AND (next_check_at IS NULL OR next_check_at<=?)", (user_id, profile_id, now)).fetchall() feeds = conn.execute("SELECT * FROM rss_feeds WHERE profile_id=? AND enabled=1 AND (next_check_at IS NULL OR next_check_at<=?)", (profile_id, now)).fetchall()
else: else:
feeds = conn.execute("SELECT * FROM rss_feeds WHERE user_id=? AND profile_id=? AND enabled=1", (user_id, profile_id)).fetchall() feeds = conn.execute("SELECT * FROM rss_feeds WHERE profile_id=? AND enabled=1", (profile_id,)).fetchall()
rules = conn.execute("SELECT * FROM rss_rules WHERE user_id=? AND profile_id=? AND enabled=1", (user_id, profile_id)).fetchall() rules = conn.execute("SELECT * FROM rss_rules WHERE profile_id=? AND enabled=1", (profile_id,)).fetchall()
queued = 0 queued = 0
tested = 0 tested = 0
errors: list[dict] = [] errors: list[dict] = []
@@ -160,11 +159,11 @@ def check(profile: dict, user_id: int | None = None, only_due: bool = False) ->
continue continue
link = item.get("link") or "" link = item.get("link") or ""
if not link: if not link:
_log(user_id, profile_id, feed["id"], rule["id"], item, "skipped", "missing link") _log(profile_id, feed["id"], rule["id"], item, "skipped", "missing link")
continue continue
enqueue("add_magnet", profile_id, {"uri": link, "start": bool(rule["start"]), "directory": rule.get("save_path") or rtorrent.default_download_path(profile), "label": rule.get("label") or "", "source": "rss"}, user_id=user_id) enqueue("add_magnet", profile_id, {"uri": link, "start": bool(rule["start"]), "directory": rule.get("save_path") or rtorrent.default_download_path(profile), "label": rule.get("label") or "", "source": "rss"}, user_id=user_id)
queued += 1 queued += 1
_log(user_id, profile_id, feed["id"], rule["id"], item, "queued", reason) _log(profile_id, feed["id"], rule["id"], item, "queued", reason)
with connect() as conn: with connect() as conn:
conn.execute("UPDATE rss_feeds SET last_error=NULL,last_checked_at=?,next_check_at=?,updated_at=? WHERE id=?", (now, next_check, now, feed["id"])) conn.execute("UPDATE rss_feeds SET last_error=NULL,last_checked_at=?,next_check_at=?,updated_at=? WHERE id=?", (now, next_check, now, feed["id"]))
except Exception as exc: except Exception as exc:
@@ -200,11 +199,11 @@ 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 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"]), int(row["user_id"])) profile = get_profile(int(row["profile_id"]))
if profile: if profile:
result = check(profile, int(row["user_id"]), only_due=True) result = check(profile, 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:

View File

@@ -3,45 +3,347 @@ from __future__ import annotations
from .client import * from .client import *
RTORRENT_CONFIG_FIELDS = [ RTORRENT_CONFIG_FIELDS = [
{"group": "Directories", "key": "directory.default", "label": "Default download directory", "type": "text"}, {
{"group": "Directories", "key": "session.path", "label": "Session path", "type": "text"}, "group": "Directories",
{"group": "Directories", "key": "system.cwd", "label": "Working directory", "type": "text", "readonly": True}, "key": "directory.default",
{"group": "Network", "key": "network.port_range", "label": "Incoming port range", "type": "text", "placeholder": "49164-49164"}, "label": "Default download directory",
{"group": "Network", "key": "network.port_random", "label": "Random incoming port", "type": "bool"}, "type": "text",
{"group": "Network", "key": "network.bind_address", "label": "Bind address", "type": "text", "placeholder": "0.0.0.0"}, "description": "Main destination for new downloads added without an explicit directory.",
{"group": "Network", "key": "network.local_address", "label": "Local address", "type": "text"}, "recommendation": "Use a stable absolute path on storage with enough free space; avoid changing it while active torrents use relative paths.",
{"group": "Network", "key": "network.max_open_files", "label": "Max open files", "type": "number"}, },
{"group": "Network", "key": "network.max_open_sockets", "label": "Max open sockets", "type": "number"}, {
{"group": "Network", "key": "network.http.max_open", "label": "Max HTTP connections", "type": "number"}, "group": "Directories",
{"group": "Network", "key": "network.http.ssl_verify_peer", "label": "Verify SSL peers", "type": "bool"}, "key": "session.path",
{"group": "Network", "key": "network.xmlrpc.size_limit", "label": "XML-RPC upload size limit", "type": "text", "placeholder": "16M"}, "label": "Session path",
{"group": "Peers", "key": "throttle.min_peers.normal", "label": "Min peers downloading", "type": "number"}, "type": "text",
{"group": "Peers", "key": "throttle.max_peers.normal", "label": "Max peers downloading", "type": "number"}, "description": "Directory where rTorrent stores session state, resume data and internal torrent metadata.",
{"group": "Peers", "key": "throttle.min_peers.seed", "label": "Min peers seeding", "type": "number"}, "recommendation": "Keep it on reliable local storage and include it in backups before maintenance.",
{"group": "Peers", "key": "throttle.max_peers.seed", "label": "Max peers seeding", "type": "number"}, },
{"group": "Peers", "key": "trackers.numwant", "label": "Tracker numwant", "type": "number"}, {
{"group": "Throttle", "key": "throttle.global_down.max_rate", "label": "Global download limit B/s", "type": "number"}, "group": "Directories",
{"group": "Throttle", "key": "throttle.global_up.max_rate", "label": "Global upload limit B/s", "type": "number"}, "key": "system.cwd",
{"group": "Throttle", "key": "throttle.max_downloads.global", "label": "Max active downloads", "type": "number"}, "label": "Working directory",
{"group": "Throttle", "key": "throttle.max_uploads.global", "label": "Max active uploads", "type": "number"}, "type": "text",
{"group": "Throttle", "key": "throttle.max_downloads.div", "label": "Max downloads per throttle", "type": "number"}, "readonly": True,
{"group": "Throttle", "key": "throttle.max_uploads.div", "label": "Max uploads per throttle", "type": "number"}, "description": "Current rTorrent process working directory reported by rTorrent.",
{"group": "DHT / PEX", "key": "dht.mode", "label": "DHT mode", "type": "text", "placeholder": "disable/off/auto/on"}, "recommendation": "Read-only diagnostic value; change it in the service or startup configuration if needed.",
{"group": "DHT / PEX", "key": "dht.port", "label": "DHT port", "type": "number"}, },
{"group": "DHT / PEX", "key": "protocol.pex", "label": "Peer exchange", "type": "bool"}, {
{"group": "Protocol", "key": "protocol.encryption.set", "label": "Encryption flags", "type": "text", "placeholder": "allow_incoming,try_outgoing,enable_retry"}, "group": "Network",
{"group": "Protocol", "key": "protocol.connection.leech", "label": "Leech connection type", "type": "text", "placeholder": "leech"}, "key": "network.port_range",
{"group": "Protocol", "key": "protocol.connection.seed", "label": "Seed connection type", "type": "text", "placeholder": "seed"}, "label": "Incoming port range",
{"group": "Files", "key": "pieces.hash.on_completion", "label": "Hash check on completion", "type": "bool"}, "type": "text",
{"group": "Files", "key": "pieces.preload.type", "label": "Pieces preload type", "type": "number"}, "placeholder": "49164-49164",
{"group": "Files", "key": "pieces.preload.min_size", "label": "Pieces preload min size", "type": "number"}, "description": "TCP port or range used for incoming peer connections.",
{"group": "Files", "key": "pieces.preload.min_rate", "label": "Pieces preload min rate", "type": "number"}, "recommendation": "Use a fixed forwarded port, for example 49164-49164, for stable connectivity.",
{"group": "Files", "key": "system.file.allocate", "label": "File allocation", "type": "number"}, },
{"group": "Files", "key": "system.file.max_size", "label": "Max file size", "type": "number"}, {
{"group": "System", "key": "system.umask", "label": "File umask", "type": "text", "placeholder": "0002"}, "group": "Network",
{"group": "System", "key": "system.hostname", "label": "Hostname", "type": "text", "readonly": True}, "key": "network.port_random",
{"group": "System", "key": "system.client_version", "label": "Client version", "type": "text", "readonly": True}, "label": "Random incoming port",
{"group": "System", "key": "system.library_version", "label": "Library version", "type": "text", "readonly": True}, "type": "bool",
"description": "Lets rTorrent select a random incoming port on startup.",
"recommendation": "Disable it when using router/NAT forwarding; fixed ports are easier to monitor.",
},
{
"group": "Network",
"key": "network.bind_address",
"label": "Bind address",
"type": "text",
"placeholder": "0.0.0.0",
"description": "Local interface address used for peer traffic binding.",
"recommendation": "Leave empty unless the host has multiple interfaces or policy routing.",
},
{
"group": "Network",
"key": "network.local_address",
"label": "Announced local address",
"type": "text",
"description": "Address rTorrent may announce as its local network address.",
"recommendation": "Usually leave empty; set only when a specific advertised address is required.",
},
{
"group": "Network",
"key": "network.max_open_files",
"label": "Max open files",
"type": "number",
"description": "Maximum number of files rTorrent can keep open at once.",
"recommendation": "Raise together with the OS file descriptor limit on large seeds.",
},
{
"group": "Network",
"key": "network.max_open_sockets",
"label": "Max open sockets",
"type": "number",
"description": "Upper bound for peer and tracker sockets opened by rTorrent.",
"recommendation": "Keep below OS limits; increase gradually when many torrents are active.",
},
{
"group": "Network",
"key": "network.http.max_open",
"label": "Max HTTP connections",
"type": "number",
"description": "Maximum simultaneous HTTP connections for tracker and metadata requests.",
"recommendation": "Moderate values reduce tracker pressure; increase only if tracker requests queue up.",
},
{
"group": "Network",
"key": "network.http.ssl_verify_peer",
"label": "Verify SSL peers",
"type": "bool",
"description": "Controls certificate verification for HTTPS tracker connections.",
"recommendation": "Keep enabled unless a private tracker has a known certificate problem.",
},
{
"group": "Network",
"key": "network.xmlrpc.size_limit",
"label": "XML-RPC upload size limit",
"type": "text",
"placeholder": "16M",
"description": "Maximum XML-RPC payload size accepted by rTorrent.",
"recommendation": "Keep enough headroom for large UI responses; avoid very high values on public endpoints.",
},
{
"group": "Peers",
"key": "throttle.min_peers.normal",
"label": "Min peers while downloading",
"type": "number",
"description": "Minimum peer target for incomplete torrents.",
"recommendation": "Use a conservative floor; too high values can waste sockets on weak swarms.",
},
{
"group": "Peers",
"key": "throttle.max_peers.normal",
"label": "Max peers while downloading",
"type": "number",
"description": "Maximum peer target for incomplete torrents.",
"recommendation": "Increase for fast lines, but keep total sockets and CPU usage under control.",
},
{
"group": "Peers",
"key": "throttle.min_peers.seed",
"label": "Min peers while seeding",
"type": "number",
"description": "Minimum peer target for complete torrents.",
"recommendation": "Lower than download min peers is usually enough for long-term seeding.",
},
{
"group": "Peers",
"key": "throttle.max_peers.seed",
"label": "Max peers while seeding",
"type": "number",
"description": "Maximum peer target for complete torrents.",
"recommendation": "Avoid excessive values on many seeding torrents because sockets multiply quickly.",
},
{
"group": "Peers",
"key": "trackers.numwant",
"label": "Tracker numwant",
"type": "number",
"description": "Number of peers requested from trackers per announce where supported.",
"recommendation": "Use moderate values; many trackers cap this server-side anyway.",
},
{
"group": "Throttle",
"key": "throttle.global_down.max_rate",
"label": "Global download limit B/s",
"type": "number",
"description": "Global download speed cap in bytes per second. Zero usually means unlimited.",
"recommendation": "Leave unlimited or cap below line speed if other services share the connection.",
},
{
"group": "Throttle",
"key": "throttle.global_up.max_rate",
"label": "Global upload limit B/s",
"type": "number",
"description": "Global upload speed cap in bytes per second. Zero usually means unlimited.",
"recommendation": "Keep below real upstream capacity to avoid bufferbloat and slow downloads.",
},
{
"group": "Throttle",
"key": "throttle.max_downloads.global",
"label": "Max active downloads",
"type": "number",
"description": "Maximum number of downloading torrents active at once.",
"recommendation": "Match disk and network capacity; fewer active downloads often finish faster.",
},
{
"group": "Throttle",
"key": "throttle.max_uploads.global",
"label": "Max active uploads",
"type": "number",
"description": "Maximum number of uploading torrents active at once.",
"recommendation": "Keep enough slots for ratio goals without overloading disks and sockets.",
},
{
"group": "Throttle",
"key": "throttle.max_downloads.div",
"label": "Max downloads per throttle",
"type": "number",
"description": "Per-throttle download slot divisor used by rTorrent throttling logic.",
"recommendation": "Change only when using named throttle groups or advanced queues.",
},
{
"group": "Throttle",
"key": "throttle.max_uploads.div",
"label": "Max uploads per throttle",
"type": "number",
"description": "Per-throttle upload slot divisor used by rTorrent throttling logic.",
"recommendation": "Change only when using named throttle groups or advanced queues.",
},
{
"group": "DHT / PEX",
"key": "dht.mode",
"label": "DHT mode",
"type": "text",
"placeholder": "disable/off/auto/on",
"description": "Controls Distributed Hash Table usage for peer discovery.",
"recommendation": "Private-tracker setups often disable DHT; public torrents usually benefit from auto/on.",
},
{
"group": "DHT / PEX",
"key": "dht.port",
"label": "DHT port",
"type": "number",
"description": "UDP port used by DHT traffic.",
"recommendation": "Use the same forwarded port strategy as incoming TCP when DHT is enabled.",
},
{
"group": "DHT / PEX",
"key": "protocol.pex",
"label": "Peer exchange",
"type": "bool",
"description": "Enables Peer Exchange peer discovery between connected peers.",
"recommendation": "Disable for strict private-tracker policies; enable for public swarms if allowed.",
},
{
"group": "DHT / PEX",
"key": "trackers.use_udp",
"label": "UDP trackers",
"type": "bool",
"description": "Allows rTorrent to use UDP trackers where supported.",
"recommendation": "Keep enabled for public torrents unless the network blocks UDP tracker traffic.",
},
{
"group": "Protocol",
"key": "protocol.encryption.set",
"label": "Encryption flags",
"type": "text",
"placeholder": "allow_incoming,try_outgoing,enable_retry",
"description": "Encryption policy flags for peer connections.",
"recommendation": "Prefer permissive settings unless a tracker or network requires strict encryption.",
},
{
"group": "Protocol",
"key": "protocol.connection.leech",
"label": "Leech connection type",
"type": "text",
"placeholder": "leech",
"description": "Connection behavior profile used by incomplete torrents.",
"recommendation": "Leave default unless tuning advanced libTorrent behavior.",
},
{
"group": "Protocol",
"key": "protocol.connection.seed",
"label": "Seed connection type",
"type": "text",
"placeholder": "seed",
"description": "Connection behavior profile used by complete torrents.",
"recommendation": "Leave default unless tuning advanced libTorrent behavior.",
},
{
"group": "Files",
"key": "pieces.hash.on_completion",
"label": "Hash check on completion",
"type": "bool",
"description": "Runs a hash verification after a torrent completes.",
"recommendation": "Enable for data integrity when storage is unreliable; disable if completion checks are too expensive.",
},
{
"group": "Files",
"key": "pieces.preload.type",
"label": "Pieces preload type",
"type": "number",
"description": "Controls how rTorrent preloads torrent pieces from disk.",
"recommendation": "Keep default unless you are tuning disk cache behavior for a known workload.",
},
{
"group": "Files",
"key": "pieces.preload.min_size",
"label": "Pieces preload min size",
"type": "number",
"description": "Minimum piece size threshold for preload behavior.",
"recommendation": "Keep default unless large-piece torrents show disk latency issues.",
},
{
"group": "Files",
"key": "pieces.preload.min_rate",
"label": "Pieces preload min rate",
"type": "number",
"description": "Minimum transfer rate threshold for preloading pieces.",
"recommendation": "Tune only after measuring disk read pressure.",
},
{
"group": "Files",
"key": "pieces.memory.max",
"label": "Pieces memory max",
"type": "text",
"placeholder": "512M",
"description": "Maximum memory rTorrent may use for piece handling where supported.",
"recommendation": "Avoid values that compete with OS page cache; increase only on hosts with spare RAM.",
},
{
"group": "Files",
"key": "system.file.allocate",
"label": "File allocation",
"type": "number",
"description": "Controls preallocation behavior for downloaded files.",
"recommendation": "Preallocation can reduce fragmentation but may slow adding very large torrents.",
},
{
"group": "Files",
"key": "system.file.max_size",
"label": "Max file size",
"type": "number",
"description": "Maximum single file size rTorrent accepts where supported.",
"recommendation": "Leave default unless you intentionally need to block oversized files.",
},
{
"group": "System",
"key": "system.umask",
"label": "File umask",
"type": "text",
"placeholder": "0002",
"description": "Permission mask applied to files created by rTorrent.",
"recommendation": "Use 0002 for shared media groups, 0022 for private single-user setups.",
},
{
"group": "System",
"key": "system.hostname",
"label": "Hostname",
"type": "text",
"readonly": True,
"description": "Hostname reported by the rTorrent runtime.",
"recommendation": "Read-only diagnostic value.",
},
{
"group": "System",
"key": "system.client_version",
"label": "Client version",
"type": "text",
"readonly": True,
"description": "rTorrent client version reported through XML-RPC.",
"recommendation": "Read-only diagnostic value useful when checking compatibility.",
},
{
"group": "System",
"key": "system.library_version",
"label": "Library version",
"type": "text",
"readonly": True,
"description": "libTorrent library version used by rTorrent.",
"recommendation": "Read-only diagnostic value useful when checking compatibility.",
},
] ]
@@ -54,11 +356,10 @@ def _normalize_config_value(meta: dict, value):
def saved_config_overrides(profile_id: int, user_id: int | None = None) -> dict[str, dict]: def saved_config_overrides(profile_id: int, user_id: int | None = None) -> dict[str, dict]:
user_id = user_id or default_user_id()
with connect() as conn: with connect() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT key,value,baseline_value,apply_on_start,updated_at FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=?", "SELECT key,value,baseline_value,apply_on_start,updated_at FROM rtorrent_config_overrides WHERE profile_id=?",
(user_id, int(profile_id)), (int(profile_id),),
).fetchall() ).fetchall()
return {r["key"]: r for r in rows} return {r["key"]: r for r in rows}
@@ -129,7 +430,6 @@ def _read_rtorrent_config_value(client, key: str, meta: dict) -> str:
def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, baseline_values: dict | None = None, clear_keys: list[str] | None = None) -> list[str]: def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, baseline_values: dict | None = None, clear_keys: list[str] | None = None) -> list[str]:
known = {f["key"]: f for f in RTORRENT_CONFIG_FIELDS} known = {f["key"]: f for f in RTORRENT_CONFIG_FIELDS}
user_id = default_user_id()
now = utcnow() now = utcnow()
profile_id = int(profile["id"]) profile_id = int(profile["id"])
baseline_values = baseline_values or {} baseline_values = baseline_values or {}
@@ -139,8 +439,8 @@ def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, ba
for key in clear_set: for key in clear_set:
if key in known: if key in known:
conn.execute( conn.execute(
"DELETE FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=? AND key=?", "DELETE FROM rtorrent_config_overrides WHERE profile_id=? AND key=?",
(user_id, profile_id, key), (profile_id, key),
) )
for key, value in (values or {}).items(): for key, value in (values or {}).items():
if key in clear_set: if key in clear_set:
@@ -150,8 +450,8 @@ def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, ba
continue continue
normalized = _normalize_config_value(meta, value) normalized = _normalize_config_value(meta, value)
existing = conn.execute( existing = conn.execute(
"SELECT baseline_value FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=? AND key=?", "SELECT baseline_value FROM rtorrent_config_overrides WHERE profile_id=? AND key=?",
(user_id, profile_id, key), (profile_id, key),
).fetchone() ).fetchone()
existing_baseline = existing.get("baseline_value") if existing else None existing_baseline = existing.get("baseline_value") if existing else None
@@ -165,18 +465,18 @@ def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, ba
if baseline not in (None, "") and normalized == baseline: if baseline not in (None, "") and normalized == baseline:
conn.execute( conn.execute(
"DELETE FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=? AND key=?", "DELETE FROM rtorrent_config_overrides WHERE profile_id=? AND key=?",
(user_id, profile_id, key), (profile_id, key),
) )
continue continue
conn.execute( conn.execute(
"INSERT OR REPLACE INTO rtorrent_config_overrides(user_id,profile_id,key,value,baseline_value,apply_on_start,updated_at) VALUES(?,?,?,?,?,?,?)", "INSERT OR REPLACE INTO rtorrent_config_overrides(profile_id,key,value,baseline_value,apply_on_start,updated_at) VALUES(?,?,?,?,?,?)",
(user_id, profile_id, key, normalized, baseline, 1 if apply_on_start else 0, now), (profile_id, key, normalized, baseline, 1 if apply_on_start else 0, now),
) )
stored.append(key) stored.append(key)
conn.execute( conn.execute(
"UPDATE rtorrent_config_overrides SET apply_on_start=?, updated_at=? WHERE user_id=? AND profile_id=?", "UPDATE rtorrent_config_overrides SET apply_on_start=?, updated_at=? WHERE profile_id=?",
(1 if apply_on_start else 0, now, user_id, profile_id), (1 if apply_on_start else 0, now, profile_id),
) )
return stored return stored
@@ -220,17 +520,16 @@ def set_config(profile: dict, values: dict, apply_now: bool = True, apply_on_sta
def reset_config_overrides(profile: dict, user_id: int | None = None) -> dict: def reset_config_overrides(profile: dict, user_id: int | None = None) -> dict:
"""Remove saved UI overrides and return the freshly read rTorrent config.""" """Remove saved UI overrides and return the freshly read rTorrent config."""
# Note: Reset means "forget pyTorrent UI overrides"; it does not write defaults back to rTorrent. # Note: Reset means "forget pyTorrent UI overrides"; it does not write defaults back to rTorrent.
user_id = user_id or default_user_id()
profile_id = int(profile["id"]) profile_id = int(profile["id"])
with connect() as conn: with connect() as conn:
row = conn.execute( row = conn.execute(
"SELECT COUNT(*) AS count FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=?", "SELECT COUNT(*) AS count FROM rtorrent_config_overrides WHERE profile_id=?",
(user_id, profile_id), (profile_id,),
).fetchone() ).fetchone()
removed = int((row or {}).get("count") or 0) removed = int((row or {}).get("count") or 0)
conn.execute( conn.execute(
"DELETE FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=?", "DELETE FROM rtorrent_config_overrides WHERE profile_id=?",
(user_id, profile_id), (profile_id,),
) )
config = get_config(profile) config = get_config(profile)
config["reset_removed"] = removed config["reset_removed"] = removed

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from .client import * from .client import *
from .. import poller_control
import shlex import shlex
def scgi_diagnostics(profile: dict) -> dict: def scgi_diagnostics(profile: dict) -> dict:
@@ -64,7 +65,12 @@ def scgi_diagnostics(profile: dict) -> dict:
def profile_diagnostics(profile: dict) -> dict: def profile_diagnostics(profile: dict) -> dict:
"""Lightweight per-profile diagnostics for save/test UI.""" """Lightweight per-profile diagnostics for save/test UI."""
started = time.perf_counter() started = time.perf_counter()
result = {"profile_id": profile.get("id"), "ok": False, "checks": {}} profile_id = profile.get("id")
try:
slow_threshold_ms = float(poller_control.get_settings(int(profile_id)).get("slow_response_threshold_ms") or poller_control.DEFAULTS["slow_response_threshold_ms"])
except Exception:
slow_threshold_ms = float(poller_control.DEFAULTS["slow_response_threshold_ms"])
result = {"profile_id": profile_id, "ok": False, "checks": {}, "slow_threshold_ms": slow_threshold_ms}
try: try:
c = client_for(profile) c = client_for(profile)
version = str(c.call("system.client_version") or "") version = str(c.call("system.client_version") or "")
@@ -96,7 +102,7 @@ def profile_diagnostics(profile: dict) -> dict:
free_disk[base] = {"error": str(exc)} free_disk[base] = {"error": str(exc)}
result.update({ result.update({
"ok": True, "ok": True,
"status": "online", "status": "normal",
"version": version, "version": version,
"library_version": library, "library_version": library,
"base_paths": paths, "base_paths": paths,
@@ -106,7 +112,8 @@ def profile_diagnostics(profile: dict) -> dict:
}) })
except Exception as exc: except Exception as exc:
result.update({"ok": False, "status": "error", "error": str(exc), "response_time_ms": round((time.perf_counter() - started) * 1000, 2)}) result.update({"ok": False, "status": "error", "error": str(exc), "response_time_ms": round((time.perf_counter() - started) * 1000, 2)})
if result.get("ok") and result.get("response_time_ms", 0) > 1500: # Note: Profile diagnostics uses the same slow-response threshold as Tools -> Poller for this profile.
if result.get("ok") and result.get("response_time_ms", 0) > slow_threshold_ms:
result["status"] = "slow" result["status"] = "slow"
return result return result

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from .client import * from .client import *
from ...config import BASE_DIR
def torrent_files(profile: dict, torrent_hash: str) -> list[dict]: def torrent_files(profile: dict, torrent_hash: str) -> list[dict]:
rows = client_for(profile).f.multicall(torrent_hash, "", "f.path=", "f.size_bytes=", "f.completed_chunks=", "f.size_chunks=", "f.priority=") rows = client_for(profile).f.multicall(torrent_hash, "", "f.path=", "f.size_bytes=", "f.completed_chunks=", "f.size_chunks=", "f.priority=")
@@ -58,10 +59,17 @@ def _torrent_file_remote_path(profile: dict, torrent_hash: str, index: int) -> t
if selected is None: if selected is None:
available = ", ".join(str(f.get("index")) for f in files[:20]) or "none" available = ", ".join(str(f.get("index")) for f in files[:20]) or "none"
raise ValueError(f"File index {index} not found. Available indexes: {available}") raise ValueError(f"File index {index} not found. Available indexes: {available}")
base = _remote_clean_path(_torrent_data_path(c, torrent_hash)) base = _remote_clean_path(_torrent_data_path(c, torrent_hash))
rel = str(selected.get("path") or "").lstrip("/") rel = str(selected.get("path") or "").lstrip("/")
if len(files) == 1 and base and not base.endswith("/"):
path = base # Note: rTorrent can report d.base_path as either the payload file or the
# containing data directory for a one-file torrent. Keep both existing
# layouts working and avoid treating a directory as the media file.
if len(files) == 1 and base and rel:
base_name = posixpath.basename(base.rstrip("/"))
rel_name = posixpath.basename(rel.rstrip("/"))
path = base if base_name == rel_name else _remote_join(base, rel)
else: else:
path = _remote_join(base, rel) path = _remote_join(base, rel)
return selected, path return selected, path
@@ -123,6 +131,392 @@ def iter_remote_file_chunks(profile: dict, source_path: str, size: int | None =
break break
_MEDIA_INFO_EXTENSIONS = {
".3g2", ".3gp", ".aac", ".aiff", ".ape", ".asf", ".avi", ".flac",
".flv", ".m4a", ".m4v", ".mka", ".mkv", ".mov", ".mp3", ".mp4",
".mpeg", ".mpg", ".ogg", ".opus", ".ts", ".wav", ".webm", ".wma", ".wmv",
}
_TEXT_PREVIEW_EXTENSIONS = {
".ass", ".cue", ".csv", ".ini", ".json", ".log", ".m3u", ".m3u8",
".md", ".nfo", ".srt", ".ssa", ".sub", ".sfv", ".txt", ".url",
".xml", ".yaml", ".yml",
}
_IMAGE_PREVIEW_EXTENSIONS = {".avif", ".bmp", ".gif", ".jpeg", ".jpg", ".png", ".webp"}
_PDF_PREVIEW_EXTENSIONS = {".pdf"}
_MEDIA_INFO_SAMPLE_BYTES = 32 * 1024 * 1024
_MEDIA_INFO_CHUNK_BYTES = 1024 * 1024
_TEXT_PREVIEW_BYTES = 512 * 1024
_IMAGE_PREVIEW_BYTES = 8 * 1024 * 1024
_MEDIA_INFO_TMP_DIR = BASE_DIR / "data" / "media-info-samples"
def _file_extension(path: str) -> str:
return LocalPath(str(path or "")).suffix.lower()
def _media_info_supported(path: str) -> bool:
# Note: Extension filtering avoids trying binary metadata parsers on every torrent payload file.
return _file_extension(path) in _MEDIA_INFO_EXTENSIONS
def _text_preview_supported(path: str) -> bool:
# Note: Text previews intentionally include NFO and subtitle files so the existing info button becomes useful for release notes too.
return _file_extension(path) in _TEXT_PREVIEW_EXTENSIONS
def _image_preview_supported(path: str) -> bool:
# Note: Image previews are limited to browser-safe raster formats and avoid SVG to prevent inline script-like payloads.
return _file_extension(path) in _IMAGE_PREVIEW_EXTENSIONS
def _pdf_preview_supported(path: str) -> bool:
# Note: PDF previews are rendered inline by the browser so image-heavy books keep their page layout.
return _file_extension(path) in _PDF_PREVIEW_EXTENSIONS
def _media_info_sample_suffix(source_path: str) -> str:
suffix = LocalPath(str(source_path or "")).suffix.lower()
if suffix and len(suffix) <= 16 and all(ch.isalnum() or ch in ".-_" for ch in suffix):
return suffix
return ".bin"
def _read_file_prefix(profile: dict, source_path: str, max_bytes: int) -> bytes:
# Note: File info must read through rTorrent, not the pyTorrent process, because torrents may live on a remote host or under rTorrent-only permissions.
limit = max(0, int(max_bytes or 0))
chunks: list[bytes] = []
collected = 0
for chunk in iter_remote_file_chunks(profile, source_path, size=limit, chunk_size=_MEDIA_INFO_CHUNK_BYTES):
if collected >= limit:
break
data = bytes(chunk[: max(0, limit - collected)])
chunks.append(data)
collected += len(data)
return b"".join(chunks)
def _decode_text_preview(data: bytes) -> tuple[str, str]:
# Note: NFO files are often CP437, while normal text is usually UTF-8; the fallback keeps ASCII art readable.
if not data:
return "utf-8", ""
for encoding in ("utf-8-sig", "utf-8"):
try:
return encoding, data.decode(encoding)
except UnicodeDecodeError:
pass
for encoding in ("cp437", "cp1250", "latin-1"):
try:
return encoding, data.decode(encoding, errors="replace")
except Exception:
pass
return "utf-8", data.decode("utf-8", errors="replace")
def _image_preview_mime(path: str) -> str:
# Note: The MIME type is extension-based because preview input is already restricted to known image suffixes.
ext = _file_extension(path)
return {
".avif": "image/avif",
".bmp": "image/bmp",
".gif": "image/gif",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
}.get(ext, "application/octet-stream")
def _text_file_preview(profile: dict, selected: dict, remote_path: str, max_bytes: int = _TEXT_PREVIEW_BYTES) -> dict:
# Note: Text preview returns escaped-by-frontend content and a clear truncation flag for large NFO/log/subtitle files.
size = int(selected.get("size") or 0)
data = _read_file_prefix(profile, remote_path, max_bytes)
encoding, text = _decode_text_preview(data)
return {
**selected,
"kind": "text",
"parser": "text-preview",
"supported": True,
"sample_bytes": len(data),
"sample_limit": int(max_bytes),
"partial": bool(size and len(data) < size),
"encoding": encoding,
"text": text,
"line_count": text.count("\n") + (1 if text else 0),
"summary": {},
"fields": [
{"key": "Type", "value": "Text preview"},
{"key": "Encoding", "value": encoding},
{"key": "Preview bytes", "value": human_size(len(data))},
],
"raw": [],
}
def _image_file_preview(profile: dict, selected: dict, remote_path: str, max_bytes: int = _IMAGE_PREVIEW_BYTES) -> dict:
# Note: Image preview is size capped and CSS-constrained in the modal instead of decoding/resizing images server-side.
size = int(selected.get("size") or 0)
result = {
**selected,
"kind": "image",
"parser": "image-preview",
"supported": True,
"sample_bytes": 0,
"sample_limit": int(max_bytes),
"partial": False,
"mime_type": _image_preview_mime(str(selected.get("path") or remote_path)),
"summary": {},
"fields": [
{"key": "Type", "value": "Image preview"},
{"key": "Preview limit", "value": human_size(max_bytes)},
],
"raw": [],
}
if size > max_bytes:
result.update({
"too_large": True,
"error": f"Image preview is limited to {human_size(max_bytes)}. Download the file to view the full image.",
})
return result
data = _read_file_prefix(profile, remote_path, max_bytes)
import base64
result.update({
"sample_bytes": len(data),
"data_url": f"data:{result['mime_type']};base64,{base64.b64encode(data).decode('ascii')}",
"fields": result["fields"] + [
{"key": "Image bytes", "value": human_size(len(data))},
{"key": "MIME type", "value": result["mime_type"]},
],
})
return result
def _pdf_file_preview(
profile: dict,
selected: dict,
remote_path: str,
) -> dict:
# Note: pypdf is no longer required because PDFs are not parsed; the browser renders the original file stream.
size = int(selected.get("size") or 0)
return {
**selected,
"kind": "pdf",
"parser": "browser-pdf-viewer",
"supported": True,
"sample_bytes": 0,
"sample_limit": 0,
"page_limit": 0,
"partial": False,
"summary": {
"duration": None,
"bit_rate": human_size(size) if size else None,
"compression": "PDF",
"producer": "Browser inline preview",
"creation_date": None,
},
"fields": [
{"key": "Type", "value": "PDF inline preview"},
{"key": "PDF size", "value": human_size(size)},
{"key": "Preview mode", "value": "Browser PDF renderer"},
],
"raw": [],
"text": "",
}
def _media_info_temp_sample(profile: dict, source_path: str, max_bytes: int) -> tuple[str, int]:
# Note: hachoir needs a seekable file, so this writes a bounded sample into the app data directory instead of loading whole media into RAM.
import tempfile
_MEDIA_INFO_TMP_DIR.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(
prefix="pytorrent-mediainfo-",
suffix=_media_info_sample_suffix(source_path),
dir=str(_MEDIA_INFO_TMP_DIR),
)
written = 0
try:
with os.fdopen(fd, "wb") as tmp:
for chunk in iter_remote_file_chunks(profile, source_path, size=max_bytes, chunk_size=_MEDIA_INFO_CHUNK_BYTES):
if written >= max_bytes:
break
data = bytes(chunk[: max(0, max_bytes - written)])
tmp.write(data)
written += len(data)
return tmp_path, written
except Exception:
try:
os.unlink(tmp_path)
except Exception:
pass
raise
def _media_info_plaintext(metadata) -> list[str]:
# Note: exportPlaintext is the most stable hachoir API across supported package versions.
try:
lines = metadata.exportPlaintext() or []
except Exception:
return []
return [str(line).strip(" -") for line in lines if str(line).strip(" -")]
def _media_info_parse_lines(lines: list[str]) -> list[dict]:
# Note: The frontend receives both grouped fields and raw text so unknown hachoir fields stay visible.
fields = []
for line in lines:
if not line or ":" not in line:
continue
key, value = line.split(":", 1)
key = key.strip()
value = value.strip()
if key and value:
fields.append({"key": key, "value": value})
return fields
def _media_info_field_lookup(fields: list[dict]) -> dict:
lookup = {}
for field in fields:
key = str(field.get("key") or "").lower()
if key and key not in lookup:
lookup[key] = field.get("value")
return lookup
def _media_info_summary(fields: list[dict]) -> dict:
# Note: Summary keeps the modal readable while raw fields remain available below it.
lookup = _media_info_field_lookup(fields)
def first(*names):
for name in names:
value = lookup.get(name.lower())
if value:
return value
return None
return {
"duration": first("Duration", "Play duration"),
"bit_rate": first("Bit rate", "Overall bit rate"),
"width": first("Image width", "Width"),
"height": first("Image height", "Height"),
"frame_rate": first("Frame rate"),
"sample_rate": first("Sample rate"),
"channels": first("Channel", "Channel(s)", "Channels"),
"compression": first("Compression", "Compressor", "Codec", "Video codec", "Audio codec"),
"producer": first("Producer", "Encoder", "Writing application"),
"creation_date": first("Creation date", "Creation time"),
}
def _media_info_hachoir_imports():
# Note: Import is checked before reading the media sample so dependency problems fail fast and clearly.
import sys
try:
from hachoir.metadata import extractMetadata
from hachoir.parser import createParser
return createParser, extractMetadata
except ModuleNotFoundError as exc:
missing = str(getattr(exc, "name", "") or "hachoir")
if missing.split(".", 1)[0] == "hachoir":
raise RuntimeError(
"Python package 'hachoir' is not importable in the application runtime. "
"Install it inside the pyTorrent virtualenv and restart the service: "
"/opt/pyTorrent/venv/bin/pip install -r /opt/pyTorrent/requirements.txt && systemctl restart pytorrent. "
f"Runtime: {sys.executable}."
) from exc
raise RuntimeError(
f"hachoir is installed, but one of its Python dependencies is missing: {missing}. "
f"Runtime: {sys.executable}."
) from exc
except Exception as exc:
raise RuntimeError(
"hachoir was found, but failed during import. "
f"Runtime: {sys.executable}. Details: {exc}"
) from exc
def _torrent_file_is_complete(selected: dict) -> bool:
# Note: File info reads real file bytes, so incomplete payload files are blocked before any parser touches them.
size = int(selected.get("size") or 0)
completed_chunks = int(selected.get("completed_chunks") or 0)
size_chunks = int(selected.get("size_chunks") or 0)
progress = float(selected.get("progress") or 0)
return size <= 0 or progress >= 100.0 or (size_chunks > 0 and completed_chunks >= size_chunks)
def torrent_file_media_info(profile: dict, torrent_hash: str, index: int, max_bytes: int = _MEDIA_INFO_SAMPLE_BYTES) -> dict:
# Note: This additive endpoint now acts as a smart file preview: media metadata, text/NFO reader, or image preview depending on file type.
selected, remote_path = _torrent_file_remote_path(profile, torrent_hash, index)
name = str(selected.get("path") or remote_path)
size = int(selected.get("size") or 0)
if not _torrent_file_is_complete(selected):
raise RuntimeError("File info is available only after this file is fully downloaded.")
err = remote_file_readability_error(profile, remote_path)
if err:
raise RuntimeError(err)
if _text_preview_supported(name):
return _text_file_preview(profile, selected, remote_path)
if _image_preview_supported(name):
return _image_file_preview(profile, selected, remote_path)
if _pdf_preview_supported(name):
return _pdf_file_preview(profile, selected, remote_path)
supported = _media_info_supported(name)
result = {
**selected,
"kind": "media",
"supported": supported,
"sample_bytes": 0,
"sample_limit": int(max_bytes),
"partial": True,
"summary": {},
"fields": [],
"raw": [],
"parser": "hachoir",
}
if not supported:
result.update({
"kind": "unsupported",
"error": "This file extension is not supported by the built-in preview or media info parser.",
})
return result
createParser, extractMetadata = _media_info_hachoir_imports()
tmp_path = None
try:
tmp_path, written = _media_info_temp_sample(profile, remote_path, max(1024 * 1024, int(max_bytes)))
# Note: Do not pass real_filename here; some hachoir versions treat it as an input path and fail for nested torrent file names.
parser = createParser(tmp_path)
if parser is None:
result.update({"sample_bytes": written, "error": "hachoir could not detect this media container."})
return result
with parser:
metadata = extractMetadata(parser)
if metadata is None:
result.update({"sample_bytes": written, "error": "No media metadata found in the sampled part of the file."})
return result
raw = _media_info_plaintext(metadata)
fields = _media_info_parse_lines(raw)
result.update({
"sample_bytes": written,
"partial": bool(size and written < size),
"summary": _media_info_summary(fields),
"fields": fields,
"raw": raw,
})
return result
finally:
if tmp_path:
try:
os.unlink(tmp_path)
except Exception:
pass
def torrent_download_file_info(profile: dict, torrent_hash: str, index: int) -> dict: def torrent_download_file_info(profile: dict, torrent_hash: str, index: int) -> dict:
selected, remote_path = _torrent_file_remote_path(profile, torrent_hash, index) selected, remote_path = _torrent_file_remote_path(profile, torrent_hash, index)
err = remote_file_readability_error(profile, remote_path) err = remote_file_readability_error(profile, remote_path)
@@ -148,17 +542,31 @@ def torrent_download_zip_items(profile: dict, torrent_hash: str, indexes: list[i
return items return items
def _remote_file_exists(c: ScgiRtorrentClient, source_path: str) -> bool:
# Note: Export fallback checks candidate .torrent files on the rTorrent host before staging, avoiding stale tied-file paths.
clean = _remote_clean_path(source_path)
if not clean:
return False
script = 'p=$1; [ -f "$p" ] && [ -r "$p" ] && printf OK || true'
try:
return str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-file-exists", clean) or "").strip() == "OK"
except Exception:
return False
def _remote_stage_path(c: ScgiRtorrentClient, source_path: str, suffix: str = "") -> str: def _remote_stage_path(c: ScgiRtorrentClient, source_path: str, suffix: str = "") -> str:
token = uuid.uuid4().hex token = uuid.uuid4().hex
safe_suffix = ''.join(ch if ch.isalnum() or ch in '.-_' else '_' for ch in str(suffix or ''))[:80] safe_suffix = ''.join(ch if ch.isalnum() or ch in '.-_' else '_' for ch in str(suffix or ''))[:80]
target = f"{download_tmp_dir().rstrip('/')}/pytorrent-download-{token}{safe_suffix}" target = f"{download_tmp_dir().rstrip('/')}/pytorrent-download-{token}{safe_suffix}"
script = ( script = (
'src=$1; dst=$2; ' 'src=$1; dst=$2; '
'if [ ! -f "$src" ]; then echo "ERR\tmissing source"; exit 0; fi; ' 'if [ ! -f "$src" ]; then printf "ERR\tmissing source: %s\n" "$src"; exit 0; fi; '
'if [ ! -r "$src" ]; then printf "ERR\tsource is not readable: %s\n" "$src"; exit 0; fi; '
'cp -- "$src" "$dst" 2>/tmp/pytorrent-cp-err-$$ || { rc=$?; err=$(cat /tmp/pytorrent-cp-err-$$ 2>/dev/null); rm -f /tmp/pytorrent-cp-err-$$; printf "ERR\t%s\t%s\n" "$rc" "$err"; exit 0; }; ' 'cp -- "$src" "$dst" 2>/tmp/pytorrent-cp-err-$$ || { rc=$?; err=$(cat /tmp/pytorrent-cp-err-$$ 2>/dev/null); rm -f /tmp/pytorrent-cp-err-$$; printf "ERR\t%s\t%s\n" "$rc" "$err"; exit 0; }; '
'rm -f /tmp/pytorrent-cp-err-$$; chmod 0644 "$dst" 2>/dev/null || true; printf "OK\t%s\n" "$dst"' 'rm -f /tmp/pytorrent-cp-err-$$; chmod 0644 "$dst" 2>/dev/null || true; printf "OK\t%s\n" "$dst"'
) )
output = str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-stage-file", source_path, target) or "").strip() clean_source = _remote_clean_path(source_path)
output = str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-stage-file", clean_source, target) or "").strip()
parts = (output.splitlines()[0] if output else "").split("\t", 2) parts = (output.splitlines()[0] if output else "").split("\t", 2)
if len(parts) >= 2 and parts[0] == "OK": if len(parts) >= 2 and parts[0] == "OK":
return parts[1] return parts[1]
@@ -250,14 +658,48 @@ def _torrent_raw_from_method(c: ScgiRtorrentClient, torrent_hash: str) -> bytes
return None return None
def _torrent_source_file(c: ScgiRtorrentClient, torrent_hash: str) -> str: def _rtorrent_session_path(c: ScgiRtorrentClient) -> str:
for method in ("session.path", "get_session"):
try:
value = str(c.call(method) or "").strip()
except Exception:
continue
if value:
return _remote_clean_path(value)
return ""
def _torrent_source_file_candidates(c: ScgiRtorrentClient, torrent_hash: str) -> list[str]:
# Note: rTorrent may keep stale watch/tied paths; session candidates preserve .torrent export when the original source was moved.
candidates: list[str] = []
for method in ("d.tied_to_file", "d.get_tied_to_file", "d.loaded_file", "d.get_loaded_file", "d.session_file", "d.get_session_file"): for method in ("d.tied_to_file", "d.get_tied_to_file", "d.loaded_file", "d.get_loaded_file", "d.session_file", "d.get_session_file"):
try: try:
value = str(c.call(method, torrent_hash) or "").strip() value = str(c.call(method, torrent_hash) or "").strip()
except Exception: except Exception:
continue continue
if value: if value:
return value candidates.append(value)
session_path = _rtorrent_session_path(c)
hash_values = []
clean_hash = str(torrent_hash or "").strip()
if clean_hash:
hash_values.extend([clean_hash, clean_hash.upper(), clean_hash.lower()])
for h in dict.fromkeys(hash_values):
if session_path:
candidates.append(_remote_join(session_path, f"{h}.torrent"))
candidates.append(f"/tmp/{h}.torrent")
result = []
for item in candidates:
clean = _remote_clean_path(item)
if clean and clean not in result:
result.append(clean)
return result
def _torrent_source_file(c: ScgiRtorrentClient, torrent_hash: str) -> str:
for source in _torrent_source_file_candidates(c, torrent_hash):
if _remote_file_exists(c, source):
return source
return "" return ""
@@ -265,16 +707,16 @@ def export_torrent_file(profile: dict, torrent_hash: str) -> dict:
c = client_for(profile) c = client_for(profile)
name = str(c.call("d.name", torrent_hash) or torrent_hash).strip() or torrent_hash name = str(c.call("d.name", torrent_hash) or torrent_hash).strip() or torrent_hash
filename = f"{name}.torrent" if not name.lower().endswith(".torrent") else name filename = f"{name}.torrent" if not name.lower().endswith(".torrent") else name
source = _torrent_source_file(c, torrent_hash)
if source:
# Note: Stream the existing .torrent source directly instead of copying it to a temporary staged file first.
return {"path": source, "download_name": filename, "local": False}
raw = _torrent_raw_from_method(c, torrent_hash) raw = _torrent_raw_from_method(c, torrent_hash)
if raw: if raw:
target = LocalPath(download_tmp_dir()) / f"pytorrent-download-{uuid.uuid4().hex}.torrent" target = LocalPath(download_tmp_dir()) / f"pytorrent-download-{uuid.uuid4().hex}.torrent"
target.write_bytes(raw) target.write_bytes(raw)
return {"path": str(target), "download_name": filename, "local": True} return {"path": str(target), "download_name": filename, "local": True}
source = _torrent_source_file(c, torrent_hash) raise RuntimeError("Cannot find torrent source file in rTorrent")
if not source:
raise RuntimeError("Cannot find torrent source file in rTorrent")
staged = _remote_stage_path(c, source, ".torrent")
return {"path": staged, "download_name": filename, "local": False}
def set_file_priorities(profile: dict, torrent_hash: str, files: list[dict]) -> dict: def set_file_priorities(profile: dict, torrent_hash: str, files: list[dict]) -> dict:

View File

@@ -149,7 +149,8 @@ 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()
started_after_wait = bool(int(row.get("state") or 0)) and status != "checking" # Note: rTorrent may report state=1 after a recheck even when the download is not really active yet.
started_after_wait = bool(int(row.get("state") or 0)) and bool(int(row.get("active") or 0)) and status != "checking"
if not (_row_progress_complete(row) or status == "seeding" or started_after_wait): 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. # Note: Keep the post-check label while the torrent is stopped; remove it once it is started for download/seeding.
@@ -197,8 +198,8 @@ def apply_post_check_policy(profile: dict, rows: list[dict], previous_rows: dict
except Exception: except Exception:
pass pass
c.call("d.custom1.set", h, label_value) c.call("d.custom1.set", h, label_value)
row.update({"state": 0, "active": 0, "paused": False, "status": "Stopped", "label": label_value}) row.update({"state": 0, "active": 0, "paused": False, "post_check": True, "status": "Post-check", "label": label_value})
changes.append({"hash": h, "action": "stop_and_label_after_check", "complete": False, "label": POST_CHECK_DOWNLOAD_LABEL}) changes.append({"hash": h, "action": "mark_post_check_waiting", "complete": False, "label": POST_CHECK_DOWNLOAD_LABEL})
_clear_post_check_watch(profile_id, h) _clear_post_check_watch(profile_id, h)
except Exception as exc: except Exception as exc:
changes.append({"hash": h, "action": "post_check_policy_failed", "error": str(exc)}) changes.append({"hash": h, "action": "post_check_policy_failed", "error": str(exc)})
@@ -216,6 +217,13 @@ TORRENT_OPTIONAL_FIELDS = [
"d.timestamp.finished=", "d.timestamp.finished=",
] ]
LIVE_TORRENT_FIELDS = [
"d.hash=", "d.state=", "d.complete=", "d.size_bytes=", "d.completed_bytes=",
"d.ratio=", "d.up.rate=", "d.down.rate=", "d.up.total=", "d.down.total=",
"d.peers_connected=", "d.peers_complete=", "d.message=", "d.hashing=", "d.is_active=",
"d.custom1=",
]
def human_duration(seconds: int) -> str: def human_duration(seconds: int) -> str:
# Note: Download ETA is derived locally from remaining bytes and current download speed. # Note: Download ETA is derived locally from remaining bytes and current download speed.
@@ -267,8 +275,10 @@ def normalize_row(row: list) -> dict:
complete = int(row[3] or 0) complete = int(row[3] or 0)
# Note: d.hashing is authoritative; stale "hash check complete" messages must not keep the UI in Checking forever. # Note: d.hashing is authoritative; stale "hash check complete" messages must not keep the UI in Checking forever.
is_checking = bool(hashing) or _message_indicates_active_check(msg_l) is_checking = bool(hashing) or _message_indicates_active_check(msg_l)
is_paused = bool(state) and not bool(is_active) and not is_checking post_check = POST_CHECK_DOWNLOAD_LABEL in _label_names(str(row[17] or "")) and not is_checking and not bool(is_active)
status = "Checking" if is_checking else "Paused" if is_paused else "Seeding" if complete and state else "Downloading" if state else "Stopped" is_paused = bool(state) and not bool(is_active) and not is_checking and not post_check
# Note: Post-check is an application-level state that separates torrents waiting after a recheck from manually stopped torrents.
status = "Checking" if is_checking else "Post-check" if post_check else "Paused" if is_paused else "Seeding" if complete and state else "Downloading" if state else "Stopped"
to_download_bytes = remaining_bytes if not complete else 0 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. # Note: The To download column is only meaningful for incomplete torrents; complete rows expose an empty display value.
return { return {
@@ -305,10 +315,70 @@ def normalize_row(row: list) -> dict:
"ratio_group": str(row[18] or ""), "ratio_group": str(row[18] or ""),
"message": msg, "message": msg,
"status": status, "status": status,
"post_check": post_check,
"hashing": hashing, "hashing": hashing,
} }
def normalize_live_row(row: list) -> dict:
"""Normalize the small row used by the fast live stats poller."""
# Note: The live poller intentionally reads only volatile fields so the main list poller can run less often.
size = int(row[3] or 0)
completed = int(row[4] or 0)
complete = int(row[2] or 0)
state = int(row[1] or 0)
down_rate = int(row[7] or 0)
up_rate = int(row[6] or 0)
ratio_raw = int(row[5] or 0)
remaining_bytes = max(0, size - completed)
eta_seconds = int(remaining_bytes / down_rate) if down_rate > 0 and not complete else 0
msg = str(row[12] or "")
hashing = int(row[13] or 0)
is_active = int(row[14] or 0)
labels = str(row[15] or "")
is_checking = bool(hashing) or _message_indicates_active_check(msg.lower())
post_check = POST_CHECK_DOWNLOAD_LABEL in _label_names(labels) and not is_checking and not bool(is_active)
is_paused = bool(state) and not bool(is_active) and not is_checking and not post_check
status = "Checking" if is_checking else "Post-check" if post_check else "Paused" if is_paused else "Seeding" if complete and state else "Downloading" if state else "Stopped"
progress = 100.0 if size <= 0 and complete else round((completed / size) * 100, 2) if size else 0.0
to_download_bytes = remaining_bytes if not complete else 0
return {
"hash": str(row[0] or ""),
"state": state,
"active": is_active,
"paused": is_paused,
"complete": complete,
"completed_bytes": completed,
"progress": progress,
"ratio": round(ratio_raw / 1000, 3),
"up_rate": up_rate,
"up_rate_h": human_rate(up_rate),
"down_rate": down_rate,
"down_rate_h": human_rate(down_rate),
"eta_seconds": eta_seconds,
"eta_h": human_duration(eta_seconds) if eta_seconds else "-",
"up_total": int(row[8] or 0),
"up_total_h": human_size(row[8] or 0),
"down_total": int(row[9] or 0),
"down_total_h": human_size(row[9] or 0),
"to_download": to_download_bytes,
"to_download_h": human_size(to_download_bytes) if to_download_bytes else "",
"peers": int(row[10] or 0),
"seeds": int(row[11] or 0),
"message": msg,
"status": status,
"post_check": post_check,
"hashing": hashing,
}
def list_torrent_live_stats(profile: dict) -> list[dict]:
"""Return lightweight live torrent stats for the fast poller."""
# Note: This avoids the full torrent row multicall on every speed/status tick.
rows = client_for(profile).d.multicall2("", "main", *LIVE_TORRENT_FIELDS)
return [normalize_live_row(list(row)) for row in rows]
def list_torrents(profile: dict) -> list[dict]: def list_torrents(profile: dict) -> list[dict]:
c = client_for(profile) c = client_for(profile)
try: try:
@@ -545,12 +615,16 @@ def _download_runtime_state(c: ScgiRtorrentClient, h: str) -> dict:
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. # Note: In rTorrent, pause does not change d.state. Paused means state=1, open=1, active=0.
label = _str_rpc(c, 'd.custom1', h)
post_check = POST_CHECK_DOWNLOAD_LABEL in _label_names(label) and not bool(active)
return { return {
'state': state, 'state': state,
'open': opened, 'open': opened,
'active': active, 'active': active,
'paused': bool(state and opened and not active), 'paused': bool(state and opened and not active and not post_check),
'stopped': not bool(state), 'stopped': not bool(state),
'post_check': post_check,
'label': label,
'message': _str_rpc(c, 'd.message', h), 'message': _str_rpc(c, 'd.message', h),
} }
@@ -590,10 +664,14 @@ def stop_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
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': []}
if before.get('stopped'): if before.get('stopped') and not before.get('post_check'):
result.update({'ok': True, 'skipped': 'already_stopped', 'after': before}) result.update({'ok': True, 'skipped': 'already_stopped', 'after': before})
return result return result
try: try:
# Note: User Stop converts the app-level Post-check state into a regular stopped torrent.
if before.get('post_check'):
clear_post_check_download_label(c, h, before.get('label'))
result['commands'].append('clear_post_check_label')
# Note: Smart Queue now enforces the queue with d.stop only; user-paused torrents stay untouched. # Note: Smart Queue now enforces the queue with d.stop only; user-paused torrents stay untouched.
c.call('d.stop', h) c.call('d.stop', h)
result['commands'].append('d.stop') result['commands'].append('d.stop')
@@ -643,10 +721,13 @@ def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str, prefer_start:
result: dict = {'hash': h, 'before': before, 'commands': []} result: dict = {'hash': h, 'before': before, 'commands': []}
if before.get('active'): if before.get('active'):
if before.get('post_check'):
clear_post_check_download_label(c, h, before.get('label'))
before = _download_runtime_state(c, h)
result.update({'ok': True, 'skipped': 'already_active', 'after': before}) result.update({'ok': True, 'skipped': 'already_active', 'after': before})
return result return result
if before.get('paused') and not prefer_start: if before.get('paused') and not prefer_start and not before.get('post_check'):
# Note: Manual Start keeps the clean pause-to-resume path. Do not classify every # 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 # 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. # open/inactive rTorrent state and needs d.open + d.start.
@@ -654,6 +735,13 @@ def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str, prefer_start:
resumed['mode'] = 'resume_paused' resumed['mode'] = 'resume_paused'
return resumed return resumed
if before.get('post_check'):
try:
# Note: Post-check start first forces a clean stopped state, matching the manual Stop -> Start recovery path.
c.call('d.stop', h)
result['commands'].append('d.stop')
except Exception as exc:
result.setdefault('ignored_errors', []).append(f'd.stop: {exc}')
try: try:
c.call('d.open', h) c.call('d.open', h)
result['commands'].append('d.open') result['commands'].append('d.open')
@@ -670,7 +758,13 @@ def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str, prefer_start:
except Exception as exc2: except Exception as exc2:
result.setdefault('ignored_errors', []).append(f'd.try_start: {exc2}') result.setdefault('ignored_errors', []).append(f'd.try_start: {exc2}')
result['ok'] = False result['ok'] = False
result['after'] = _download_runtime_state(c, h) after = _download_runtime_state(c, h)
if before.get('post_check') and after.get('active'):
# Note: The marker stays in place when start fails so the row remains visible in the Post-check filter.
clear_post_check_download_label(c, h, before.get('label'))
result['commands'].append('clear_post_check_label')
after = _download_runtime_state(c, h)
result['after'] = after
result['ok'] = result.get('ok', True) result['ok'] = result.get('ok', True)
return result return result

View File

@@ -135,9 +135,8 @@ def _int_setting(data: dict[str, Any], current: dict[str, Any], key: str, defaul
return max(minimum, int(default)) return max(minimum, int(default))
def _default_settings(user_id: int, profile_id: int) -> dict[str, Any]: def _default_settings(profile_id: int) -> dict[str, Any]:
return { return {
'user_id': user_id,
'profile_id': profile_id, 'profile_id': profile_id,
'enabled': 0, 'enabled': 0,
'max_active_downloads': 5, 'max_active_downloads': 5,
@@ -162,18 +161,16 @@ def _default_settings(user_id: int, profile_id: int) -> dict[str, Any]:
def get_settings(profile_id: int, user_id: int | None = None) -> dict[str, Any]: def get_settings(profile_id: int, user_id: int | None = None) -> dict[str, Any]:
user_id = user_id or default_user_id()
with connect() as conn: with connect() as conn:
row = conn.execute( row = conn.execute(
'SELECT * FROM smart_queue_settings WHERE user_id=? AND profile_id=?', 'SELECT * FROM smart_queue_settings WHERE profile_id=?',
(user_id, profile_id), (profile_id,),
).fetchone() ).fetchone()
settings = dict(row or _default_settings(user_id, profile_id)) settings = dict(row or _default_settings(profile_id))
return settings return settings
def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = None) -> dict[str, Any]: def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = None) -> dict[str, Any]:
user_id = user_id or default_user_id()
current = get_settings(profile_id, user_id) current = get_settings(profile_id, user_id)
settings = { settings = {
'enabled': 1 if data.get('enabled', current.get('enabled')) else 0, 'enabled': 1 if data.get('enabled', current.get('enabled')) else 0,
@@ -214,9 +211,9 @@ def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = N
now = utcnow() now = utcnow()
with connect() as conn: with connect() as conn:
conn.execute( conn.execute(
'''INSERT INTO smart_queue_settings(user_id,profile_id,enabled,max_active_downloads,stalled_seconds,min_speed_bytes,min_seeds,min_peers,ignore_seed_peer,ignore_speed,manage_stopped,cooldown_minutes,stop_batch_size,start_grace_seconds,protect_active_below_cap,auto_stop_idle,refill_enabled,refill_interval_minutes,updated_at) '''INSERT INTO smart_queue_settings(profile_id,enabled,max_active_downloads,stalled_seconds,min_speed_bytes,min_seeds,min_peers,ignore_seed_peer,ignore_speed,manage_stopped,cooldown_minutes,stop_batch_size,start_grace_seconds,protect_active_below_cap,auto_stop_idle,refill_enabled,refill_interval_minutes,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(user_id, profile_id) DO UPDATE SET ON CONFLICT(profile_id) DO UPDATE SET
enabled=excluded.enabled, enabled=excluded.enabled,
max_active_downloads=excluded.max_active_downloads, max_active_downloads=excluded.max_active_downloads,
stalled_seconds=excluded.stalled_seconds, stalled_seconds=excluded.stalled_seconds,
@@ -234,80 +231,74 @@ def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = N
refill_enabled=excluded.refill_enabled, refill_enabled=excluded.refill_enabled,
refill_interval_minutes=excluded.refill_interval_minutes, refill_interval_minutes=excluded.refill_interval_minutes,
updated_at=excluded.updated_at''', updated_at=excluded.updated_at''',
(user_id, profile_id, settings['enabled'], settings['max_active_downloads'], settings['stalled_seconds'], settings['min_speed_bytes'], settings['min_seeds'], settings['min_peers'], settings['ignore_seed_peer'], settings['ignore_speed'], settings['manage_stopped'], settings['cooldown_minutes'], settings['stop_batch_size'], settings['start_grace_seconds'], settings['protect_active_below_cap'], settings['auto_stop_idle'], settings['refill_enabled'], settings['refill_interval_minutes'], now), (profile_id, settings['enabled'], settings['max_active_downloads'], settings['stalled_seconds'], settings['min_speed_bytes'], settings['min_seeds'], settings['min_peers'], settings['ignore_seed_peer'], settings['ignore_speed'], settings['manage_stopped'], settings['cooldown_minutes'], settings['stop_batch_size'], settings['start_grace_seconds'], settings['protect_active_below_cap'], settings['auto_stop_idle'], settings['refill_enabled'], settings['refill_interval_minutes'], now),
) )
return get_settings(profile_id, user_id) return get_settings(profile_id, user_id)
def list_exclusions(profile_id: int, user_id: int | None = None) -> list[dict[str, Any]]: def list_exclusions(profile_id: int, user_id: int | None = None) -> list[dict[str, Any]]:
user_id = user_id or default_user_id()
with connect() as conn: with connect() as conn:
return conn.execute( return conn.execute(
'SELECT * FROM smart_queue_exclusions WHERE user_id=? AND profile_id=? ORDER BY created_at DESC', 'SELECT * FROM smart_queue_exclusions WHERE profile_id=? ORDER BY created_at DESC',
(user_id, profile_id), (profile_id,),
).fetchall() ).fetchall()
def set_exclusion(profile_id: int, torrent_hash: str, excluded: bool, reason: str = '', user_id: int | None = None) -> None: def set_exclusion(profile_id: int, torrent_hash: str, excluded: bool, reason: str = '', user_id: int | None = None) -> None:
user_id = user_id or default_user_id()
now = utcnow() now = utcnow()
with connect() as conn: with connect() as conn:
if excluded: if excluded:
conn.execute( conn.execute(
'INSERT OR REPLACE INTO smart_queue_exclusions(user_id,profile_id,torrent_hash,reason,created_at) VALUES(?,?,?,?,?)', 'INSERT OR REPLACE INTO smart_queue_exclusions(profile_id,torrent_hash,reason,created_at) VALUES(?,?,?,?)',
(user_id, profile_id, torrent_hash, reason, now), (profile_id, torrent_hash, reason, now),
) )
else: else:
conn.execute( conn.execute(
'DELETE FROM smart_queue_exclusions WHERE user_id=? AND profile_id=? AND torrent_hash=?', 'DELETE FROM smart_queue_exclusions WHERE profile_id=? AND torrent_hash=?',
(user_id, profile_id, torrent_hash), (profile_id, torrent_hash),
) )
def add_history(profile_id: int, event: str, paused: list[str] | None = None, resumed: list[str] | None = None, checked: int = 0, details: dict[str, Any] | None = None, user_id: int | None = None) -> None: def add_history(profile_id: int, event: str, paused: list[str] | None = None, resumed: list[str] | None = None, checked: int = 0, details: dict[str, Any] | None = None, user_id: int | None = None) -> None:
user_id = user_id or default_user_id()
paused = paused or [] paused = paused or []
resumed = resumed or [] resumed = resumed or []
details = details or {} details = details or {}
with connect() as conn: with connect() as conn:
conn.execute( conn.execute(
'INSERT INTO smart_queue_history(user_id,profile_id,event,paused_count,resumed_count,checked_count,details_json,created_at) VALUES(?,?,?,?,?,?,?,?)', 'INSERT INTO smart_queue_history(profile_id,event,paused_count,resumed_count,checked_count,details_json,created_at) VALUES(?,?,?,?,?,?,?)',
(user_id, profile_id, event, len(paused), len(resumed), int(checked or 0), json.dumps({**details, 'paused': paused, 'resumed': resumed}), utcnow()), (profile_id, event, len(paused), len(resumed), int(checked or 0), json.dumps({**details, 'paused': paused, 'resumed': resumed}), utcnow()),
) )
def list_history(profile_id: int, user_id: int | None = None, limit: int = 30) -> list[dict[str, Any]]: def list_history(profile_id: int, user_id: int | None = None, limit: int = 30) -> list[dict[str, Any]]:
user_id = user_id or default_user_id()
with connect() as conn: with connect() as conn:
return conn.execute( return conn.execute(
'SELECT * FROM smart_queue_history WHERE user_id=? AND profile_id=? ORDER BY created_at DESC LIMIT ?', 'SELECT * FROM smart_queue_history WHERE profile_id=? ORDER BY created_at DESC LIMIT ?',
(user_id, profile_id, max(1, min(int(limit or 30), 100))), (profile_id, max(1, min(int(limit or 30), 100))),
).fetchall() ).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:
"""Delete Smart Queue history rows for the current profile and return the removed count.""" """Delete Smart Queue history rows for the current profile and return the removed count."""
# Note: Manual cleanup only removes audit history; settings, exclusions and pending queue state stay untouched. # Note: Manual cleanup only removes audit history; settings, exclusions and pending queue state stay untouched.
user_id = user_id or default_user_id()
with connect() as conn: with connect() as conn:
row = conn.execute( row = conn.execute(
'SELECT COUNT(*) AS count FROM smart_queue_history WHERE user_id=? AND profile_id=?', 'SELECT COUNT(*) AS count FROM smart_queue_history WHERE profile_id=?',
(user_id, profile_id), (profile_id,),
).fetchone() ).fetchone()
count = int((row or {}).get('count') or 0) count = int((row or {}).get('count') or 0)
conn.execute( conn.execute(
'DELETE FROM smart_queue_history WHERE user_id=? AND profile_id=?', 'DELETE FROM smart_queue_history WHERE profile_id=?',
(user_id, profile_id), (profile_id,),
) )
return count return count
def count_history(profile_id: int, user_id: int | None = None) -> int: def count_history(profile_id: int, user_id: int | None = None) -> int:
user_id = user_id or default_user_id()
with connect() as conn: with connect() as conn:
row = conn.execute( row = conn.execute(
'SELECT COUNT(*) AS count FROM smart_queue_history WHERE user_id=? AND profile_id=?', 'SELECT COUNT(*) AS count FROM smart_queue_history WHERE profile_id=?',
(user_id, profile_id), (profile_id,),
).fetchone() ).fetchone()
return int((row or {}).get('count') or 0) return int((row or {}).get('count') or 0)
@@ -315,11 +306,10 @@ def count_history(profile_id: int, user_id: int | None = None) -> int:
def _latest_history_event(profile_id: int, user_id: int | None = None) -> str: def _latest_history_event(profile_id: int, user_id: int | None = None) -> str:
"""Return the newest Smart Queue history event for duplicate suppression.""" """Return the newest Smart Queue history event for duplicate suppression."""
# Note: Disabled Smart Queue should leave one waiting marker, not a poller-generated log stream. # Note: Disabled Smart Queue should leave one waiting marker, not a poller-generated log stream.
user_id = user_id or default_user_id()
with connect() as conn: with connect() as conn:
row = conn.execute( row = conn.execute(
'SELECT event FROM smart_queue_history WHERE user_id=? AND profile_id=? ORDER BY created_at DESC LIMIT 1', 'SELECT event FROM smart_queue_history WHERE profile_id=? ORDER BY created_at DESC LIMIT 1',
(user_id, profile_id), (profile_id,),
).fetchone() ).fetchone()
return str((row or {}).get('event') or '') return str((row or {}).get('event') or '')
@@ -338,8 +328,8 @@ def _record_disabled_waiting_once(profile_id: int, user_id: int, details: dict[s
return True return True
def _excluded_hashes(profile_id: int, user_id: int) -> set[str]: def _excluded_hashes(profile_id: int, user_id: int | None = None) -> set[str]:
return {r['torrent_hash'] for r in list_exclusions(profile_id, user_id)} return {r['torrent_hash'] for r in list_exclusions(profile_id)}
@@ -891,7 +881,7 @@ def _refill_mode(settings: dict[str, Any]) -> str:
def _mark_refill_run(profile_id: int, user_id: int) -> None: def _mark_refill_run(profile_id: int, user_id: int) -> None:
# Note: Custom refill interval is measured from the last lightweight refill attempt. # Note: Custom refill interval is measured from the last lightweight refill attempt.
with connect() as conn: with connect() as conn:
conn.execute('UPDATE smart_queue_settings SET last_refill_at=?, updated_at=? WHERE user_id=? AND profile_id=?', (utcnow(), utcnow(), user_id, profile_id)) conn.execute('UPDATE smart_queue_settings SET last_refill_at=?, updated_at=? WHERE profile_id=?', (utcnow(), utcnow(), profile_id))
def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_id: int, user_id: int) -> dict[str, Any]: def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_id: int, user_id: int) -> dict[str, Any]:
@@ -987,13 +977,37 @@ def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_i
| {str(t.get('hash') or '') for t in stopped if _has_smart_queue_label(str(t.get('label') or '')) and str(t.get('hash') or '') not in set(started_by_queue)} | {str(t.get('hash') or '') for t in stopped if _has_smart_queue_label(str(t.get('label') or '')) and str(t.get('hash') or '') not in set(started_by_queue)}
) )
restored = _cleanup_auto_labels(c, profile_id, torrents, keep_labels, True) restored = _cleanup_auto_labels(c, profile_id, torrents, keep_labels, True)
# Note: Cooldown refill uses started incomplete torrents as queue slots. This diagnostic
# explains why a refill may legitimately start nothing even when only a few torrents transfer data.
active_transferring = sum(1 for t in downloading if int(t.get('down_rate') or 0) > 0 or int(t.get('up_rate') or 0) > 0)
active_rtorrent = sum(1 for t in downloading if int(t.get('active') or 0))
active_state = sum(1 for t in downloading if int(t.get('state') or 0))
active_after_expected = len(downloading) + len(start_requested)
if available_slots <= 0:
refill_decision = f'Cooldown refill skipped: active slots at limit ({len(downloading)}/{max_active})'
refill_blocked_reason = 'active_slots_at_limit'
elif not candidates:
refill_decision = 'Cooldown refill skipped: no stopped candidates available'
refill_blocked_reason = 'no_candidates'
elif start_requested:
refill_decision = f'Cooldown refill requested {len(start_requested)} start(s)'
refill_blocked_reason = ''
else:
refill_decision = 'Cooldown refill ran but rTorrent did not confirm new starts yet'
refill_blocked_reason = 'start_not_confirmed'
details = { details = {
'decision': refill_decision,
'blocked_reason': refill_blocked_reason,
'enabled': bool(settings.get('enabled')), 'enabled': bool(settings.get('enabled')),
'cooldown_refill': True, 'cooldown_refill': True,
'cooldown_respected': True, 'cooldown_respected': True,
'refill_mode': _refill_mode(settings), 'refill_mode': _refill_mode(settings),
'refill_interval_minutes': int(settings.get('refill_interval_minutes') or 0), 'refill_interval_minutes': int(settings.get('refill_interval_minutes') or 0),
'active_before': len(downloading), 'active_before': len(downloading),
'active_after_expected': active_after_expected,
'active_transferring_count': active_transferring,
'active_rtorrent_count': active_rtorrent,
'active_state_count': active_state,
'available_slots': available_slots, 'available_slots': available_slots,
'candidates': len(candidates), 'candidates': len(candidates),
'start_source_skipped': len(source_skipped), 'start_source_skipped': len(source_skipped),
@@ -1024,6 +1038,10 @@ def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_i
'max_active_downloads': max_active, 'max_active_downloads': max_active,
'available_slots': available_slots, 'available_slots': available_slots,
'candidates': len(candidates), 'candidates': len(candidates),
'active_transferring': active_transferring,
'active_rtorrent': active_rtorrent,
'active_state': active_state,
'blocked_reason': refill_blocked_reason,
'start_source_skipped': len(source_skipped), 'start_source_skipped': len(source_skipped),
'requested': len(start_requested), 'requested': len(start_requested),
'verified': len(active_verified), 'verified': len(active_verified),
@@ -1079,7 +1097,11 @@ def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_i
'start_pending_confirmation': start_pending_confirmation, 'start_pending_confirmation': start_pending_confirmation,
'active_verified': active_verified, 'active_verified': active_verified,
'active_before': len(downloading), 'active_before': len(downloading),
'active_after_expected': len(downloading) + len(started_by_queue), 'active_after_expected': active_after_expected,
'active_transferring_count': active_transferring,
'active_rtorrent_count': active_rtorrent,
'active_state_count': active_state,
'blocked_reason': refill_blocked_reason,
'available_slots': available_slots, 'available_slots': available_slots,
'start_source_skipped': len(source_skipped), 'start_source_skipped': len(source_skipped),
'checked': len(torrents), 'checked': len(torrents),
@@ -1090,13 +1112,13 @@ def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_i
def mark_run(profile_id: int, user_id: int | None = None) -> None: def mark_run(profile_id: int, user_id: int | None = None) -> None:
user_id = user_id or default_user_id() user_id = user_id or default_user_id()
with connect() as conn: with connect() as conn:
conn.execute('UPDATE smart_queue_settings SET last_run_at=?, updated_at=? WHERE user_id=? AND profile_id=?', (utcnow(), utcnow(), user_id, profile_id)) conn.execute('UPDATE smart_queue_settings SET last_run_at=?, updated_at=? WHERE profile_id=?', (utcnow(), utcnow(), profile_id))
def _disable_when_idle(profile_id: int, user_id: int, torrents: list[dict[str, Any]], details: dict[str, Any]) -> dict[str, Any]: def _disable_when_idle(profile_id: int, user_id: int, torrents: list[dict[str, Any]], details: dict[str, Any]) -> dict[str, Any]:
# Note: Auto-stop is intentionally profile-scoped and only flips the Smart Queue enabled flag; saved thresholds remain intact. # Note: Auto-stop is intentionally profile-scoped and only flips the Smart Queue enabled flag; saved thresholds remain intact.
now = utcnow() now = utcnow()
with connect() as conn: with connect() as conn:
conn.execute('UPDATE smart_queue_settings SET enabled=0, last_run_at=?, updated_at=? WHERE user_id=? AND profile_id=?', (now, now, user_id, profile_id)) conn.execute('UPDATE smart_queue_settings SET enabled=0, last_run_at=?, updated_at=? WHERE profile_id=?', (now, now, profile_id))
add_history(profile_id, 'auto_stopped_idle', [], [], len(torrents), details, user_id) add_history(profile_id, 'auto_stopped_idle', [], [], len(torrents), details, user_id)
settings = get_settings(profile_id, user_id) settings = get_settings(profile_id, user_id)
return {'ok': True, 'enabled': False, 'auto_stopped_idle': True, 'paused': [], 'resumed': [], 'stopped': [], 'started': [], 'checked': len(torrents), 'settings': settings, 'message': 'Smart Queue stopped because there is no active or waiting work.'} return {'ok': True, 'enabled': False, 'auto_stopped_idle': True, 'paused': [], 'resumed': [], 'stopped': [], 'started': [], 'checked': len(torrents), 'settings': settings, 'message': 'Smart Queue stopped because there is no active or waiting work.'}

View File

@@ -4,6 +4,8 @@ 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"}
_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"}
@@ -33,6 +35,42 @@ class TorrentCache:
self._updated_at.pop(profile_id, None) self._updated_at.pop(profile_id, None)
return removed return removed
def refresh_live(self, profile: dict) -> dict:
"""Refresh only volatile live fields without replacing the full cached torrent rows."""
# Note: The fast poller uses this lightweight path so speeds/statuses can update often while the full list poller stays slower.
profile_id = int(profile["id"])
try:
rows = rtorrent.list_torrent_live_stats(profile)
live = {t["hash"]: t for t in rows if t.get("hash")}
with self._lock:
old = dict(self._data.get(profile_id, {}))
if not old:
self._errors[profile_id] = ""
return {"ok": True, "profile_id": profile_id, "updated": [], "missing": [], "unknown": list(live.keys()), "requires_full_refresh": bool(live)}
updated = []
for h, live_row in live.items():
current = old.get(h)
if not current:
continue
patch = {"hash": h}
for key in _LIVE_KEYS:
if key in live_row and current.get(key) != live_row.get(key):
patch[key] = live_row.get(key)
if len(patch) > 1:
current.update({k: v for k, v in patch.items() if k != "hash"})
updated.append(patch)
missing = [h for h in old.keys() if h not in live]
unknown = [h for h in live.keys() if h not in old]
self._data[profile_id] = old
self._errors[profile_id] = ""
self._updated_at[profile_id] = time()
return {"ok": True, "profile_id": profile_id, "updated": updated, "missing": missing, "unknown": unknown, "requires_full_refresh": bool(missing or unknown)}
except Exception as exc:
with self._lock:
self._errors[profile_id] = str(exc)
return {"ok": False, "profile_id": profile_id, "error": str(exc), "updated": [], "missing": [], "unknown": [], "requires_full_refresh": False}
def refresh(self, profile: dict) -> dict: def refresh(self, profile: dict) -> dict:
profile_id = int(profile["id"]) profile_id = int(profile["id"])
try: try:

View File

@@ -19,7 +19,7 @@ _ERROR_PATTERNS = (
"unreachable", "unreachable",
"denied", "denied",
) )
_SUMMARY_TYPES = ("all", "downloading", "seeding", "paused", "checking", "error", "stopped") _SUMMARY_TYPES = ("all", "downloading", "seeding", "paused", "checking", "error", "post_check", "stopped")
_summary_cache: dict[int, dict] = {} _summary_cache: dict[int, dict] = {}
_summary_lock = RLock() _summary_lock = RLock()
@@ -55,9 +55,12 @@ def _matches(row: dict, summary_type: str) -> bool:
return checking return checking
if summary_type == "error": if summary_type == "error":
return _has_error(row) return _has_error(row)
if summary_type == "post_check":
# Note: Post-check is counted separately from Stopped so automation can target it safely.
return str(row.get("status") or "") == "Post-check" or bool(row.get("post_check"))
if summary_type == "stopped": if summary_type == "stopped":
# Note: Stopped count follows the UI filter exactly, so torrents being hash-checked do not inflate an empty Stopped list. # Note: Stopped count follows the UI filter exactly and excludes app-level post-check waiting rows.
return not checking and not bool(row.get("state")) return not checking and not bool(row.get("state")) and str(row.get("status") or "") != "Post-check" and not bool(row.get("post_check"))
return False return False

View File

@@ -6,6 +6,7 @@ import json
import psutil import psutil
from flask_socketio import emit, join_room, leave_room, disconnect from flask_socketio import emit, join_room, leave_room, disconnect
from .preferences import active_profile, get_profile from .preferences import active_profile, get_profile
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
@@ -38,13 +39,15 @@ def _emit_profile(socketio, event: str, payload: dict, profile_id: int) -> None:
def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None: 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())
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) if auth.enabled() else None)
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:
result = smart_queue.check(profile, force=False) result = smart_queue.check(profile, user_id=profile_user_id, force=False)
if result.get("enabled"): if result.get("enabled"):
_emit_profile(socketio, "smart_queue_update", result, profile_id) _emit_profile(socketio, "smart_queue_update", result, profile_id)
if result.get("stopped") or result.get("started") or result.get("start_requested") or result.get("paused") or result.get("resumed"): if result.get("stopped") or result.get("started") or result.get("start_requested") or result.get("paused") or result.get("resumed"):
@@ -55,13 +58,13 @@ def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None:
except Exception as exc: except Exception as exc:
_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, 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"):
_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)
try: try:
plan_result = download_planner.enforce(profile, force=False) plan_result = download_planner.enforce(profile, force=False, user_id=profile_user_id)
if plan_result.get("enabled") and not plan_result.get("skipped"): if plan_result.get("enabled") and not plan_result.get("skipped"):
_emit_profile(socketio, "download_plan_update", plan_result, profile_id) _emit_profile(socketio, "download_plan_update", plan_result, profile_id)
except Exception as exc: except Exception as exc:
@@ -103,7 +106,7 @@ def register_socketio_handlers(socketio):
def poller(): def poller():
while True: while True:
loop_started = time.monotonic() loop_started = time.monotonic()
next_sleep = poller_control.MIN_POLL_INTERVAL_SECONDS next_sleep = 10.0
for profile in _poller_profiles(): for profile in _poller_profiles():
if not profile: if not profile:
continue continue
@@ -111,47 +114,96 @@ def register_socketio_handlers(socketio):
settings = poller_control.get_settings(pid) settings = poller_control.get_settings(pid)
state = poller_control.state_for(pid) state = poller_control.state_for(pid)
now = time.monotonic() now = time.monotonic()
next_sleep = min(next_sleep, poller_control.effective_fast_interval(settings, state)) live_interval = poller_control.effective_live_interval(settings, state)
if not poller_control.should_fast_poll(now, settings, state): list_interval = poller_control.effective_list_interval(settings, state)
next_sleep = min(
next_sleep,
max(poller_control.MIN_POLL_INTERVAL_SECONDS, live_interval - (now - state.last_live_at)),
max(poller_control.MIN_POLL_INTERVAL_SECONDS, list_interval - (now - state.last_list_at)),
max(poller_control.MIN_POLL_INTERVAL_SECONDS, float(settings["system_stats_interval_seconds"]) - (now - state.last_system_at)),
max(poller_control.MIN_POLL_INTERVAL_SECONDS, float(settings["slow_stats_interval_seconds"]) - (now - state.last_slow_at)),
max(poller_control.MIN_POLL_INTERVAL_SECONDS, float(settings["queue_stats_interval_seconds"]) - (now - state.last_queue_at)),
)
run_live = poller_control.should_live_poll(now, settings, state)
run_list = poller_control.should_list_poll(now, settings, state)
run_system = poller_control.should_system_poll(now, settings, state)
run_slow = poller_control.should_slow_poll(now, settings, state)
run_queue = poller_control.should_queue_poll(now, settings, state)
if not (run_live or run_list or run_system or run_slow or run_queue):
continue continue
tick_started = time.monotonic() tick_started = time.monotonic()
changed = False changed = False
ok = True ok = True
error = "" error = ""
active = False active = state.last_active
emitted_payload_size = 0 emitted_payload_size = 0
rtorrent_call_count = 0 rtorrent_call_count = 0
skipped_emissions = 0 skipped_emissions = 0
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:
diff = torrent_cache.refresh(profile)
rtorrent_call_count += 1
state.last_fast_at = now
ok = bool(diff.get("ok"))
error = str(diff.get("error") or "")
rows = torrent_cache.snapshot(pid) rows = torrent_cache.snapshot(pid)
active = _is_active_rows(rows) speed_status = _speed_status_from_rows(pid, rows)
speed_status = _speed_status_from_rows(pid, rows) if diff.get("ok") else None
if diff.get("ok") and (diff["added"] or diff["updated"] or diff["removed"]): if run_live:
changed = True live_started = time.monotonic()
payload = {**diff, "summary": cached_summary(pid, rows, force=True), "speed_status": speed_status} live = torrent_cache.refresh_live(profile)
emitted_payload_size += len(json.dumps(payload, default=str)) rtorrent_call_count += 1
_emit_profile(socketio, "torrent_patch", payload, pid) state.last_live_at = now
elif not diff.get("ok"): state.last_fast_at = now
_emit_profile(socketio, "rtorrent_error", diff, pid) ok = bool(live.get("ok"))
else: error = str(live.get("error") or "")
# Note: Speeds and peak records may change even when no torrent rows need repainting. poller_control.mark_live_poll(state, live_started, ok, error, len(live.get("updated") or []), bool(live.get("requires_full_refresh")))
if speed_status: rows = torrent_cache.snapshot(pid)
payload = {"ok": True, "profile_id": pid, "added": [], "updated": [], "removed": [], "speed_status": speed_status} active = _is_active_rows(rows)
speed_status = _speed_status_from_rows(pid, rows) if live.get("ok") else speed_status
if live.get("ok"):
if live.get("updated") or speed_status:
changed = changed or bool(live.get("updated"))
payload = {
"ok": True,
"profile_id": pid,
"updated": live.get("updated") or [],
"speed_status": speed_status,
"requires_full_refresh": bool(live.get("requires_full_refresh")),
}
emitted_payload_size += len(json.dumps(payload, default=str))
_emit_profile(socketio, "torrent_live_patch", payload, pid)
else:
skipped_emissions += 1
if live.get("requires_full_refresh"):
# Note: Missing or unknown hashes mean the next slow list tick must reconcile rows.
state.last_list_at = 0.0
run_list = True
else:
_emit_profile(socketio, "rtorrent_error", live, pid)
if run_list:
list_started = time.monotonic()
diff = torrent_cache.refresh(profile)
rtorrent_call_count += 1
state.last_list_at = now
ok = bool(diff.get("ok"))
error = str(diff.get("error") or "")
poller_control.mark_list_poll(state, list_started, ok, error, len(diff.get("added") or []), len(diff.get("updated") or []), len(diff.get("removed") or []))
rows = torrent_cache.snapshot(pid)
active = _is_active_rows(rows)
speed_status = _speed_status_from_rows(pid, rows) if diff.get("ok") else speed_status
if diff.get("ok") and (diff["added"] or diff["updated"] or diff["removed"]):
changed = True
payload = {**diff, "summary": cached_summary(pid, rows, force=True), "speed_status": speed_status}
emitted_payload_size += len(json.dumps(payload, default=str)) emitted_payload_size += len(json.dumps(payload, default=str))
_emit_profile(socketio, "torrent_patch", payload, pid) _emit_profile(socketio, "torrent_patch", payload, pid)
elif not diff.get("ok"):
_emit_profile(socketio, "rtorrent_error", diff, pid)
else: else:
skipped_emissions += 1 skipped_emissions += 1
if poller_control.should_system_poll(now, settings, state): if run_system:
state.last_system_at = now state.last_system_at = now
rows = torrent_cache.snapshot(pid)
status = rtorrent.system_status(profile, rows) status = rtorrent.system_status(profile, rows)
rtorrent_call_count += 1 rtorrent_call_count += 1
if bool(profile.get("is_remote")): if bool(profile.get("is_remote")):
@@ -182,9 +234,11 @@ def register_socketio_handlers(socketio):
if poller_control.should_tracker_poll(now, settings, state): if poller_control.should_tracker_poll(now, settings, state):
state.last_tracker_at = now state.last_tracker_at = now
if poller_control.should_slow_poll(now, settings, state) or poller_control.should_queue_poll(now, settings, state): if run_slow or run_queue:
state.last_slow_at = now if run_slow:
state.last_queue_at = now state.last_slow_at = now
if run_queue:
state.last_queue_at = now
if state.slow_task_running: if state.slow_task_running:
skipped_emissions += 1 skipped_emissions += 1
else: else:
@@ -217,7 +271,7 @@ def register_socketio_handlers(socketio):
@socketio.on("connect") @socketio.on("connect")
def handle_connect(): def handle_connect():
ensure_poller_started() ensure_poller_started()
if auth.enabled() and not auth.current_user_id(): if auth.enabled() and not auth.ensure_request_user():
disconnect() disconnect()
return False return False
profile = active_profile() profile = active_profile()
@@ -234,7 +288,7 @@ def register_socketio_handlers(socketio):
@socketio.on("select_profile") @socketio.on("select_profile")
def handle_select_profile(data): def handle_select_profile(data):
if auth.enabled() and not auth.current_user_id(): if auth.enabled() and not auth.ensure_request_user():
disconnect() disconnect()
return return
old_profile = active_profile() old_profile = active_profile()

View File

@@ -9,6 +9,8 @@ from . import rtorrent, auth, disk_guard, operation_logs
from .preferences import get_profile from .preferences import get_profile
from ..config import WORKERS from ..config import WORKERS
from ..db import connect, utcnow, default_user_id from ..db import connect, utcnow, default_user_id
from .torrent_cache import torrent_cache
from .torrent_summary import cached_summary
LIGHT_ACTIONS = {"start", "stop", "pause", "resume", "unpause", "set_label", "set_ratio_group", "reannounce", "set_limits"} LIGHT_ACTIONS = {"start", "stop", "pause", "resume", "unpause", "set_label", "set_ratio_group", "reannounce", "set_limits"}
WATCHDOG_INTERVAL_SECONDS = 30 WATCHDOG_INTERVAL_SECONDS = 30
@@ -216,10 +218,11 @@ def _job_event_meta(payload: dict) -> dict:
return meta return meta
def _execute(profile: dict, action_name: str, payload: dict): def _execute(profile: dict, action_name: str, payload: dict, user_id: int | None = None):
if action_name == "smart_queue_check": if action_name == "smart_queue_check":
from . import smart_queue from . import smart_queue
return smart_queue.check(profile, user_id=auth.current_user_id() or default_user_id(), force=True) # Note: Worker execution uses the job owner instead of Flask session state.
return smart_queue.check(profile, user_id=user_id or default_user_id(), force=True)
if action_name == "add_magnet": if action_name == "add_magnet":
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)
@@ -268,11 +271,43 @@ def _mark_running(job_id: str, attempts: int) -> bool:
return int(cur.rowcount or 0) == 1 return int(cur.rowcount or 0) == 1
def _emit_torrent_refresh(profile: dict, action_name: str) -> None:
if action_name not in {"add_magnet", "add_torrent_raw", "remove", "move", "start", "stop", "pause", "resume", "unpause", "set_label", "set_ratio_group", "recheck"}:
return
try:
diff = torrent_cache.refresh(profile)
profile_id = int(profile["id"])
if diff.get("ok"):
rows = torrent_cache.snapshot(profile_id)
_emit("torrent_patch", {**diff, "summary": cached_summary(profile_id, rows, force=True)})
else:
_emit("rtorrent_error", diff)
except Exception as exc:
# Note: A failed live refresh must not change the already completed job result.
_emit("rtorrent_error", {"profile_id": int(profile.get("id") or 0), "error": str(exc)})
def _schedule_delayed_torrent_refresh(profile: dict, action_name: str) -> None:
if action_name not in {"start", "stop", "pause", "resume", "unpause"} or not _socketio:
return
def delayed_refresh():
# Note: rTorrent may expose state changes one poll later than the XML-RPC action result.
sleep_fn = getattr(_socketio, "sleep", time.sleep)
for delay in (0.75, 1.75):
sleep_fn(delay)
_emit_torrent_refresh(profile, action_name)
_socketio.start_background_task(delayed_refresh)
def _run(job_id: str): 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_lock = None
job = {}
payload = {}
try: try:
job = _job_row(job_id) job = _job_row(job_id)
if not job or job["status"] == "cancelled": if not job or job["status"] == "cancelled":
@@ -303,7 +338,7 @@ def _run(job_id: str):
operation_logs.record_job_event(profile["id"], job["action"], "started", payload, job_id=job_id, user_id=int(job.get("user_id") or 0)) operation_logs.record_job_event(profile["id"], job["action"], "started", payload, job_id=job_id, user_id=int(job.get("user_id") or 0))
_emit("operation_started", {"job_id": job_id, "action": job["action"], "profile_id": profile["id"], "hashes": payload.get("hashes") or [], "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1, **event_meta}) _emit("operation_started", {"job_id": job_id, "action": job["action"], "profile_id": profile["id"], "hashes": payload.get("hashes") or [], "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1, **event_meta})
_emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "running", "attempts": attempts}) _emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "running", "attempts": attempts})
result = _execute(profile, job["action"], payload) result = _execute(profile, job["action"], payload, user_id=int(job.get("user_id") or 0))
fresh = _job_row(job_id) fresh = _job_row(job_id)
# Note: Emergency cancel and watchdog timeout keep late work from overwriting a terminal state. # 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":
@@ -311,6 +346,10 @@ def _run(job_id: str):
_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 "")
_emit_torrent_refresh(profile, action_name)
_schedule_delayed_torrent_refresh(profile, action_name)
_emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "done", "result": result}) _emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "done", "result": result})
except Exception as exc: except Exception as exc:
fresh = _job_row(job_id) or {} fresh = _job_row(job_id) or {}

File diff suppressed because one or more lines are too long

View File

@@ -9,6 +9,7 @@ import { torrentDetailsSource } from './torrentDetails.js';
import { modalsSource } from './modals.js'; import { modalsSource } from './modals.js';
import { rssSource } from './rss.js'; import { rssSource } from './rss.js';
import { smartQueueSource } from './smartQueue.js'; import { smartQueueSource } from './smartQueue.js';
import { authUsersSource } from './authUsers.js';
import { plannerSource } from './planner.js'; import { plannerSource } from './planner.js';
import { pollerSource } from './poller.js'; import { pollerSource } from './poller.js';
import { profilesSource } from './profiles.js'; import { profilesSource } from './profiles.js';
@@ -29,6 +30,7 @@ export const moduleSources = [
modalsSource, modalsSource,
rssSource, rssSource,
smartQueueSource, smartQueueSource,
authUsersSource,
plannerSource, plannerSource,
dashboardSource, dashboardSource,
operationLogsSource, operationLogsSource,

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

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

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

View File

@@ -0,0 +1,66 @@
/* Balanced violet-blue theme with calm defaults. */
:root, [data-bs-theme="light"] {
color-scheme: light;
--bs-primary: #6750a4;
--bs-primary-rgb: 103,80,164;
--bs-link-color: #6750a4;
--bs-link-color-rgb: 103,80,164;
--bs-link-hover-color: #4b2f8f;
--bs-link-hover-color-rgb: 103,80,164;
--bs-body-bg: #f6f7fb;
--bs-body-bg-rgb: 246,247,251;
--bs-body-color: #1f2937;
--bs-secondary-bg: #ffffff;
--bs-secondary-bg-rgb: 255,255,255;
--bs-tertiary-bg: #eef1f7;
--bs-border-color: #d9deea;
--bs-secondary-color: #667085;
--bs-primary-bg-subtle: #ece7ff;
--bs-primary-text-emphasis: #4b2f8f;
--torrent-progress-complete: #2f9e75;
--pytorrent-page-bg: linear-gradient(180deg, #f7f8fc 0%, #eef1f7 100%);
--pytorrent-shell-shadow: 0 16px 40px rgba(39, 45, 73, 0.12);
}
[data-bs-theme="dark"] {
color-scheme: dark;
--bs-primary: #8f7cff;
--bs-primary-rgb: 143,124,255;
--bs-link-color: #8f7cff;
--bs-link-color-rgb: 143,124,255;
--bs-link-hover-color: #c6bdff;
--bs-link-hover-color-rgb: 143,124,255;
--bs-body-bg: #080b12;
--bs-body-bg-rgb: 8,11,18;
--bs-body-color: #dce3f0;
--bs-secondary-bg: #101624;
--bs-secondary-bg-rgb: 16,22,36;
--bs-tertiary-bg: #151d2e;
--bs-border-color: #273247;
--bs-secondary-color: #97a4ba;
--bs-primary-bg-subtle: #191735;
--bs-primary-text-emphasis: #c6bdff;
--torrent-progress-complete: #2f9e75;
--pytorrent-page-bg: radial-gradient(circle at top left, rgba(143, 124, 255, 0.16), transparent 34%), #080b12;
--pytorrent-shell-shadow: 0 18px 55px rgba(0, 0, 0, 0.48);
}
.btn-primary {
--bs-btn-bg: var(--bs-primary);
--bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: var(--bs-primary-text-emphasis);
--bs-btn-hover-border-color: var(--bs-primary-text-emphasis);
}
.btn-outline-primary {
--bs-btn-color: var(--bs-primary);
--bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72);
--bs-btn-hover-bg: var(--bs-primary);
--bs-btn-hover-border-color: var(--bs-primary);
}
.nav-pills {
--bs-nav-pills-link-active-bg: var(--bs-primary);
}
.progress,
.progress-stacked {
--bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82);
}

View File

@@ -0,0 +1,66 @@
/* Warm amber theme, good for light mode and softer dark mode. */
:root, [data-bs-theme="light"] {
color-scheme: light;
--bs-primary: #b45309;
--bs-primary-rgb: 180,83,9;
--bs-link-color: #b45309;
--bs-link-color-rgb: 180,83,9;
--bs-link-hover-color: #92400e;
--bs-link-hover-color-rgb: 180,83,9;
--bs-body-bg: #fff8ed;
--bs-body-bg-rgb: 255,248,237;
--bs-body-color: #2c1d0b;
--bs-secondary-bg: #ffffff;
--bs-secondary-bg-rgb: 255,255,255;
--bs-tertiary-bg: #ffefd6;
--bs-border-color: #f3d7aa;
--bs-secondary-color: #7a6750;
--bs-primary-bg-subtle: #ffecd0;
--bs-primary-text-emphasis: #92400e;
--torrent-progress-complete: #16a34a;
--pytorrent-page-bg: linear-gradient(180deg, #fffaf2 0%, #fff0d8 100%);
--pytorrent-shell-shadow: 0 16px 38px rgba(146, 64, 14, 0.13);
}
[data-bs-theme="dark"] {
color-scheme: dark;
--bs-primary: #f59e0b;
--bs-primary-rgb: 245,158,11;
--bs-link-color: #f59e0b;
--bs-link-color-rgb: 245,158,11;
--bs-link-hover-color: #fde68a;
--bs-link-hover-color-rgb: 245,158,11;
--bs-body-bg: #140d05;
--bs-body-bg-rgb: 20,13,5;
--bs-body-color: #f5e9d6;
--bs-secondary-bg: #211607;
--bs-secondary-bg-rgb: 33,22,7;
--bs-tertiary-bg: #2b1d0b;
--bs-border-color: #4c3515;
--bs-secondary-color: #c3a780;
--bs-primary-bg-subtle: #3a2609;
--bs-primary-text-emphasis: #fde68a;
--torrent-progress-complete: #22c55e;
--pytorrent-page-bg: radial-gradient(circle at top left, rgba(245, 158, 11, 0.16), transparent 34%), #140d05;
--pytorrent-shell-shadow: 0 18px 55px rgba(30, 15, 0, 0.58);
}
.btn-primary {
--bs-btn-bg: var(--bs-primary);
--bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: var(--bs-primary-text-emphasis);
--bs-btn-hover-border-color: var(--bs-primary-text-emphasis);
}
.btn-outline-primary {
--bs-btn-color: var(--bs-primary);
--bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72);
--bs-btn-hover-bg: var(--bs-primary);
--bs-btn-hover-border-color: var(--bs-primary);
}
.nav-pills {
--bs-nav-pills-link-active-bg: var(--bs-primary);
}
.progress,
.progress-stacked {
--bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82);
}

View File

@@ -0,0 +1,247 @@
/* Note: Bootstrap 2 Inverse keeps the old beveled control language with darker navigation chrome. */
:root {
--bs-border-radius: 4px;
--bs-border-radius-sm: 3px;
--bs-border-radius-lg: 5px;
--bs-font-sans-serif: "Helvetica Neue", Arial, sans-serif;
}
[data-bs-theme="light"] {
--bs-body-bg: #eceff1;
--bs-body-color: #2f2f2f;
--bs-primary: #2f96b4;
--bs-primary-rgb: 47, 150, 180;
--bs-secondary-bg: #d9dee2;
--bs-secondary-bg-rgb: 217, 222, 226;
--bs-secondary-color: #4d5963;
--bs-tertiary-bg: #ffffff;
--bs-border-color: #b8c0c7;
--bs-link-color: #2f96b4;
}
[data-bs-theme="dark"] {
--bs-body-bg: #161a1d;
--bs-body-color: #e7ecef;
--bs-primary: #49afcd;
--bs-primary-rgb: 73, 175, 205;
--bs-secondary-bg: #252b30;
--bs-secondary-bg-rgb: 37, 43, 48;
--bs-secondary-color: #c9d3da;
--bs-tertiary-bg: #20262a;
--bs-border-color: #48535c;
--bs-link-color: #5bc0de;
}
/* Note: Bootstrap 2 surfaces were simple grey panels with subtle inset highlights. */
.card,
.dropdown-menu,
.modal-content,
.surface-section,
.smart-setting-row,
.table,
.toast {
background-image: linear-gradient(#ffffff, #f7f7f7);
border: 1px solid var(--bs-border-color);
border-radius: 4px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55), 0 1px 2px rgba(0, 0, 0, 0.08);
}
[data-bs-theme="dark"] .card,
[data-bs-theme="dark"] .dropdown-menu,
[data-bs-theme="dark"] .modal-content,
[data-bs-theme="dark"] .surface-section,
[data-bs-theme="dark"] .smart-setting-row,
[data-bs-theme="dark"] .table,
[data-bs-theme="dark"] .toast {
background-image: linear-gradient(#303840, #252c33);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 1px 2px rgba(0, 0, 0, 0.25);
}
.btn {
border-color: rgba(0, 0, 0, 0.22);
border-radius: 4px;
border-width: 1px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.38), 0 1px 2px rgba(0, 0, 0, 0.08);
font-weight: 600;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.25);
transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease;
}
.btn:hover {
filter: none;
text-decoration: none;
}
.btn:focus-visible {
box-shadow: 0 0 0 .2rem rgba(var(--bs-primary-rgb), .32);
filter: none;
text-decoration: none;
}
.btn-primary {
background-image: linear-gradient(#08c, #04c);
border-color: #0044cc #0044cc #002a80;
color: #ffffff;
}
.btn-primary:hover,
.btn-primary:focus-visible {
background-image: linear-gradient(#0077d9, #003bb3);
border-color: #003bb3 #003bb3 #001f66;
}
.btn-success,
.btn-success:hover,
.btn-success:focus-visible {
background-image: linear-gradient(#62c462, #51a351);
border-color: #51a351 #51a351 #387038;
color: #ffffff;
}
.btn-danger,
.btn-danger:hover,
.btn-danger:focus-visible {
background-image: linear-gradient(#ee5f5b, #bd362f);
border-color: #bd362f #bd362f #802420;
color: #ffffff;
}
.btn-secondary,
.btn-outline-secondary,
.btn-light {
background-image: linear-gradient(#ffffff, #e6e6e6);
border-color: #cccccc #cccccc #b3b3b3;
color: #333333;
}
.btn-outline-primary,
.btn-outline-success,
.btn-outline-danger,
.btn-outline-warning,
.btn-outline-info {
background-color: var(--bs-body-bg);
}
.btn-outline-primary:hover,
.btn-outline-primary:focus-visible,
.nav-pills .nav-link.active,
.nav-pills .show > .nav-link {
background-image: linear-gradient(#08c, #04c);
color: #ffffff;
}
.btn-outline-success:hover,
.btn-outline-success:focus-visible {
background-image: linear-gradient(#62c462, #51a351);
color: #ffffff;
}
.btn-outline-danger:hover,
.btn-outline-danger:focus-visible {
background-image: linear-gradient(#ee5f5b, #bd362f);
color: #ffffff;
}
.btn-outline-warning:hover,
.btn-outline-warning:focus-visible {
background-image: linear-gradient(#fbb450, #f89406);
color: #111111;
}
.btn-outline-info:hover,
.btn-outline-info:focus-visible {
background-image: linear-gradient(#5bc0de, #2f96b4);
color: #ffffff;
}
.btn-secondary:hover,
.btn-secondary:focus-visible,
.btn-outline-secondary:hover,
.btn-outline-secondary:focus-visible {
background-image: linear-gradient(#e6e6e6, #cfcfcf);
border-color: #adadad;
color: #222222;
}
[data-bs-theme="dark"] .btn-secondary,
[data-bs-theme="dark"] .btn-outline-secondary,
[data-bs-theme="dark"] .btn-light {
background-image: linear-gradient(#4b545d, #303840);
border-color: #5c6670;
color: #f0f0f0;
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.45);
}
[data-bs-theme="dark"] .btn-secondary:hover,
[data-bs-theme="dark"] .btn-secondary:focus-visible,
[data-bs-theme="dark"] .btn-outline-secondary:hover,
[data-bs-theme="dark"] .btn-outline-secondary:focus-visible {
background-image: linear-gradient(#68737e, #48515a);
color: #ffffff;
}
.form-control,
.form-select,
.input-group-text {
border: 1px solid #cccccc;
border-radius: 4px;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
}
.form-control:focus,
.form-select:focus {
border-color: rgba(82, 168, 236, 0.8);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6);
}
.modal-header,
.card-header,
.dropdown-header,
.table thead th {
background-image: linear-gradient(#f9f9f9, #eeeeee);
border-bottom: 1px solid var(--bs-border-color);
color: var(--bs-body-color);
font-weight: 700;
}
[data-bs-theme="dark"] .modal-header,
[data-bs-theme="dark"] .card-header,
[data-bs-theme="dark"] .dropdown-header,
[data-bs-theme="dark"] .table thead th {
background-image: linear-gradient(#39434c, #2c343b);
}
.badge,
.label {
border-radius: 3px;
}
.alert {
border-radius: 3px;
border-width: 1px;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.35);
}
.progress {
background-image: linear-gradient(#f5f5f5, #e6e6e6);
border-radius: 3px;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18);
}
.progress-bar {
background-image: linear-gradient(#149bdf, #0480be);
}
.table > :not(caption) > * > * {
border-bottom-color: var(--bs-border-color);
}
/* Note: Inverse variant reproduces the dark Bootstrap 2 navbar strip. */
.navbar,
.sidebar,
.topbar {
background-image: linear-gradient(#444444, #222222);
border-color: #252525;
color: #eeeeee;
}

View File

@@ -0,0 +1,237 @@
/* Note: Bootstrap 2 Classic maps the app UI to the old beveled Bootstrap 2 control language. */
:root {
--bs-border-radius: 4px;
--bs-border-radius-sm: 3px;
--bs-border-radius-lg: 5px;
--bs-font-sans-serif: "Helvetica Neue", Arial, sans-serif;
}
[data-bs-theme="light"] {
--bs-body-bg: #f5f5f5;
--bs-body-color: #333333;
--bs-primary: #006dcc;
--bs-primary-rgb: 0, 109, 204;
--bs-secondary-bg: #eeeeee;
--bs-secondary-bg-rgb: 238, 238, 238;
--bs-secondary-color: #555555;
--bs-tertiary-bg: #ffffff;
--bs-border-color: #c8c8c8;
--bs-link-color: #0088cc;
}
[data-bs-theme="dark"] {
--bs-body-bg: #1f252b;
--bs-body-color: #e6e6e6;
--bs-primary: #4aa3df;
--bs-primary-rgb: 74, 163, 223;
--bs-secondary-bg: #2f363d;
--bs-secondary-bg-rgb: 47, 54, 61;
--bs-secondary-color: #c7d0d8;
--bs-tertiary-bg: #252b31;
--bs-border-color: #4a535c;
--bs-link-color: #6bbdf0;
}
/* Note: Bootstrap 2 surfaces were simple grey panels with subtle inset highlights. */
.card,
.dropdown-menu,
.modal-content,
.surface-section,
.smart-setting-row,
.table,
.toast {
background-image: linear-gradient(#ffffff, #f7f7f7);
border: 1px solid var(--bs-border-color);
border-radius: 4px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55), 0 1px 2px rgba(0, 0, 0, 0.08);
}
[data-bs-theme="dark"] .card,
[data-bs-theme="dark"] .dropdown-menu,
[data-bs-theme="dark"] .modal-content,
[data-bs-theme="dark"] .surface-section,
[data-bs-theme="dark"] .smart-setting-row,
[data-bs-theme="dark"] .table,
[data-bs-theme="dark"] .toast {
background-image: linear-gradient(#303840, #252c33);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 1px 2px rgba(0, 0, 0, 0.25);
}
.btn {
border-color: rgba(0, 0, 0, 0.22);
border-radius: 4px;
border-width: 1px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.38), 0 1px 2px rgba(0, 0, 0, 0.08);
font-weight: 600;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.25);
transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease;
}
.btn:hover {
filter: none;
text-decoration: none;
}
.btn:focus-visible {
box-shadow: 0 0 0 .2rem rgba(var(--bs-primary-rgb), .32);
filter: none;
text-decoration: none;
}
.btn-primary {
background-image: linear-gradient(#08c, #04c);
border-color: #0044cc #0044cc #002a80;
color: #ffffff;
}
.btn-primary:hover,
.btn-primary:focus-visible {
background-image: linear-gradient(#0077d9, #003bb3);
border-color: #003bb3 #003bb3 #001f66;
}
.btn-success,
.btn-success:hover,
.btn-success:focus-visible {
background-image: linear-gradient(#62c462, #51a351);
border-color: #51a351 #51a351 #387038;
color: #ffffff;
}
.btn-danger,
.btn-danger:hover,
.btn-danger:focus-visible {
background-image: linear-gradient(#ee5f5b, #bd362f);
border-color: #bd362f #bd362f #802420;
color: #ffffff;
}
.btn-secondary,
.btn-outline-secondary,
.btn-light {
background-image: linear-gradient(#ffffff, #e6e6e6);
border-color: #cccccc #cccccc #b3b3b3;
color: #333333;
}
.btn-outline-primary,
.btn-outline-success,
.btn-outline-danger,
.btn-outline-warning,
.btn-outline-info {
background-color: var(--bs-body-bg);
}
.btn-outline-primary:hover,
.btn-outline-primary:focus-visible,
.nav-pills .nav-link.active,
.nav-pills .show > .nav-link {
background-image: linear-gradient(#08c, #04c);
color: #ffffff;
}
.btn-outline-success:hover,
.btn-outline-success:focus-visible {
background-image: linear-gradient(#62c462, #51a351);
color: #ffffff;
}
.btn-outline-danger:hover,
.btn-outline-danger:focus-visible {
background-image: linear-gradient(#ee5f5b, #bd362f);
color: #ffffff;
}
.btn-outline-warning:hover,
.btn-outline-warning:focus-visible {
background-image: linear-gradient(#fbb450, #f89406);
color: #111111;
}
.btn-outline-info:hover,
.btn-outline-info:focus-visible {
background-image: linear-gradient(#5bc0de, #2f96b4);
color: #ffffff;
}
.btn-secondary:hover,
.btn-secondary:focus-visible,
.btn-outline-secondary:hover,
.btn-outline-secondary:focus-visible {
background-image: linear-gradient(#e6e6e6, #cfcfcf);
border-color: #adadad;
color: #222222;
}
[data-bs-theme="dark"] .btn-secondary,
[data-bs-theme="dark"] .btn-outline-secondary,
[data-bs-theme="dark"] .btn-light {
background-image: linear-gradient(#4b545d, #303840);
border-color: #5c6670;
color: #f0f0f0;
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.45);
}
[data-bs-theme="dark"] .btn-secondary:hover,
[data-bs-theme="dark"] .btn-secondary:focus-visible,
[data-bs-theme="dark"] .btn-outline-secondary:hover,
[data-bs-theme="dark"] .btn-outline-secondary:focus-visible {
background-image: linear-gradient(#68737e, #48515a);
color: #ffffff;
}
.form-control,
.form-select,
.input-group-text {
border: 1px solid #cccccc;
border-radius: 4px;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
}
.form-control:focus,
.form-select:focus {
border-color: rgba(82, 168, 236, 0.8);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6);
}
.modal-header,
.card-header,
.dropdown-header,
.table thead th {
background-image: linear-gradient(#f9f9f9, #eeeeee);
border-bottom: 1px solid var(--bs-border-color);
color: var(--bs-body-color);
font-weight: 700;
}
[data-bs-theme="dark"] .modal-header,
[data-bs-theme="dark"] .card-header,
[data-bs-theme="dark"] .dropdown-header,
[data-bs-theme="dark"] .table thead th {
background-image: linear-gradient(#39434c, #2c343b);
}
.badge,
.label {
border-radius: 3px;
}
.alert {
border-radius: 3px;
border-width: 1px;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.35);
}
.progress {
background-image: linear-gradient(#f5f5f5, #e6e6e6);
border-radius: 3px;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18);
}
.progress-bar {
background-image: linear-gradient(#149bdf, #0480be);
}
.table > :not(caption) > * > * {
border-bottom-color: var(--bs-border-color);
}

View File

@@ -0,0 +1,268 @@
/* Note: Bootstrap 3 Inverse keeps the dark navbar era with Bootstrap 3 panel and button shapes. */
:root {
--bs-border-radius: 4px;
--bs-border-radius-sm: 3px;
--bs-border-radius-lg: 4px;
--bs-font-sans-serif: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
[data-bs-theme="light"] {
--bs-body-bg: #eceff2;
--bs-body-color: #2d3338;
--bs-primary: #428bca;
--bs-primary-rgb: 66, 139, 202;
--bs-success: #5cb85c;
--bs-danger: #d9534f;
--bs-warning: #f0ad4e;
--bs-info: #5bc0de;
--bs-secondary-bg: #dfe4e8;
--bs-secondary-bg-rgb: 223, 228, 232;
--bs-secondary-color: #4c5963;
--bs-tertiary-bg: #ffffff;
--bs-border-color: #c5cdd4;
--bs-link-color: #2a6496;
}
[data-bs-theme="dark"] {
--bs-body-bg: #151a1f;
--bs-body-color: #edf2f6;
--bs-primary: #6fb4eb;
--bs-primary-rgb: 111, 180, 235;
--bs-success: #7dd37d;
--bs-danger: #ec7773;
--bs-warning: #f6c572;
--bs-info: #83d8ee;
--bs-secondary-bg: #232b33;
--bs-secondary-bg-rgb: 35, 43, 51;
--bs-secondary-color: #c3ccd4;
--bs-tertiary-bg: #1d242b;
--bs-border-color: #3f4b56;
--bs-link-color: #94cdf5;
}
/* Note: Bootstrap 3 panels and wells are represented through shared app containers. */
.card,
.dropdown-menu,
.modal-content,
.surface-section,
.smart-setting-row,
.toast {
background-color: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
border-radius: 4px;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
}
.navbar,
.topbar {
background-image: linear-gradient(#ffffff, #f2f2f2);
border-bottom: 1px solid var(--bs-border-color);
}
[data-bs-theme="dark"] .navbar,
[data-bs-theme="dark"] .topbar {
background-image: linear-gradient(#303941, #20272e);
}
.btn {
border-width: 1px;
border-radius: 4px;
font-weight: 600;
transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease;
}
.btn:hover {
filter: none;
text-decoration: none;
}
.btn:focus-visible {
box-shadow: 0 0 0 .2rem rgba(var(--bs-primary-rgb), .32);
filter: none;
text-decoration: none;
}
.btn-primary {
background-image: linear-gradient(#428bca, #2d6ca2);
border-color: #2b669a;
color: #ffffff;
}
.btn-primary:hover,
.btn-primary:focus-visible,
.nav-pills .nav-link.active,
.nav-pills .show > .nav-link {
background-color: #2d6ca2;
color: #ffffff;
}
.btn-success,
.btn-success:hover,
.btn-success:focus-visible {
background-color: #5cb85c;
border-color: #4cae4c;
color: #ffffff;
}
.btn-danger,
.btn-danger:hover,
.btn-danger:focus-visible {
background-color: #d9534f;
border-color: #d43f3a;
color: #ffffff;
}
.btn-warning,
.btn-warning:hover,
.btn-warning:focus-visible {
background-color: #f0ad4e;
border-color: #eea236;
color: #111111;
}
.btn-info,
.btn-info:hover,
.btn-info:focus-visible {
background-color: #5bc0de;
border-color: #46b8da;
color: #ffffff;
}
.btn-secondary,
.btn-outline-secondary,
.btn-light {
background-color: #ffffff;
border-color: #cccccc;
color: #333333;
}
.btn-outline-primary,
.btn-outline-success,
.btn-outline-danger,
.btn-outline-warning,
.btn-outline-info {
background-color: var(--bs-body-bg);
}
.btn-outline-primary:hover,
.btn-outline-primary:focus-visible {
background-color: var(--bs-primary);
border-color: var(--bs-primary);
color: #ffffff;
}
.btn-outline-success:hover,
.btn-outline-success:focus-visible {
background-color: var(--bs-success);
border-color: var(--bs-success);
color: #ffffff;
}
.btn-outline-danger:hover,
.btn-outline-danger:focus-visible {
background-color: var(--bs-danger);
border-color: var(--bs-danger);
color: #ffffff;
}
.btn-outline-warning:hover,
.btn-outline-warning:focus-visible {
background-color: var(--bs-warning);
border-color: var(--bs-warning);
color: #111111;
}
.btn-outline-info:hover,
.btn-outline-info:focus-visible {
background-color: var(--bs-info);
border-color: var(--bs-info);
color: #111111;
}
.btn-secondary:hover,
.btn-secondary:focus-visible,
.btn-outline-secondary:hover,
.btn-outline-secondary:focus-visible {
background-color: #e6e6e6;
border-color: #adadad;
color: #222222;
}
[data-bs-theme="dark"] .btn-secondary,
[data-bs-theme="dark"] .btn-outline-secondary,
[data-bs-theme="dark"] .btn-light {
background-color: #323b44;
border-color: #53606b;
color: #edf2f6;
}
[data-bs-theme="dark"] .btn-secondary:hover,
[data-bs-theme="dark"] .btn-secondary:focus-visible,
[data-bs-theme="dark"] .btn-outline-secondary:hover,
[data-bs-theme="dark"] .btn-outline-secondary:focus-visible {
background-color: #46515b;
color: #ffffff;
}
.form-control,
.form-select,
.input-group-text {
border: 1px solid #cccccc;
border-radius: 4px;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
}
.form-control:focus,
.form-select:focus {
border-color: #66afe9;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(102, 175, 233, 0.6);
}
.card-header,
.modal-header,
.table thead th {
background-color: #f5f5f5;
border-bottom: 1px solid var(--bs-border-color);
color: var(--bs-body-color);
font-weight: 700;
}
[data-bs-theme="dark"] .card-header,
[data-bs-theme="dark"] .modal-header,
[data-bs-theme="dark"] .table thead th {
background-color: #303941;
}
.badge,
.label {
border-radius: 4px;
}
.alert {
border-radius: 4px;
border-width: 1px;
}
.progress {
background-color: #f5f5f5;
border-radius: 4px;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
}
.progress-bar {
background-image: linear-gradient(#5bc0de, #339bb9);
}
.table > :not(caption) > * > * {
border-bottom-color: var(--bs-border-color);
}
/* Note: Inverse variant reproduces Bootstrap 3's dark default navbar treatment. */
.navbar,
.sidebar,
.topbar {
background-image: linear-gradient(#3c3c3c, #222222);
border-color: #080808;
color: #eeeeee;
}

View File

@@ -0,0 +1,258 @@
/* Note: Bootstrap 3 Glyph uses flat panels, square controls and the Bootstrap 3 palette. */
:root {
--bs-border-radius: 4px;
--bs-border-radius-sm: 3px;
--bs-border-radius-lg: 4px;
--bs-font-sans-serif: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
[data-bs-theme="light"] {
--bs-body-bg: #f8f8f8;
--bs-body-color: #333333;
--bs-primary: #337ab7;
--bs-primary-rgb: 51, 122, 183;
--bs-success: #5cb85c;
--bs-danger: #d9534f;
--bs-warning: #f0ad4e;
--bs-info: #5bc0de;
--bs-secondary-bg: #eeeeee;
--bs-secondary-bg-rgb: 238, 238, 238;
--bs-secondary-color: #555555;
--bs-tertiary-bg: #ffffff;
--bs-border-color: #dddddd;
--bs-link-color: #337ab7;
}
[data-bs-theme="dark"] {
--bs-body-bg: #1f252b;
--bs-body-color: #e7edf2;
--bs-primary: #5dade2;
--bs-primary-rgb: 93, 173, 226;
--bs-success: #70c770;
--bs-danger: #e26b67;
--bs-warning: #f4bd65;
--bs-info: #73cde6;
--bs-secondary-bg: #2b333b;
--bs-secondary-bg-rgb: 43, 51, 59;
--bs-secondary-color: #bac4cd;
--bs-tertiary-bg: #252c33;
--bs-border-color: #46515b;
--bs-link-color: #8cc8f0;
}
/* Note: Bootstrap 3 panels and wells are represented through shared app containers. */
.card,
.dropdown-menu,
.modal-content,
.surface-section,
.smart-setting-row,
.toast {
background-color: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
border-radius: 4px;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
}
.navbar,
.topbar {
background-image: linear-gradient(#ffffff, #f2f2f2);
border-bottom: 1px solid var(--bs-border-color);
}
[data-bs-theme="dark"] .navbar,
[data-bs-theme="dark"] .topbar {
background-image: linear-gradient(#303941, #20272e);
}
.btn {
border-width: 1px;
border-radius: 4px;
font-weight: 600;
transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease;
}
.btn:hover {
filter: none;
text-decoration: none;
}
.btn:focus-visible {
box-shadow: 0 0 0 .2rem rgba(var(--bs-primary-rgb), .32);
filter: none;
text-decoration: none;
}
.btn-primary {
background-image: linear-gradient(#428bca, #2d6ca2);
border-color: #2b669a;
color: #ffffff;
}
.btn-primary:hover,
.btn-primary:focus-visible,
.nav-pills .nav-link.active,
.nav-pills .show > .nav-link {
background-color: #2d6ca2;
color: #ffffff;
}
.btn-success,
.btn-success:hover,
.btn-success:focus-visible {
background-color: #5cb85c;
border-color: #4cae4c;
color: #ffffff;
}
.btn-danger,
.btn-danger:hover,
.btn-danger:focus-visible {
background-color: #d9534f;
border-color: #d43f3a;
color: #ffffff;
}
.btn-warning,
.btn-warning:hover,
.btn-warning:focus-visible {
background-color: #f0ad4e;
border-color: #eea236;
color: #111111;
}
.btn-info,
.btn-info:hover,
.btn-info:focus-visible {
background-color: #5bc0de;
border-color: #46b8da;
color: #ffffff;
}
.btn-secondary,
.btn-outline-secondary,
.btn-light {
background-color: #ffffff;
border-color: #cccccc;
color: #333333;
}
.btn-outline-primary,
.btn-outline-success,
.btn-outline-danger,
.btn-outline-warning,
.btn-outline-info {
background-color: var(--bs-body-bg);
}
.btn-outline-primary:hover,
.btn-outline-primary:focus-visible {
background-color: var(--bs-primary);
border-color: var(--bs-primary);
color: #ffffff;
}
.btn-outline-success:hover,
.btn-outline-success:focus-visible {
background-color: var(--bs-success);
border-color: var(--bs-success);
color: #ffffff;
}
.btn-outline-danger:hover,
.btn-outline-danger:focus-visible {
background-color: var(--bs-danger);
border-color: var(--bs-danger);
color: #ffffff;
}
.btn-outline-warning:hover,
.btn-outline-warning:focus-visible {
background-color: var(--bs-warning);
border-color: var(--bs-warning);
color: #111111;
}
.btn-outline-info:hover,
.btn-outline-info:focus-visible {
background-color: var(--bs-info);
border-color: var(--bs-info);
color: #111111;
}
.btn-secondary:hover,
.btn-secondary:focus-visible,
.btn-outline-secondary:hover,
.btn-outline-secondary:focus-visible {
background-color: #e6e6e6;
border-color: #adadad;
color: #222222;
}
[data-bs-theme="dark"] .btn-secondary,
[data-bs-theme="dark"] .btn-outline-secondary,
[data-bs-theme="dark"] .btn-light {
background-color: #323b44;
border-color: #53606b;
color: #edf2f6;
}
[data-bs-theme="dark"] .btn-secondary:hover,
[data-bs-theme="dark"] .btn-secondary:focus-visible,
[data-bs-theme="dark"] .btn-outline-secondary:hover,
[data-bs-theme="dark"] .btn-outline-secondary:focus-visible {
background-color: #46515b;
color: #ffffff;
}
.form-control,
.form-select,
.input-group-text {
border: 1px solid #cccccc;
border-radius: 4px;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
}
.form-control:focus,
.form-select:focus {
border-color: #66afe9;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(102, 175, 233, 0.6);
}
.card-header,
.modal-header,
.table thead th {
background-color: #f5f5f5;
border-bottom: 1px solid var(--bs-border-color);
color: var(--bs-body-color);
font-weight: 700;
}
[data-bs-theme="dark"] .card-header,
[data-bs-theme="dark"] .modal-header,
[data-bs-theme="dark"] .table thead th {
background-color: #303941;
}
.badge,
.label {
border-radius: 4px;
}
.alert {
border-radius: 4px;
border-width: 1px;
}
.progress {
background-color: #f5f5f5;
border-radius: 4px;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
}
.progress-bar {
background-image: linear-gradient(#5bc0de, #339bb9);
}
.table > :not(caption) > * > * {
border-bottom-color: var(--bs-border-color);
}

View File

@@ -0,0 +1,142 @@
/* Note: Bootstrap 4 Cards uses the v4 blue, subtler radii and card-first surfaces. */
:root {
--bs-border-radius: .25rem;
--bs-border-radius-sm: .2rem;
--bs-border-radius-lg: .3rem;
--bs-font-sans-serif: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
[data-bs-theme="light"] {
--bs-body-bg: #f8f9fa;
--bs-body-color: #212529;
--bs-primary: #007bff;
--bs-primary-rgb: 0, 123, 255;
--bs-success: #28a745;
--bs-danger: #dc3545;
--bs-warning: #ffc107;
--bs-info: #17a2b8;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-secondary-color: #6c757d;
--bs-tertiary-bg: #ffffff;
--bs-border-color: #dee2e6;
--bs-link-color: #007bff;
}
[data-bs-theme="dark"] {
--bs-body-bg: #181c20;
--bs-body-color: #e9ecef;
--bs-primary: #4dabf7;
--bs-primary-rgb: 77, 171, 247;
--bs-success: #51cf66;
--bs-danger: #ff6b6b;
--bs-warning: #ffd43b;
--bs-info: #3bc9db;
--bs-secondary-bg: #2a3036;
--bs-secondary-bg-rgb: 42, 48, 54;
--bs-secondary-color: #c1c7cd;
--bs-tertiary-bg: #22272c;
--bs-border-color: #444c55;
--bs-link-color: #74c0fc;
}
.card,
.surface-section,
.modal-content,
.dropdown-menu {
border-radius: .25rem;
box-shadow: 0 .125rem .25rem rgba(0, 0, 0, .075);
}
.btn-primary:hover,
.btn-primary:focus-visible {
background-color: #0069d9;
border-color: #0062cc;
}
[data-bs-theme="dark"] .btn-primary:hover,
[data-bs-theme="dark"] .btn-primary:focus-visible {
background-color: #228be6;
border-color: #1c7ed6;
}
.nav-pills .nav-link.active,
.nav-pills .show > .nav-link {
background-color: var(--bs-primary);
color: #ffffff;
}
.btn {
border-width: 1px;
font-weight: 600;
transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease, filter .15s ease;
}
.btn:hover,
.btn:focus-visible {
filter: none;
text-decoration: none;
}
.btn-primary,
.btn-success,
.btn-danger,
.btn-warning,
.btn-info {
color: #ffffff;
}
.btn-outline-primary,
.btn-outline-success,
.btn-outline-danger,
.btn-outline-warning,
.btn-outline-info,
.btn-outline-secondary {
background-color: var(--bs-body-bg);
}
.btn-outline-primary:hover,
.btn-outline-primary:focus-visible {
background-color: var(--bs-primary);
border-color: var(--bs-primary);
color: #ffffff;
}
.btn-outline-secondary:hover,
.btn-outline-secondary:focus-visible {
background-color: var(--bs-secondary-color);
border-color: var(--bs-secondary-color);
color: var(--bs-body-bg);
}
.btn-outline-success:hover,
.btn-outline-success:focus-visible {
background-color: var(--bs-success);
border-color: var(--bs-success);
color: #ffffff;
}
.btn-outline-danger:hover,
.btn-outline-danger:focus-visible {
background-color: var(--bs-danger);
border-color: var(--bs-danger);
color: #ffffff;
}
.btn-outline-warning:hover,
.btn-outline-warning:focus-visible {
background-color: var(--bs-warning);
border-color: var(--bs-warning);
color: #111111;
}
.btn-outline-info:hover,
.btn-outline-info:focus-visible {
background-color: var(--bs-info);
border-color: var(--bs-info);
color: #111111;
}
.btn:focus-visible {
box-shadow: 0 0 0 .2rem rgba(var(--bs-primary-rgb), .32);
}

View File

@@ -0,0 +1,146 @@
/* Note: Bootstrap 5 Soft keeps the modern variable palette with stronger action hover contrast. */
:root {
--bs-border-radius: .5rem;
--bs-border-radius-sm: .375rem;
--bs-border-radius-lg: .75rem;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
[data-bs-theme="light"] {
--bs-body-bg: #f6f8fb;
--bs-body-color: #1f2937;
--bs-primary: #0d6efd;
--bs-primary-rgb: 13, 110, 253;
--bs-success: #198754;
--bs-danger: #dc3545;
--bs-warning: #ffc107;
--bs-info: #0dcaf0;
--bs-secondary-bg: #edf2f7;
--bs-secondary-bg-rgb: 237, 242, 247;
--bs-secondary-color: #64748b;
--bs-tertiary-bg: #ffffff;
--bs-border-color: #d9e2ec;
--bs-link-color: #0d6efd;
}
[data-bs-theme="dark"] {
--bs-body-bg: #101827;
--bs-body-color: #e5e7eb;
--bs-primary: #60a5fa;
--bs-primary-rgb: 96, 165, 250;
--bs-success: #34d399;
--bs-danger: #f87171;
--bs-warning: #fbbf24;
--bs-info: #22d3ee;
--bs-secondary-bg: #1e293b;
--bs-secondary-bg-rgb: 30, 41, 59;
--bs-secondary-color: #cbd5e1;
--bs-tertiary-bg: #172033;
--bs-border-color: #334155;
--bs-link-color: #93c5fd;
}
.card,
.surface-section,
.modal-content,
.dropdown-menu {
border-radius: .75rem;
box-shadow: 0 .35rem 1rem rgba(15, 23, 42, .08);
}
.btn {
border-radius: .5rem;
}
.btn-primary:hover,
.btn-primary:focus-visible {
background-color: #0b5ed7;
border-color: #0a58ca;
}
[data-bs-theme="dark"] .btn-primary:hover,
[data-bs-theme="dark"] .btn-primary:focus-visible {
background-color: #3b82f6;
border-color: #2563eb;
}
.nav-pills .nav-link.active,
.nav-pills .show > .nav-link {
background-color: var(--bs-primary);
color: #ffffff;
}
.btn {
border-width: 1px;
font-weight: 600;
transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease, filter .15s ease;
}
.btn:hover,
.btn:focus-visible {
filter: none;
text-decoration: none;
}
.btn-primary,
.btn-success,
.btn-danger,
.btn-warning,
.btn-info {
color: #ffffff;
}
.btn-outline-primary,
.btn-outline-success,
.btn-outline-danger,
.btn-outline-warning,
.btn-outline-info,
.btn-outline-secondary {
background-color: var(--bs-body-bg);
}
.btn-outline-primary:hover,
.btn-outline-primary:focus-visible {
background-color: var(--bs-primary);
border-color: var(--bs-primary);
color: #ffffff;
}
.btn-outline-secondary:hover,
.btn-outline-secondary:focus-visible {
background-color: var(--bs-secondary-color);
border-color: var(--bs-secondary-color);
color: var(--bs-body-bg);
}
.btn-outline-success:hover,
.btn-outline-success:focus-visible {
background-color: var(--bs-success);
border-color: var(--bs-success);
color: #ffffff;
}
.btn-outline-danger:hover,
.btn-outline-danger:focus-visible {
background-color: var(--bs-danger);
border-color: var(--bs-danger);
color: #ffffff;
}
.btn-outline-warning:hover,
.btn-outline-warning:focus-visible {
background-color: var(--bs-warning);
border-color: var(--bs-warning);
color: #111111;
}
.btn-outline-info:hover,
.btn-outline-info:focus-visible {
background-color: var(--bs-info);
border-color: var(--bs-info);
color: #111111;
}
.btn:focus-visible {
box-shadow: 0 0 0 .2rem rgba(var(--bs-primary-rgb), .32);
}

View File

@@ -0,0 +1,66 @@
/* High-contrast red accent theme without the previous purple feel. */
:root, [data-bs-theme="light"] {
color-scheme: light;
--bs-primary: #be123c;
--bs-primary-rgb: 190,18,60;
--bs-link-color: #be123c;
--bs-link-color-rgb: 190,18,60;
--bs-link-hover-color: #9f1239;
--bs-link-hover-color-rgb: 190,18,60;
--bs-body-bg: #fff5f7;
--bs-body-bg-rgb: 255,245,247;
--bs-body-color: #2b1118;
--bs-secondary-bg: #ffffff;
--bs-secondary-bg-rgb: 255,255,255;
--bs-tertiary-bg: #ffe4ea;
--bs-border-color: #f5c6d1;
--bs-secondary-color: #76545d;
--bs-primary-bg-subtle: #ffe1e9;
--bs-primary-text-emphasis: #9f1239;
--torrent-progress-complete: #16a34a;
--pytorrent-page-bg: linear-gradient(180deg, #fff8fa 0%, #ffe8ee 100%);
--pytorrent-shell-shadow: 0 16px 38px rgba(159, 18, 57, 0.13);
}
[data-bs-theme="dark"] {
color-scheme: dark;
--bs-primary: #fb7185;
--bs-primary-rgb: 251,113,133;
--bs-link-color: #fb7185;
--bs-link-color-rgb: 251,113,133;
--bs-link-hover-color: #fecdd3;
--bs-link-hover-color-rgb: 251,113,133;
--bs-body-bg: #13070a;
--bs-body-bg-rgb: 19,7,10;
--bs-body-color: #f7dce2;
--bs-secondary-bg: #211014;
--bs-secondary-bg-rgb: 33,16,20;
--bs-tertiary-bg: #2b151b;
--bs-border-color: #4b2430;
--bs-secondary-color: #c096a1;
--bs-primary-bg-subtle: #3b111b;
--bs-primary-text-emphasis: #fecdd3;
--torrent-progress-complete: #22c55e;
--pytorrent-page-bg: radial-gradient(circle at top left, rgba(251, 113, 133, 0.15), transparent 35%), #13070a;
--pytorrent-shell-shadow: 0 18px 55px rgba(28, 0, 8, 0.58);
}
.btn-primary {
--bs-btn-bg: var(--bs-primary);
--bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: var(--bs-primary-text-emphasis);
--bs-btn-hover-border-color: var(--bs-primary-text-emphasis);
}
.btn-outline-primary {
--bs-btn-color: var(--bs-primary);
--bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72);
--bs-btn-hover-bg: var(--bs-primary);
--bs-btn-hover-border-color: var(--bs-primary);
}
.nav-pills {
--bs-nav-pills-link-active-bg: var(--bs-primary);
}
.progress,
.progress-stacked {
--bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82);
}

View File

@@ -0,0 +1,66 @@
/* Green productivity theme with warm contrast. */
:root, [data-bs-theme="light"] {
color-scheme: light;
--bs-primary: #2f7d32;
--bs-primary-rgb: 47,125,50;
--bs-link-color: #2f7d32;
--bs-link-color-rgb: 47,125,50;
--bs-link-hover-color: #256329;
--bs-link-hover-color-rgb: 47,125,50;
--bs-body-bg: #f4faf2;
--bs-body-bg-rgb: 244,250,242;
--bs-body-color: #172417;
--bs-secondary-bg: #ffffff;
--bs-secondary-bg-rgb: 255,255,255;
--bs-tertiary-bg: #e7f4e4;
--bs-border-color: #cce4c7;
--bs-secondary-color: #61735e;
--bs-primary-bg-subtle: #dcf2d7;
--bs-primary-text-emphasis: #256329;
--torrent-progress-complete: #22c55e;
--pytorrent-page-bg: linear-gradient(180deg, #fbfff9 0%, #e9f6e5 100%);
--pytorrent-shell-shadow: 0 16px 38px rgba(35, 84, 38, 0.13);
}
[data-bs-theme="dark"] {
color-scheme: dark;
--bs-primary: #4ade80;
--bs-primary-rgb: 74,222,128;
--bs-link-color: #4ade80;
--bs-link-color-rgb: 74,222,128;
--bs-link-hover-color: #bbf7d0;
--bs-link-hover-color-rgb: 74,222,128;
--bs-body-bg: #071109;
--bs-body-bg-rgb: 7,17,9;
--bs-body-color: #d9f1dc;
--bs-secondary-bg: #0f1f12;
--bs-secondary-bg-rgb: 15,31,18;
--bs-tertiary-bg: #152b18;
--bs-border-color: #24472a;
--bs-secondary-color: #95b79a;
--bs-primary-bg-subtle: #13381a;
--bs-primary-text-emphasis: #bbf7d0;
--torrent-progress-complete: #4ade80;
--pytorrent-page-bg: radial-gradient(circle at top left, rgba(74, 222, 128, 0.14), transparent 36%), #071109;
--pytorrent-shell-shadow: 0 18px 55px rgba(0, 20, 4, 0.58);
}
.btn-primary {
--bs-btn-bg: var(--bs-primary);
--bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: var(--bs-primary-text-emphasis);
--bs-btn-hover-border-color: var(--bs-primary-text-emphasis);
}
.btn-outline-primary {
--bs-btn-color: var(--bs-primary);
--bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72);
--bs-btn-hover-bg: var(--bs-primary);
--bs-btn-hover-border-color: var(--bs-primary);
}
.nav-pills {
--bs-nav-pills-link-active-bg: var(--bs-primary);
}
.progress,
.progress-stacked {
--bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82);
}

View File

@@ -0,0 +1,66 @@
/* Neutral grey theme for users who want low saturation. */
:root, [data-bs-theme="light"] {
color-scheme: light;
--bs-primary: #334155;
--bs-primary-rgb: 51,65,85;
--bs-link-color: #334155;
--bs-link-color-rgb: 51,65,85;
--bs-link-hover-color: #1f2937;
--bs-link-hover-color-rgb: 51,65,85;
--bs-body-bg: #f5f6f8;
--bs-body-bg-rgb: 245,246,248;
--bs-body-color: #18202f;
--bs-secondary-bg: #ffffff;
--bs-secondary-bg-rgb: 255,255,255;
--bs-tertiary-bg: #eceff3;
--bs-border-color: #d7dce3;
--bs-secondary-color: #667085;
--bs-primary-bg-subtle: #e4e8ef;
--bs-primary-text-emphasis: #1f2937;
--torrent-progress-complete: #16a34a;
--pytorrent-page-bg: linear-gradient(180deg, #fafafa 0%, #edf0f4 100%);
--pytorrent-shell-shadow: 0 16px 38px rgba(31, 41, 55, 0.13);
}
[data-bs-theme="dark"] {
color-scheme: dark;
--bs-primary: #94a3b8;
--bs-primary-rgb: 148,163,184;
--bs-link-color: #94a3b8;
--bs-link-color-rgb: 148,163,184;
--bs-link-hover-color: #e2e8f0;
--bs-link-hover-color-rgb: 148,163,184;
--bs-body-bg: #07090d;
--bs-body-bg-rgb: 7,9,13;
--bs-body-color: #d7dde7;
--bs-secondary-bg: #11151c;
--bs-secondary-bg-rgb: 17,21,28;
--bs-tertiary-bg: #171c25;
--bs-border-color: #2b3442;
--bs-secondary-color: #99a3b3;
--bs-primary-bg-subtle: #1d2531;
--bs-primary-text-emphasis: #e2e8f0;
--torrent-progress-complete: #22c55e;
--pytorrent-page-bg: radial-gradient(circle at top left, rgba(148, 163, 184, 0.11), transparent 34%), #07090d;
--pytorrent-shell-shadow: 0 18px 55px rgba(0, 0, 0, 0.52);
}
.btn-primary {
--bs-btn-bg: var(--bs-primary);
--bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: var(--bs-primary-text-emphasis);
--bs-btn-hover-border-color: var(--bs-primary-text-emphasis);
}
.btn-outline-primary {
--bs-btn-color: var(--bs-primary);
--bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72);
--bs-btn-hover-bg: var(--bs-primary);
--bs-btn-hover-border-color: var(--bs-primary);
}
.nav-pills {
--bs-nav-pills-link-active-bg: var(--bs-primary);
}
.progress,
.progress-stacked {
--bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82);
}

View File

@@ -0,0 +1,66 @@
/* Cool blue-grey theme inspired by Nordic palettes. */
:root, [data-bs-theme="light"] {
color-scheme: light;
--bs-primary: #4c6a92;
--bs-primary-rgb: 76,106,146;
--bs-link-color: #4c6a92;
--bs-link-color-rgb: 76,106,146;
--bs-link-hover-color: #365174;
--bs-link-hover-color-rgb: 76,106,146;
--bs-body-bg: #f4f7fb;
--bs-body-bg-rgb: 244,247,251;
--bs-body-color: #182334;
--bs-secondary-bg: #ffffff;
--bs-secondary-bg-rgb: 255,255,255;
--bs-tertiary-bg: #e7edf5;
--bs-border-color: #d0d9e8;
--bs-secondary-color: #607089;
--bs-primary-bg-subtle: #dfe8f4;
--bs-primary-text-emphasis: #365174;
--torrent-progress-complete: #3aa675;
--pytorrent-page-bg: linear-gradient(180deg, #fbfdff 0%, #e9eff7 100%);
--pytorrent-shell-shadow: 0 16px 38px rgba(47, 64, 87, 0.13);
}
[data-bs-theme="dark"] {
color-scheme: dark;
--bs-primary: #88c0d0;
--bs-primary-rgb: 136,192,208;
--bs-link-color: #88c0d0;
--bs-link-color-rgb: 136,192,208;
--bs-link-hover-color: #b6e3ee;
--bs-link-hover-color-rgb: 136,192,208;
--bs-body-bg: #0b111a;
--bs-body-bg-rgb: 11,17,26;
--bs-body-color: #d8dee9;
--bs-secondary-bg: #111927;
--bs-secondary-bg-rgb: 17,25,39;
--bs-tertiary-bg: #172233;
--bs-border-color: #2e3a4d;
--bs-secondary-color: #9aa8bc;
--bs-primary-bg-subtle: #132b3a;
--bs-primary-text-emphasis: #b6e3ee;
--torrent-progress-complete: #a3be8c;
--pytorrent-page-bg: radial-gradient(circle at top left, rgba(136, 192, 208, 0.14), transparent 35%), #0b111a;
--pytorrent-shell-shadow: 0 18px 55px rgba(3, 8, 15, 0.55);
}
.btn-primary {
--bs-btn-bg: var(--bs-primary);
--bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: var(--bs-primary-text-emphasis);
--bs-btn-hover-border-color: var(--bs-primary-text-emphasis);
}
.btn-outline-primary {
--bs-btn-color: var(--bs-primary);
--bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72);
--bs-btn-hover-bg: var(--bs-primary);
--bs-btn-hover-border-color: var(--bs-primary);
}
.nav-pills {
--bs-nav-pills-link-active-bg: var(--bs-primary);
}
.progress,
.progress-stacked {
--bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82);
}

View File

@@ -0,0 +1,66 @@
/* Dark teal/navy with a bright, clean light mode. */
:root, [data-bs-theme="light"] {
color-scheme: light;
--bs-primary: #0f766e;
--bs-primary-rgb: 15,118,110;
--bs-link-color: #0f766e;
--bs-link-color-rgb: 15,118,110;
--bs-link-hover-color: #095c55;
--bs-link-hover-color-rgb: 15,118,110;
--bs-body-bg: #f2fbfa;
--bs-body-bg-rgb: 242,251,250;
--bs-body-color: #102a2a;
--bs-secondary-bg: #ffffff;
--bs-secondary-bg-rgb: 255,255,255;
--bs-tertiary-bg: #dff5f2;
--bs-border-color: #bfe4df;
--bs-secondary-color: #54706f;
--bs-primary-bg-subtle: #d6f4ef;
--bs-primary-text-emphasis: #095c55;
--torrent-progress-complete: #14b8a6;
--pytorrent-page-bg: linear-gradient(180deg, #f7fffe 0%, #e6f7f5 100%);
--pytorrent-shell-shadow: 0 16px 38px rgba(10, 78, 74, 0.13);
}
[data-bs-theme="dark"] {
color-scheme: dark;
--bs-primary: #2dd4bf;
--bs-primary-rgb: 45,212,191;
--bs-link-color: #2dd4bf;
--bs-link-color-rgb: 45,212,191;
--bs-link-hover-color: #99f6e4;
--bs-link-hover-color-rgb: 45,212,191;
--bs-body-bg: #031316;
--bs-body-bg-rgb: 3,19,22;
--bs-body-color: #d5f4f1;
--bs-secondary-bg: #082326;
--bs-secondary-bg-rgb: 8,35,38;
--bs-tertiary-bg: #0d3034;
--bs-border-color: #17494e;
--bs-secondary-color: #8ab6b2;
--bs-primary-bg-subtle: #063a3c;
--bs-primary-text-emphasis: #99f6e4;
--torrent-progress-complete: #34d399;
--pytorrent-page-bg: radial-gradient(circle at top left, rgba(45, 212, 191, 0.16), transparent 36%), #031316;
--pytorrent-shell-shadow: 0 18px 55px rgba(0, 20, 24, 0.58);
}
.btn-primary {
--bs-btn-bg: var(--bs-primary);
--bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: var(--bs-primary-text-emphasis);
--bs-btn-hover-border-color: var(--bs-primary-text-emphasis);
}
.btn-outline-primary {
--bs-btn-color: var(--bs-primary);
--bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72);
--bs-btn-hover-bg: var(--bs-primary);
--bs-btn-hover-border-color: var(--bs-primary);
}
.nav-pills {
--bs-nav-pills-link-active-bg: var(--bs-primary);
}
.progress,
.progress-stacked {
--bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82);
}

View File

@@ -0,0 +1,66 @@
/* Fresh lighter blue theme with a readable dark variant. */
:root, [data-bs-theme="light"] {
color-scheme: light;
--bs-primary: #0284c7;
--bs-primary-rgb: 2,132,199;
--bs-link-color: #0284c7;
--bs-link-color-rgb: 2,132,199;
--bs-link-hover-color: #0369a1;
--bs-link-hover-color-rgb: 2,132,199;
--bs-body-bg: #f2f9ff;
--bs-body-bg-rgb: 242,249,255;
--bs-body-color: #102033;
--bs-secondary-bg: #ffffff;
--bs-secondary-bg-rgb: 255,255,255;
--bs-tertiary-bg: #dff1ff;
--bs-border-color: #bddff5;
--bs-secondary-color: #567185;
--bs-primary-bg-subtle: #d7efff;
--bs-primary-text-emphasis: #0369a1;
--torrent-progress-complete: #10b981;
--pytorrent-page-bg: linear-gradient(180deg, #f8fcff 0%, #e5f4ff 100%);
--pytorrent-shell-shadow: 0 16px 38px rgba(3, 105, 161, 0.13);
}
[data-bs-theme="dark"] {
color-scheme: dark;
--bs-primary: #38bdf8;
--bs-primary-rgb: 56,189,248;
--bs-link-color: #38bdf8;
--bs-link-color-rgb: 56,189,248;
--bs-link-hover-color: #bae6fd;
--bs-link-hover-color-rgb: 56,189,248;
--bs-body-bg: #06111a;
--bs-body-bg-rgb: 6,17,26;
--bs-body-color: #d9ecf8;
--bs-secondary-bg: #0d1c29;
--bs-secondary-bg-rgb: 13,28,41;
--bs-tertiary-bg: #12283a;
--bs-border-color: #21435b;
--bs-secondary-color: #91b4ca;
--bs-primary-bg-subtle: #0b334d;
--bs-primary-text-emphasis: #bae6fd;
--torrent-progress-complete: #34d399;
--pytorrent-page-bg: radial-gradient(circle at top left, rgba(56, 189, 248, 0.15), transparent 36%), #06111a;
--pytorrent-shell-shadow: 0 18px 55px rgba(0, 13, 24, 0.58);
}
.btn-primary {
--bs-btn-bg: var(--bs-primary);
--bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: var(--bs-primary-text-emphasis);
--bs-btn-hover-border-color: var(--bs-primary-text-emphasis);
}
.btn-outline-primary {
--bs-btn-color: var(--bs-primary);
--bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72);
--bs-btn-hover-bg: var(--bs-primary);
--bs-btn-hover-border-color: var(--bs-primary);
}
.nav-pills {
--bs-nav-pills-link-active-bg: var(--bs-primary);
}
.progress,
.progress-stacked {
--bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -16,6 +16,13 @@
<div class="initial-loader-brand"><i class="fa-solid fa-robot"></i> pyTorrent</div> <div class="initial-loader-brand"><i class="fa-solid fa-robot"></i> pyTorrent</div>
<div class="auth-lock" aria-hidden="true"><i class="fa-solid fa-lock"></i></div> <div class="auth-lock" aria-hidden="true"><i class="fa-solid fa-lock"></i></div>
<h1 class="initial-loader-title">Sign in</h1> <h1 class="initial-loader-title">Sign in</h1>
{% if external_provider %}
<p class="initial-loader-text">External authentication is enabled through {{ external_provider }}.</p>
{% if error %}<div class="alert alert-warning auth-alert">{{ error }}</div>{% endif %}
<div class="auth-provider-note">
pyTorrent expects trusted reverse-proxy identity headers. If you are already signed in, check provider headers and user mapping.
</div>
{% else %}
<p class="initial-loader-text">Authentication is enabled for this pyTorrent instance.</p> <p class="initial-loader-text">Authentication is enabled for this pyTorrent instance.</p>
{% if error %}<div class="alert alert-danger auth-alert">{{ error }}</div>{% endif %} {% if error %}<div class="alert alert-danger auth-alert">{{ error }}</div>{% endif %}
<form class="auth-form" method="post"> <form class="auth-form" method="post">
@@ -25,6 +32,7 @@
<input id="password" class="form-control" name="password" type="password" autocomplete="current-password"> <input id="password" class="form-control" name="password" type="password" autocomplete="current-password">
<button class="btn btn-primary w-100" type="submit"><i class="fa-solid fa-right-to-bracket"></i> Log in</button> <button class="btn btn-primary w-100" type="submit"><i class="fa-solid fa-right-to-bracket"></i> Log in</button>
</form> </form>
{% endif %}
</main> </main>
</body> </body>
</html> </html>

View File

@@ -5,3 +5,4 @@ geoip2>=4.8
psutil>=5.9 psutil>=5.9
simple-websocket>=1.0 simple-websocket>=1.0
gunicorn>=22.0 gunicorn>=22.0
hachoir>=3.3

199
scripts/db_cleanup.py Normal file
View File

@@ -0,0 +1,199 @@
#!/usr/bin/env python3
import shutil
import sqlite3
from datetime import datetime
from pathlib import Path
DB_PATH = Path("/opt/pyTorrent/data/pytorrent.sqlite3")
DROP_COLUMNS = {
"rss_feeds": [
"user_id",
],
"rss_rules": [
"user_id",
],
"rss_history": [
"user_id",
],
"smart_queue_settings": [
"user_id",
],
"smart_queue_exclusions": [
"user_id",
],
"smart_queue_history": [
"user_id",
],
"rtorrent_config_overrides": [
"user_id",
],
"user_preferences": [
"table_columns_json",
"peers_refresh_seconds",
"port_check_enabled",
"tracker_favicons_enabled",
"reverse_dns_enabled",
"disk_monitor_paths_json",
"disk_monitor_mode",
"disk_monitor_selected_path",
"disk_monitor_stop_enabled",
"disk_monitor_stop_threshold",
"torrent_sort_json",
"active_filter",
],
}
DROP_INDEXES = [
"idx_rss_feeds_user_profile",
"idx_rss_rules_user_profile",
"idx_rss_history_user_profile",
"idx_smart_queue_settings_user_profile",
"idx_smart_queue_exclusions_user_profile",
"idx_smart_queue_history_user_profile",
"idx_rtorrent_config_overrides_user_profile",
]
EXPECTED_PROFILE_TABLES = {
"rss_feeds": ["profile_id"],
"rss_rules": ["profile_id"],
"rss_history": ["profile_id"],
"smart_queue_settings": ["profile_id"],
"smart_queue_exclusions": ["profile_id"],
"smart_queue_history": ["profile_id"],
"rtorrent_config_overrides": ["profile_id"],
}
def table_exists(conn: sqlite3.Connection, table: str) -> bool:
row = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
(table,),
).fetchone()
return row is not None
def index_exists(conn: sqlite3.Connection, index: str) -> bool:
row = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='index' AND name=?",
(index,),
).fetchone()
return row is not None
def columns(conn: sqlite3.Connection, table: str) -> list[str]:
return [row[1] for row in conn.execute(f'PRAGMA table_info("{table}")')]
def quote_ident(name: str) -> str:
return '"' + name.replace('"', '""') + '"'
def validate_profile_tables(conn: sqlite3.Connection) -> None:
print("Checking required profile scoped tables...")
for table, required_columns in EXPECTED_PROFILE_TABLES.items():
if not table_exists(conn, table):
print(f"SKIP table missing: {table}")
continue
table_columns = columns(conn, table)
for column in required_columns:
if column not in table_columns:
raise RuntimeError(
f"Unsafe cleanup: table {table} does not contain required column {column}"
)
print(f"OK {table}: has {', '.join(required_columns)}")
def drop_indexes(conn: sqlite3.Connection) -> None:
print("\nDropping obsolete indexes if present...")
for index in DROP_INDEXES:
if not index_exists(conn, index):
print(f"SKIP index missing: {index}")
continue
conn.execute(f"DROP INDEX {quote_ident(index)}")
print(f"DROPPED index: {index}")
def drop_obsolete_columns(conn: sqlite3.Connection) -> None:
print("\nDropping obsolete columns if present...")
for table, obsolete_columns in DROP_COLUMNS.items():
if not table_exists(conn, table):
print(f"SKIP table missing: {table}")
continue
current_columns = columns(conn, table)
for column in obsolete_columns:
if column not in current_columns:
print(f"SKIP column missing: {table}.{column}")
continue
try:
conn.execute(
f"ALTER TABLE {quote_ident(table)} DROP COLUMN {quote_ident(column)}"
)
print(f"DROPPED column: {table}.{column}")
current_columns.remove(column)
except sqlite3.OperationalError as exc:
print(f"FAILED column: {table}.{column} -> {exc}")
print("This usually means the column is used by an index, constraint, or old SQLite version.")
def vacuum(conn: sqlite3.Connection) -> None:
print("\nRunning VACUUM...")
conn.execute("VACUUM")
print("VACUUM done.")
def main() -> None:
if not DB_PATH.exists():
raise SystemExit(f"Database not found: {DB_PATH}")
backup_path = DB_PATH.with_suffix(
DB_PATH.suffix + f".cleanup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.bak"
)
print(f"Database: {DB_PATH}")
print(f"Backup: {backup_path}")
shutil.copy2(DB_PATH, backup_path)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
conn.execute("PRAGMA foreign_keys = OFF")
validate_profile_tables(conn)
conn.execute("BEGIN")
drop_indexes(conn)
drop_obsolete_columns(conn)
conn.commit()
conn.execute("PRAGMA foreign_keys = ON")
vacuum(conn)
print("\nCleanup completed successfully.")
print(f"Backup saved as: {backup_path}")
except Exception:
conn.rollback()
print("\nCleanup failed. Database rollback completed.")
print(f"Backup is available at: {backup_path}")
raise
finally:
conn.close()
if __name__ == "__main__":
main()

View File

@@ -41,18 +41,71 @@ def google_fonts_css_url() -> str:
return f"https://fonts.googleapis.com/css2?{families}&display=swap" return f"https://fonts.googleapis.com/css2?{families}&display=swap"
BOOTSTRAP_THEMES = ( DEVEXPRESS_BOOTSTRAP_THEMES = {
"default", "blazing-berry": "Blazing Berry",
"flatly", "office-white": "Office White",
"litera", "purple": "Purple",
"lumen", }
"minty",
"sketchy", PYTORRENT_APP_THEMES = {
"solar", "adaptive": "pyTorrent Adaptive",
"spacelab", "ocean": "pyTorrent Ocean",
"united", "graphite": "pyTorrent Graphite",
"zephyr", "forest": "pyTorrent Forest",
) "amber": "pyTorrent Amber",
"nord": "pyTorrent Nord",
"crimson": "pyTorrent Crimson",
"sky": "pyTorrent Sky",
}
BOOTSTRAP_THEME_DEFINITIONS = {
"default": {
"label": "Default Bootstrap",
"local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/css/bootstrap.min.css",
"cdn": f"https://cdn.jsdelivr.net/npm/bootstrap@{BOOTSTRAP_VERSION}/dist/css/bootstrap.min.css",
},
# Bootswatch themes.
"flatly": {"label": "Bootswatch: Flatly", "provider": "bootswatch"},
"litera": {"label": "Bootswatch: Litera", "provider": "bootswatch"},
"lumen": {"label": "Bootswatch: Lumen", "provider": "bootswatch"},
"minty": {"label": "Bootswatch: Minty", "provider": "bootswatch"},
"sketchy": {"label": "Bootswatch: Sketchy", "provider": "bootswatch"},
"spacelab": {"label": "Bootswatch: Spacelab", "provider": "bootswatch"},
"united": {"label": "Bootswatch: United", "provider": "bootswatch"},
"zephyr": {"label": "Bootswatch: Zephyr", "provider": "bootswatch"},
# Complete DevExpress Bootstrap v5 dist.v5 set.
**{
f"dx-{theme}": {
"label": f"DevExpress: {label}",
"provider": "devexpress",
"local": f"{LIBS_STATIC_DIR}/devexpress-bootstrap-themes/dist.v5/{theme}/bootstrap.min.css",
"cdn": f"https://cdn.jsdelivr.net/gh/DevExpress/bootstrap-themes@master/dist.v5/{theme}/bootstrap.min.css",
}
for theme, label in DEVEXPRESS_BOOTSTRAP_THEMES.items()
},
# App-specific Bootstrap variable overrides. These sit on top of default Bootstrap.
**{
f"pytorrent-{theme}": {
"label": f"Custom: {label}",
"provider": "pytorrent",
"local": f"{LIBS_STATIC_DIR}/pytorrent-themes/{theme}/bootstrap.min.css",
"cdn": f"/static/{LIBS_STATIC_DIR}/pytorrent-themes/{theme}/bootstrap.min.css",
}
for theme, label in PYTORRENT_APP_THEMES.items()
},
}
def _theme_definition(theme: str | None) -> dict[str, str]:
theme = theme if theme in BOOTSTRAP_THEME_DEFINITIONS else "default"
item = dict(BOOTSTRAP_THEME_DEFINITIONS[theme])
if item.get("provider") == "bootswatch":
item["local"] = f"{LIBS_STATIC_DIR}/bootswatch/{BOOTSWATCH_VERSION}/{theme}/bootstrap.min.css"
item["cdn"] = f"https://cdn.jsdelivr.net/npm/bootswatch@{BOOTSWATCH_VERSION}/dist/{theme}/bootstrap.min.css"
return item
BOOTSTRAP_THEMES = tuple(BOOTSTRAP_THEME_DEFINITIONS.keys())
STATIC_ASSETS = { STATIC_ASSETS = {
"bootstrap_js": { "bootstrap_js": {
"local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/js/bootstrap.bundle.min.js", "local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/js/bootstrap.bundle.min.js",
@@ -88,15 +141,8 @@ ANY_URL_RE = re.compile(r"url\((['\"]?)(?!data:)([^)'\"]+)\1\)")
def bootstrap_css_asset(theme: str) -> dict[str, str]: def bootstrap_css_asset(theme: str) -> dict[str, str]:
if theme == "default": item = _theme_definition(theme)
return { return {"local": item["local"], "cdn": item["cdn"]}
"local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/css/bootstrap.min.css",
"cdn": f"https://cdn.jsdelivr.net/npm/bootstrap@{BOOTSTRAP_VERSION}/dist/css/bootstrap.min.css",
}
return {
"local": f"{LIBS_STATIC_DIR}/bootswatch/{BOOTSWATCH_VERSION}/{theme}/bootstrap.min.css",
"cdn": f"https://cdn.jsdelivr.net/npm/bootswatch@{BOOTSWATCH_VERSION}/dist/{theme}/bootstrap.min.css",
}
def download(url: str, dest: Path) -> None: def download(url: str, dest: Path) -> None:
@@ -169,7 +215,11 @@ def main() -> None:
for item in items: for item in items:
url = item["cdn"] url = item["cdn"]
dest = ROOT / "pytorrent" / "static" / item["local"] dest = ROOT / "pytorrent" / "static" / item["local"]
if item.get("local") == STATIC_ASSETS["font_css"]["local"]: if url.startswith("/static/"):
if not dest.is_file() or dest.stat().st_size <= 0:
raise RuntimeError(f"Bundled app theme is missing: {dest.relative_to(ROOT)}")
print(f"OK {dest.relative_to(ROOT)}")
elif item.get("local") == STATIC_ASSETS["font_css"]["local"]:
download_google_fonts_css(url, dest) download_google_fonts_css(url, dest)
elif dest.suffix == ".css": elif dest.suffix == ".css":
download_css_with_assets(url, dest) download_css_with_assets(url, dest)

View File

@@ -567,7 +567,6 @@ def rtorrent_bind_address_directive(rtorrent_ref, rtorrent_version=None):
return "network.bind_address.set" return "network.bind_address.set"
return "network.bind_address.ipv4.set" return "network.bind_address.ipv4.set"
def build_rtorrent_config_content(username, scgi_port, torrent_port, bind_address_directive): def build_rtorrent_config_content(username, scgi_port, torrent_port, bind_address_directive):
return f""" return f"""
## https://git.linuxiarz.pl/gru/tools_scripts/_edit/master/install_rtorrent.py ## https://git.linuxiarz.pl/gru/tools_scripts/_edit/master/install_rtorrent.py
@@ -597,13 +596,29 @@ schedule2 = watch_directory,60,60,load.normal=/home/{username}/watch/*.torrent
ratio.max.set = -1 ratio.max.set = -1
network.xmlrpc.size_limit.set = 33554432 network.xmlrpc.size_limit.set = 33554432
network.http.max_open.set = 64
network.max_open_sockets.set = 8192
network.max_open_files.set = 32768
network.http.dns_cache_timeout.set = 0
#pieces.memory.max.set = 1800M
""".lstrip()
network.http.max_open.set = 64
network.max_open_sockets.set = 1024
network.max_open_files.set = 8192
network.http.dns_cache_timeout.set = 25
network.http.ssl_verify_peer.set = 0
network.send_buffer.size.set = 4M
network.receive_buffer.size.set = 4M
throttle.min_peers.normal.set = 30
throttle.max_peers.normal.set = 150
throttle.min_peers.seed.set = -1
throttle.max_peers.seed.set = -1
throttle.max_downloads.global.set = 300
throttle.max_uploads.global.set = 300
throttle.max_downloads.set = 20
throttle.max_uploads.set = 20
trackers.numwant.set = 80
pieces.hash.on_completion.set = 0
#pieces.memory.max.set = 1024M
""".lstrip()
def write_rtorrent_config(user_home, username, scgi_port, torrent_port, bind_address_directive, *, force_config=False): def write_rtorrent_config(user_home, username, scgi_port, torrent_port, bind_address_directive, *, force_config=False):
config_path = Path(user_home) / ".rtorrent.rc" config_path = Path(user_home) / ".rtorrent.rc"

View File

@@ -568,7 +568,6 @@ def rtorrent_bind_address_directive(rtorrent_ref, rtorrent_version=None):
return "network.bind_address.set" return "network.bind_address.set"
return "network.bind_address.ipv4.set" return "network.bind_address.ipv4.set"
def build_rtorrent_config_content(username, scgi_port, torrent_port, bind_address_directive): def build_rtorrent_config_content(username, scgi_port, torrent_port, bind_address_directive):
return f""" return f"""
## https://git.linuxiarz.pl/gru/tools_scripts/_edit/master/install_rtorrent.py ## https://git.linuxiarz.pl/gru/tools_scripts/_edit/master/install_rtorrent.py
@@ -598,11 +597,28 @@ schedule2 = watch_directory,60,60,load.normal=/home/{username}/watch/*.torrent
ratio.max.set = -1 ratio.max.set = -1
network.xmlrpc.size_limit.set = 33554432 network.xmlrpc.size_limit.set = 33554432
network.http.max_open.set = 64 network.http.max_open.set = 64
network.max_open_sockets.set = 8192 network.max_open_sockets.set = 1024
network.max_open_files.set = 32768 network.max_open_files.set = 8192
network.http.dns_cache_timeout.set = 0 network.http.dns_cache_timeout.set = 25
#pieces.memory.max.set = 1800M network.http.ssl_verify_peer.set = 0
network.send_buffer.size.set = 4M
network.receive_buffer.size.set = 4M
throttle.min_peers.normal.set = 30
throttle.max_peers.normal.set = 150
throttle.min_peers.seed.set = -1
throttle.max_peers.seed.set = -1
throttle.max_downloads.global.set = 300
throttle.max_uploads.global.set = 300
throttle.max_downloads.set = 20
throttle.max_uploads.set = 20
trackers.numwant.set = 80
pieces.hash.on_completion.set = 0
#pieces.memory.max.set = 1024M
""".lstrip() """.lstrip()

View File

@@ -4,7 +4,7 @@ global.window = {PYTORRENT_DISABLE_AUTOSTART: true};
const app = await import('../pytorrent/static/js/app.js'); const app = await import('../pytorrent/static/js/app.js');
const source = app.buildRuntimeSource(); const source = app.buildRuntimeSource();
assert.equal(app.moduleSources.length, 12, 'all frontend module chunks are loaded'); assert.equal(app.moduleSources.length, 18, 'all frontend module chunks are loaded');
assert.doesNotThrow(() => Function('io', source), 'assembled frontend runtime compiles'); assert.doesNotThrow(() => Function('io', source), 'assembled frontend runtime compiles');
for (const marker of [ for (const marker of [