first commit
This commit is contained in:
44
.env.example
Normal file
44
.env.example
Normal file
@@ -0,0 +1,44 @@
|
||||
PYTORRENT_SECRET_KEY=change-me
|
||||
PYTORRENT_DB_PATH=data/pytorrent.sqlite3
|
||||
PYTORRENT_HOST=0.0.0.0
|
||||
PYTORRENT_PORT=8090
|
||||
PYTORRENT_DEBUG=0
|
||||
PYTORRENT_POLL_INTERVAL=0.5
|
||||
MIN_POLL_INTERVAL_SECONDS=0.5
|
||||
PYTORRENT_WORKERS=16
|
||||
PYTORRENT_GEOIP_DB=data/GeoLite2-City.mmdb
|
||||
PYTORRENT_ALLOW_UNSAFE_WERKZEUG=0
|
||||
PYTORRENT_SCGI_RETRIES=8
|
||||
|
||||
# css/js libs
|
||||
PYTORRENT_USE_OFFLINE_LIBS=true
|
||||
|
||||
# python -m pytorrent.cli reset-password admin new_Pass
|
||||
PYTORRENT_AUTH_ENABLE=false
|
||||
|
||||
# Reverse proxy / HTTPS
|
||||
PYTORRENT_PROXY_FIX_ENABLE=false
|
||||
PYTORRENT_SESSION_COOKIE_SECURE=false
|
||||
# PYTORRENT_SOCKETIO_CORS_ALLOWED_ORIGINS=https://your-domain.com
|
||||
|
||||
# Retention / Smart Queue
|
||||
PYTORRENT_TRAFFIC_HISTORY_RETENTION_DAYS=90
|
||||
PYTORRENT_JOBS_RETENTION_DAYS=30
|
||||
PYTORRENT_SMART_QUEUE_HISTORY_RETENTION_DAYS=30
|
||||
PYTORRENT_LOG_RETENTION_DAYS=30
|
||||
PYTORRENT_SMART_QUEUE_LABEL="Smart Queue"
|
||||
|
||||
# Smart Queue diagnostics
|
||||
# none - disabled
|
||||
# short - summary counters only
|
||||
# debug - detailed JSONL diagnostics for start/verify/pending analysis
|
||||
# full - alias for debug
|
||||
PYTORRENT_SMART_QUEUE_DIAGNOSTICS=none
|
||||
PYTORRENT_SMART_QUEUE_DIAGNOSTICS_MAX_ITEMS=200
|
||||
|
||||
# Logs
|
||||
PYTORRENT_LOG_DIR=data/logs
|
||||
PYTORRENT_LOG_RETENTION_HOURS=24
|
||||
PYTORRENT_GUNICORN_ACCESS_LOG=data/logs/gunicorn-access.log
|
||||
PYTORRENT_GUNICORN_ERROR_LOG=data/logs/gunicorn-error.log
|
||||
PYTORRENT_GUNICORN_LOG_LEVEL=info
|
||||
45
.gitignore
vendored
Normal file
45
.gitignore
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
|
||||
# Virtualenv
|
||||
venv/
|
||||
.env
|
||||
.venv
|
||||
|
||||
# App data
|
||||
*.log
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Tests / cache
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# Build
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
storage/*
|
||||
*.zip
|
||||
|
||||
*.sqlite3-shm
|
||||
*.sqlite3
|
||||
data/*
|
||||
!data/tracker_favicons
|
||||
data/tracker_favicons/*.ico
|
||||
data/logs/*
|
||||
!data/logs/
|
||||
!data/logs/README.md
|
||||
|
||||
|
||||
todo.txt
|
||||
pytorrent/static/libs/*
|
||||
1
INSTALL.md
Symbolic link
1
INSTALL.md
Symbolic link
@@ -0,0 +1 @@
|
||||
scripts/INSTALL.md
|
||||
169
README.md
Normal file
169
README.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# pyTorrent
|
||||
|
||||
Single-page web UI for rTorrent inspired by the ruTorrent workflow.
|
||||
|
||||
## Features
|
||||
|
||||
- Flask + Flask-SocketIO.
|
||||
- SQLite storage for preferences, SCGI profiles, Bootstrap theme and UI font.
|
||||
- Multiple rTorrent profiles per user.
|
||||
- Profiles can be added and edited from the UI; the remote profile flag hides local CPU/RAM usage to avoid confusing it with remote rTorrent host resources.
|
||||
- Active rTorrent profile switching from the UI.
|
||||
- Live torrent list over WebSocket.
|
||||
- Application-side cache with patch updates instead of full table reloads.
|
||||
- User operations executed through ThreadPoolExecutor.
|
||||
- `move` and `remove` actions are executed per profile in request order, so later deletes wait for earlier moves.
|
||||
- Job log shows a short date/time in the table and the full timestamp in the tooltip.
|
||||
- Bulk start, pause, stop, resume, recheck, remove and move.
|
||||
- Move supports `move_data=true`; data is physically moved on the rTorrent side in the background and status is polled from a marker file, so long `mv` operations do not hit the SCGI timeout.
|
||||
- Multi-magnet add modal.
|
||||
- Bottom status bar with CPU, RAM, rTorrent version, speeds, limits, total DL/UP and port-check status when enabled.
|
||||
- Torrent context menu.
|
||||
- Keyboard shortcuts.
|
||||
- Details tabs: General, Files, Peers, Trackers and Log.
|
||||
- Smart Queue shows the last 10 operations by default and can expand history to 100 rows.
|
||||
- Peer GeoIP with MaxMind GeoLite2-City.mmdb and IP cache.
|
||||
- Static cache busting with MD5 and cache headers.
|
||||
- Appearance preferences: default Bootstrap or Bootswatch themes Flatly, Litera, Lumen, Minty, Sketchy, Solar, Spacelab, United and Zephyr.
|
||||
- Font preferences: default theme font, Adwaita Mono and additional matching fonts.
|
||||
|
||||
## Complete Debian / Ubuntu install
|
||||
|
||||
The repository includes a full installer for Debian and Ubuntu:
|
||||
|
||||
```bash
|
||||
wget -qO /tmp/install_debian_ubuntu.sh "https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_debian_ubuntu.sh" && sudo bash /tmp/install_debian_ubuntu.sh
|
||||
```
|
||||
|
||||
The installer installs system packages, creates the dedicated `pytorrent` system user, copies the app to `/opt/pytorrent`, creates a virtual environment, installs Python dependencies, downloads offline frontend libraries and GeoIP data when helper scripts are available, then creates and starts the `pytorrent` systemd service.
|
||||
|
||||
Optional environment variables:
|
||||
|
||||
```bash
|
||||
PYTORRENT_USER=pytorrent \
|
||||
PYTORRENT_APP_DIR=/opt/pytorrent \
|
||||
PYTORRENT_SERVICE_NAME=pytorrent \
|
||||
sudo -E bash scripts/install_debian_ubuntu.sh
|
||||
```
|
||||
|
||||
Check the service with:
|
||||
|
||||
```bash
|
||||
sudo systemctl status pytorrent
|
||||
sudo journalctl -u pytorrent -f
|
||||
```
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
./install.sh
|
||||
. venv/bin/activate
|
||||
python app.py
|
||||
```
|
||||
|
||||
Default URL: `http://127.0.0.1:8090`.
|
||||
|
||||
## Production run
|
||||
|
||||
Preferred mode without development Werkzeug:
|
||||
|
||||
```bash
|
||||
. venv/bin/activate
|
||||
gunicorn --worker-class gthread --workers 1 --threads 32 --bind 0.0.0.0:8090 --access-logfile - --error-logfile - wsgi:app
|
||||
```
|
||||
|
||||
Note: the app keeps `async_mode="threading"`, so WebSocket, `start_background_task`, operation queues and the poller run in the same model as before.
|
||||
|
||||
Alternatives reviewed but not enabled by default:
|
||||
|
||||
- Gunicorn with `eventlet`: works with Flask-SocketIO, but requires green threads and monkey patching, which increases regression risk for file and SCGI operations.
|
||||
- Gunicorn with `gevent`: a valid production option, but it needs extra dependencies and compatibility testing.
|
||||
- Multiple Gunicorn workers: requires Redis, RabbitMQ or Kafka as the Socket.IO message queue, so it is not a drop-in replacement.
|
||||
|
||||
## Reverse proxy
|
||||
|
||||
When pyTorrent is served behind a reverse proxy, enable proxy header handling only when the proxy is trusted:
|
||||
|
||||
```env
|
||||
PYTORRENT_PROXY_FIX_ENABLE=true
|
||||
PYTORRENT_SESSION_COOKIE_SECURE=true
|
||||
```
|
||||
|
||||
The proxy should forward at least:
|
||||
|
||||
```txt
|
||||
X-Forwarded-For
|
||||
X-Forwarded-Proto
|
||||
X-Forwarded-Host
|
||||
X-Forwarded-Port
|
||||
```
|
||||
|
||||
This keeps login redirects, session cookies and same-origin API checks correct when HTTPS is terminated by the proxy. If pyTorrent is mounted under a sub-path, also forward `X-Forwarded-Prefix`.
|
||||
|
||||
## SCGI profile
|
||||
|
||||
Example:
|
||||
|
||||
```txt
|
||||
scgi://127.0.0.1:5000/RPC2
|
||||
```
|
||||
|
||||
On the rTorrent side:
|
||||
|
||||
```txt
|
||||
network.scgi.open_port = 127.0.0.1:5000
|
||||
```
|
||||
|
||||
## GeoIP
|
||||
|
||||
The installer downloads GeoLite2-City once to:
|
||||
|
||||
```txt
|
||||
data/GeoLite2-City.mmdb
|
||||
```
|
||||
|
||||
Manual download:
|
||||
|
||||
```bash
|
||||
./scripts/download_geoip.sh
|
||||
```
|
||||
|
||||
The script uses `https://git.io/GeoLite2-City.mmdb` as the primary source and `https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb` as fallback. The `data` directory is set to `755`, and the database file is set to `644`.
|
||||
|
||||
## API docs
|
||||
|
||||
OpenAPI documentation is available at `/docs`. `/api/profiles` supports `max_parallel_jobs` with default value `5` and `is_remote`; `PUT /api/profiles/{profile_id}` edits an existing profile. `/api/preferences` supports fields including `theme`, `bootstrap_theme`, `font_family`, `table_columns_json`, `peers_refresh_seconds` and `port_check_enabled`. `/api/port-check` returns port status with `checked_at`; for remote profiles the public IP is read through rTorrent with fallbacks when supported. `/api/system/status` returns `usage_available=false` for remote profiles and does not read local CPU/RAM.
|
||||
|
||||
`/api/openapi.json` includes reusable schemas for main API responses, including `TorrentListResponse`, `TorrentSummary`, `TorrentFilterSummary`, `CleanupSummary` and `AppStatus`. `GET /api/torrents` documents the `summary` field used by sidebar filters.
|
||||
|
||||
## Admin CLI
|
||||
|
||||
Reset an existing user's password:
|
||||
|
||||
```bash
|
||||
. venv/bin/activate
|
||||
python -m pytorrent.cli reset-password admin new_password
|
||||
```
|
||||
|
||||
Without the password argument, the CLI asks for it interactively:
|
||||
|
||||
```bash
|
||||
python -m pytorrent.cli reset-password admin
|
||||
```
|
||||
|
||||
The command uses the same database as the app and respects `PYTORRENT_DB_PATH` from `.env`. The reset changes only the password hash and leaves role and permissions unchanged.
|
||||
|
||||
## API authentication tokens
|
||||
|
||||
When `PYTORRENT_AUTH_ENABLE=0`, API endpoints work without authentication.
|
||||
|
||||
When `PYTORRENT_AUTH_ENABLE=1`, API access can use either the browser session cookie or a per-user API token. Admin users can generate a token in **Tools -> Users** with **Generate token**. Copy the token immediately; only its prefix and metadata are stored afterwards.
|
||||
|
||||
Use a token in one of these forms:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer pt_xxx" http://127.0.0.1:8080/api/system/status
|
||||
curl -H "X-API-Key: pt_xxx" http://127.0.0.1:8080/api/system/status
|
||||
```
|
||||
|
||||
Token permissions follow the owning user's role and rTorrent profile permissions. Revoked tokens stop working immediately.
|
||||
265
TODO.md
Normal file
265
TODO.md
Normal file
@@ -0,0 +1,265 @@
|
||||
# TODO
|
||||
|
||||
## Done
|
||||
|
||||
- Fixed remote system statistics after the rTorrent service split so CPU/RAM are read from the rTorrent host again.
|
||||
- Fixed split system service dependencies for `default_download_path` and `list_torrents`, restoring disk/system status calls.
|
||||
- Fixed split rTorrent package exports so private compatibility helpers, remote caches and post-check state remain visible after refactor.
|
||||
- Restored remote directory browsing in the split rTorrent system service.
|
||||
- Split the largest backend files into smaller route modules while keeping existing API behavior.
|
||||
- Split rTorrent service logic into a package with focused modules and compatibility exports.
|
||||
- Updated OpenAPI coverage for backend routes, including Planner and Poller endpoints.
|
||||
- Split Planner and Adaptive Poller into separate Tools tabs.
|
||||
- Added Download Planner settings:
|
||||
- master enable switch,
|
||||
- night-only downloads as a separate option,
|
||||
- quiet hours,
|
||||
- weekday and weekend speed limits,
|
||||
- CPU protection,
|
||||
- disk protection,
|
||||
- auto-resume for planner-paused torrents.
|
||||
- Added speed presets and Mbit/s sliders while keeping exact B/s fields for rTorrent values.
|
||||
- Moved disk protection configuration out of Preferences and into Planner.
|
||||
- Kept worker-side disk-start protection wired to Planner disk settings.
|
||||
- Added footer Planner shortcut visible only when Planner is enabled.
|
||||
- Added Adaptive Poller settings:
|
||||
- active interval,
|
||||
- idle interval,
|
||||
- error interval,
|
||||
- system stats interval,
|
||||
- slow stats interval,
|
||||
- heartbeat interval.
|
||||
- Reduced unnecessary heartbeat emissions.
|
||||
- Added tick duration tracking for the poller.
|
||||
- Cleaned stale backup files from the package.
|
||||
- Normalized Planner and Poller UX to match Smart Queue style.
|
||||
- Cleaned Planner/Poller CSS and removed redundant UI wrappers.
|
||||
- Kept the WebSocket live refresh responsive by moving slow poller tasks (torrent stats, Smart Queue, automations and Planner enforce) into a guarded background task.
|
||||
- Added a regression smoke check for fixed-mode poller metrics while a slow background task is running.
|
||||
- Set Adaptive Poller startup defaults to the requested fast-live profile: active/torrent loop 0.5s, idle 3s, errors 2s, system stats 1s, queue 5s, heartbeat 5s, slow threshold 10000ms and slowdown multiplier 1.
|
||||
- Added poller runtime diagnostics for real tick gap, effective interval and configured minimum interval so 0.5s polling can be verified from the UI.
|
||||
|
||||
## Planned
|
||||
|
||||
### Phase 1 - Stabilization and maintainability
|
||||
|
||||
- Split `static/app.js` into smaller frontend modules:
|
||||
- `api.js`,
|
||||
- `state.js`,
|
||||
- `torrents.js`,
|
||||
- `modals.js`,
|
||||
- `smartQueue.js`,
|
||||
- `planner.js`,
|
||||
- `poller.js`,
|
||||
- `rss.js`,
|
||||
- `charts.js`.
|
||||
- Add request validation for API endpoints instead of repeated manual `request.json` parsing.
|
||||
- Add typed application exceptions:
|
||||
- `RtorrentError`,
|
||||
- `RtorrentTimeout`,
|
||||
- `ProfileAccessError`,
|
||||
- `ValidationError`,
|
||||
- `UnsafePathError`,
|
||||
- `BackgroundJobError`.
|
||||
- Reduce broad `except Exception` blocks and map expected errors to clear API responses.
|
||||
- Add structured logging with request ID, profile ID and operation timing.
|
||||
- Add endpoint timing metrics for API calls and rTorrent operations.
|
||||
- Add a deeper healthcheck endpoint covering:
|
||||
- database readability/writability,
|
||||
- active profile state,
|
||||
- rTorrent connection,
|
||||
- background queue state,
|
||||
- poller state.
|
||||
|
||||
### Phase 2 - Tests and safety
|
||||
|
||||
- Add unit tests for torrent parsing.
|
||||
- Add unit tests for Smart Queue decisions.
|
||||
- Add unit tests for Planner schedule windows.
|
||||
- Add unit tests for quiet hours.
|
||||
- Add unit tests for Planner speed limit selection.
|
||||
- Add unit tests for disk protection and CPU protection.
|
||||
- Add unit tests for RSS rule matching.
|
||||
- Add unit tests for ratio rules.
|
||||
- Add unit tests for profile validation.
|
||||
- Add auth and permission tests.
|
||||
- Add path safety tests for move, remove and ZIP download operations.
|
||||
- Add mocked rTorrent integration tests for Planner pause/resume and speed limit application.
|
||||
- Add mocked poller tests for adaptive cadence without requiring a live rTorrent instance.
|
||||
- Add visual regression checks for Tools tabs on narrow screens.
|
||||
- Add CI checks for Python compile, JS syntax, linting and tests.
|
||||
|
||||
### Phase 3 - Planner improvements
|
||||
|
||||
- [x] Add named Planner profiles:
|
||||
- night mode,
|
||||
- weekend mode,
|
||||
- low power mode,
|
||||
- unlimited mode.
|
||||
- [x] Keep Planner settings per active rTorrent profile.
|
||||
- [x] Add Planner preview with the currently matched rule and next scheduled change.
|
||||
- [x] Add Planner action history:
|
||||
- paused torrents,
|
||||
- resumed torrents,
|
||||
- speed limit changes,
|
||||
- CPU protection triggers,
|
||||
- disk protection triggers.
|
||||
- [x] Add dry-run mode for Planner actions.
|
||||
- [x] Add optional network/load protection rules.
|
||||
- [x] Add configurable grace period before auto-resume.
|
||||
- [x] Add manual override timeout, including `disable Planner for 1 hour`.
|
||||
- [x] Add clearer status badges in the footer and Tools panel.
|
||||
|
||||
Implementation notes:
|
||||
- Frontend settings live in the Planner tab and are stored through `/api/download-planner`.
|
||||
- Preview/history are exposed through `/api/download-planner/preview`.
|
||||
- Manual override is exposed through `/api/download-planner/override`.
|
||||
- Dry-run mode records intended actions without calling rTorrent mutations.
|
||||
|
||||
### Phase 4 - Adaptive poller improvements
|
||||
|
||||
- [x] Make polling intervals configurable per rTorrent profile.
|
||||
- [x] Add automatic slowdown when rTorrent responses are slow.
|
||||
- [x] Add automatic recovery after repeated rTorrent errors.
|
||||
- [x] Split polling loop configuration by data type:
|
||||
- torrent list,
|
||||
- system stats,
|
||||
- tracker stats,
|
||||
- disk stats,
|
||||
- queue/job stats.
|
||||
- [x] Emit WebSocket torrent updates only when data changed, with heartbeat fallback.
|
||||
- [x] Add per-tick metrics:
|
||||
- duration,
|
||||
- emitted payload size,
|
||||
- rTorrent call count,
|
||||
- skipped emissions,
|
||||
- current adaptive mode.
|
||||
- [x] Add a Poller diagnostics panel.
|
||||
- [x] Add safe fallback mode if adaptive polling is misconfigured.
|
||||
|
||||
Implementation notes:
|
||||
- Frontend settings live in the Poller tab and are stored through `/api/poller/settings`.
|
||||
- Runtime metrics are included in heartbeat/system stats payloads and shown in Poller diagnostics.
|
||||
- Fallback mode clamps unsafe intervals during normalization.
|
||||
|
||||
### Phase 5 - UX and dashboard
|
||||
|
||||
- [x] Add a torrent health dashboard with sections for:
|
||||
- torrents without seeders,
|
||||
- stopped torrents that should be active,
|
||||
- tracker errors,
|
||||
- duplicate torrents,
|
||||
- slowest torrents,
|
||||
- dead torrents,
|
||||
- largest torrents,
|
||||
- torrents below target ratio.
|
||||
- [x] Add Smart Views:
|
||||
- Needs attention,
|
||||
- Large and slow,
|
||||
- Seeding too long,
|
||||
- New from RSS,
|
||||
- No label,
|
||||
- Private trackers.
|
||||
- [x] Add global search across:
|
||||
- torrent name,
|
||||
- hash,
|
||||
- label,
|
||||
- tracker,
|
||||
- path,
|
||||
- ratio group,
|
||||
- error status.
|
||||
- [x] Add a persistent notification center for:
|
||||
- rTorrent errors,
|
||||
- RSS errors,
|
||||
- automation errors,
|
||||
- disk warnings,
|
||||
- Smart Queue decisions,
|
||||
- Planner actions,
|
||||
- port status.
|
||||
- [x] Add a Diagnostics page for profile, rTorrent, poller, database and worker state.
|
||||
|
||||
Implementation notes:
|
||||
- Health and Smart Views are calculated client-side from the live torrent snapshot.
|
||||
- `Seeding too long` uses rTorrent completion timestamp when available and falls back safely when older rTorrent builds do not expose it.
|
||||
- Global search uses torrent name, hash, label, tracker domain, path, ratio group and warning/error text.
|
||||
- App Status now stays application-focused: process, workers, database and cleanup counters.
|
||||
- Diagnostics owns profile, rTorrent connection, poller, planner, database and worker snapshots to avoid duplicated status cards.
|
||||
|
||||
### Phase 6 - RSS and automation
|
||||
|
||||
- Add RSS feed preview before saving a rule.
|
||||
- Add RSS rule testing against the latest feed entries.
|
||||
- Add RSS dry-run mode.
|
||||
- Add duplicate detection by info-hash where possible.
|
||||
- Add RSS rule priorities.
|
||||
- Add RSS history explaining why an item was accepted or rejected.
|
||||
- Add cleanup rules:
|
||||
- remove completed torrents after target ratio,
|
||||
- move completed data to a destination path,
|
||||
- remove dead torrents after a configurable time,
|
||||
- remove torrents with missing data,
|
||||
- stop seeding after a configured upload amount.
|
||||
- Add import/export for automation rules.
|
||||
|
||||
### Phase 7 - File and torrent operations
|
||||
|
||||
- Improve the Files view with extension filters.
|
||||
- Add bulk priority actions by file type, for example `.mkv`, `.srt`, `.nfo`.
|
||||
- Add folder-level priority actions by path pattern.
|
||||
- Add safer selected-file download handling for large selections.
|
||||
- Add missing-file detection.
|
||||
- Add duplicate torrent detection and merge/cleanup suggestions.
|
||||
|
||||
### Phase 8 - Profiles and rTorrent diagnostics
|
||||
|
||||
- Add SCGI connection test before saving a profile.
|
||||
- Add profile diagnostics:
|
||||
- rTorrent version,
|
||||
- base paths,
|
||||
- write permissions,
|
||||
- free disk space,
|
||||
- response time.
|
||||
- Add import/export for profiles.
|
||||
- Show profile status in the profile picker:
|
||||
- online,
|
||||
- offline,
|
||||
- slow,
|
||||
- error.
|
||||
- Add per-profile API and polling limits.
|
||||
|
||||
### Phase 9 - Security and API
|
||||
|
||||
- Require changing default admin credentials on first login when auth is enabled.
|
||||
- Add login rate limiting.
|
||||
- Add optional TOTP/2FA.
|
||||
- Add stronger CSRF protection for state-changing requests.
|
||||
- Set secure session flags when running behind HTTPS.
|
||||
- Add API tokens:
|
||||
- read-only token,
|
||||
- full-access token,
|
||||
- profile-limited token,
|
||||
- token revocation,
|
||||
- token usage log.
|
||||
- Generate or validate OpenAPI schemas from backend request/response models.
|
||||
- Add generated JS API client from OpenAPI.
|
||||
|
||||
### Phase 10 - Database, backup and release quality
|
||||
|
||||
- Add explicit database migrations with version numbers.
|
||||
- Add `PRAGMA busy_timeout` configuration.
|
||||
- Add periodic `VACUUM` or WAL checkpoint maintenance.
|
||||
- Add indexes for history and frequently filtered data.
|
||||
- Add retention limits for RSS history, automation history and Planner history.
|
||||
- Add backup checksums.
|
||||
- Add backup restore validation.
|
||||
- Keep release archives free from `.bak`, cache and local debug files.
|
||||
|
||||
|
||||
## Phase 5 implementation status
|
||||
|
||||
- [x] Torrent health dashboard in Tools.
|
||||
- [x] Smart Views filters, including completion-time based `Seeding too long`.
|
||||
- [x] Global search expanded to hash, label, tracker, path, ratio group and error/status.
|
||||
- [x] Persistent local notification center.
|
||||
- [x] Diagnostics tool panel for profile, rTorrent, poller, database and workers.
|
||||
|
||||
14
app.py
Normal file
14
app.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from pytorrent import create_app, socketio
|
||||
from pytorrent.config import ALLOW_UNSAFE_WERKZEUG, DEBUG, HOST, PORT
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Note: This entrypoint is kept for local development; production should use gunicorn via wsgi:app.
|
||||
socketio.run(
|
||||
app,
|
||||
host=HOST,
|
||||
port=PORT,
|
||||
debug=DEBUG,
|
||||
allow_unsafe_werkzeug=ALLOW_UNSAFE_WERKZEUG,
|
||||
)
|
||||
1
data/logs/README.md
Normal file
1
data/logs/README.md
Normal file
@@ -0,0 +1 @@
|
||||
logs
|
||||
1
data/tracker_favicons/README.md
Normal file
1
data/tracker_favicons/README.md
Normal file
@@ -0,0 +1 @@
|
||||
tracker_favicons
|
||||
25
deploy/pytorrent.service
Normal file
25
deploy/pytorrent.service
Normal file
@@ -0,0 +1,25 @@
|
||||
# useradd --system --home /opt/pyTorrent --shell /usr/sbin/nologin pytorrent
|
||||
# chown -R pytorrent:pytorrent /opt/pyTorrent
|
||||
|
||||
[Unit]
|
||||
Description=pyTorrent Web UI
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pytorrent
|
||||
Group=pytorrent
|
||||
WorkingDirectory=/opt/pyTorrent
|
||||
Environment="PYTHONUNBUFFERED=1"
|
||||
EnvironmentFile=/opt/pyTorrent/.env
|
||||
ExecStart=/opt/pyTorrent/venv/bin/gunicorn -c /opt/pyTorrent/gunicorn.conf.py --worker-class gthread --workers 1 --threads 32 --bind ${PYTORRENT_HOST}:${PYTORRENT_PORT} wsgi:app
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
KillSignal=SIGINT
|
||||
TimeoutStopSec=20
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
15
gunicorn.conf.py
Normal file
15
gunicorn.conf.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import gunicorn.http.wsgi
|
||||
|
||||
gunicorn.http.wsgi.SERVER = "pyTorrent"
|
||||
|
||||
# Note: Gunicorn writes to data/logs by default; pyTorrent also writes rotated app/access/error logs there.
|
||||
_log_dir = Path(os.getenv("PYTORRENT_LOG_DIR", "data/logs"))
|
||||
_log_dir.mkdir(parents=True, exist_ok=True)
|
||||
accesslog = os.getenv("PYTORRENT_GUNICORN_ACCESS_LOG", str(_log_dir / "gunicorn-access.log"))
|
||||
errorlog = os.getenv("PYTORRENT_GUNICORN_ERROR_LOG", str(_log_dir / "gunicorn-error.log"))
|
||||
loglevel = os.getenv("PYTORRENT_GUNICORN_LOG_LEVEL", "info")
|
||||
14
install.sh
Executable file
14
install.sh
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
python3 -m venv venv
|
||||
. venv/bin/activate
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
cp -n .env.example .env || true
|
||||
grep -q '^PYTORRENT_USE_OFFLINE_LIBS=' .env || echo 'PYTORRENT_USE_OFFLINE_LIBS=true' >> .env
|
||||
./scripts/download_frontend_libs.py
|
||||
mkdir -p data
|
||||
chmod 755 data
|
||||
./scripts/download_geoip.sh data/GeoLite2-City.mmdb
|
||||
python -c "from pytorrent.db import init_db; init_db(); print(\"SQLite initialized\")"
|
||||
echo "Run: . venv/bin/activate && python app.py"
|
||||
70
make_zip.py
Normal file
70
make_zip.py
Normal file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
import zipfile
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run_git_command(args, repo_path: Path) -> bytes:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=repo_path,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def get_files_to_archive(repo_path: Path) -> list[str]:
|
||||
output = run_git_command(
|
||||
["ls-files", "--cached", "--others", "--exclude-standard", "-z"],
|
||||
repo_path,
|
||||
)
|
||||
files = output.decode("utf-8", errors="surrogateescape").split("\0")
|
||||
return [f for f in files if f]
|
||||
|
||||
|
||||
def make_zip(repo_path: Path, output_zip: Path) -> None:
|
||||
files = get_files_to_archive(repo_path)
|
||||
|
||||
output_zip = output_zip.resolve()
|
||||
if output_zip.exists():
|
||||
output_zip.unlink()
|
||||
|
||||
with zipfile.ZipFile(output_zip, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for rel_path in files:
|
||||
abs_path = repo_path / rel_path
|
||||
|
||||
if not abs_path.exists():
|
||||
continue
|
||||
|
||||
if abs_path.resolve() == output_zip:
|
||||
continue
|
||||
|
||||
zf.write(abs_path, arcname=rel_path)
|
||||
|
||||
print(f"Utworzono archiwum: {output_zip}")
|
||||
print(f"Added files: {len(files)}")
|
||||
|
||||
|
||||
def main():
|
||||
repo_path = Path.cwd()
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
output_zip = Path(sys.argv[1])
|
||||
else:
|
||||
output_zip = repo_path / f"{repo_path.name}.zip"
|
||||
|
||||
try:
|
||||
run_git_command(["rev-parse", "--show-toplevel"], repo_path)
|
||||
except subprocess.CalledProcessError:
|
||||
print("Error: this directory is not a Git repository.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
make_zip(repo_path, output_zip)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
156
pytorrent/__init__.py
Normal file
156
pytorrent/__init__.py
Normal file
@@ -0,0 +1,156 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from flask import Flask, jsonify, render_template, request, url_for
|
||||
from flask_socketio import SocketIO
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from .config import (
|
||||
SECRET_KEY,
|
||||
SESSION_COOKIE_SECURE,
|
||||
PROXY_FIX_ENABLE,
|
||||
PROXY_FIX_X_FOR,
|
||||
PROXY_FIX_X_PROTO,
|
||||
PROXY_FIX_X_HOST,
|
||||
PROXY_FIX_X_PORT,
|
||||
PROXY_FIX_X_PREFIX,
|
||||
SOCKETIO_CORS_ALLOWED_ORIGINS,
|
||||
)
|
||||
from .db import init_db
|
||||
from .services.frontend_assets import asset_path, bootstrap_css_path, validate_offline_assets
|
||||
from .utils import file_md5
|
||||
|
||||
socketio = SocketIO(cors_allowed_origins=SOCKETIO_CORS_ALLOWED_ORIGINS, ping_timeout=30, async_mode="threading")
|
||||
_static_md5_cache: dict[tuple, str] = {}
|
||||
|
||||
|
||||
def _wants_json_response() -> bool:
|
||||
"""Return true for API/error clients that should not receive an HTML page."""
|
||||
best = request.accept_mimetypes.best_match(["application/json", "text/html"])
|
||||
return request.path.startswith("/api/") or best == "application/json"
|
||||
|
||||
|
||||
def register_error_pages(app: Flask) -> None:
|
||||
@app.errorhandler(404)
|
||||
def not_found(error):
|
||||
if _wants_json_response():
|
||||
return jsonify({"ok": False, "error": "Not found"}), 404
|
||||
return render_template(
|
||||
"error.html",
|
||||
code=404,
|
||||
title="Page not found",
|
||||
message="The requested pyTorrent view does not exist or is not available.",
|
||||
icon="fa-compass-drafting",
|
||||
), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
def server_error(error):
|
||||
if _wants_json_response():
|
||||
return jsonify({"ok": False, "error": "Internal server error"}), 500
|
||||
return render_template(
|
||||
"error.html",
|
||||
code=500,
|
||||
title="Application error",
|
||||
message="pyTorrent hit an internal error while handling this request.",
|
||||
icon="fa-bug",
|
||||
), 500
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
validate_offline_assets()
|
||||
app = Flask(__name__)
|
||||
from .logging_config import configure_logging
|
||||
configure_logging(app)
|
||||
if PROXY_FIX_ENABLE:
|
||||
app.wsgi_app = ProxyFix(
|
||||
app.wsgi_app,
|
||||
x_for=PROXY_FIX_X_FOR,
|
||||
x_proto=PROXY_FIX_X_PROTO,
|
||||
x_host=PROXY_FIX_X_HOST,
|
||||
x_port=PROXY_FIX_X_PORT,
|
||||
x_prefix=PROXY_FIX_X_PREFIX,
|
||||
)
|
||||
app.secret_key = SECRET_KEY
|
||||
app.config.update(
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SAMESITE="Lax",
|
||||
SESSION_COOKIE_SECURE=SESSION_COOKIE_SECURE,
|
||||
)
|
||||
|
||||
@app.context_processor
|
||||
def static_helpers():
|
||||
def static_url(filename: str) -> str:
|
||||
path = Path(app.static_folder or "") / filename
|
||||
try:
|
||||
stat = path.stat()
|
||||
key = (filename, stat.st_mtime_ns, stat.st_size)
|
||||
version = _static_md5_cache.get(key)
|
||||
if not version:
|
||||
_static_md5_cache.clear()
|
||||
version = file_md5(path)
|
||||
_static_md5_cache[key] = version
|
||||
return url_for("static", filename=filename, v=version)
|
||||
except OSError:
|
||||
return url_for("static", filename=filename)
|
||||
|
||||
def frontend_asset_url(key: str) -> str:
|
||||
path = asset_path(key)
|
||||
return path if path.startswith("http") else static_url(path)
|
||||
|
||||
def bootstrap_theme_url(theme: str | None = None) -> str:
|
||||
path = bootstrap_css_path(theme)
|
||||
return path if path.startswith("http") else static_url(path)
|
||||
|
||||
return {
|
||||
"static_url": static_url,
|
||||
"frontend_asset_url": frontend_asset_url,
|
||||
"bootstrap_theme_url": bootstrap_theme_url,
|
||||
}
|
||||
|
||||
@app.after_request
|
||||
def cache_headers(response):
|
||||
static_file = request.path.startswith("/static/")
|
||||
tracker_icon = request.path.startswith("/static/tracker_favicons/")
|
||||
favicon = request.path in ("/favicon.ico", "/favicon.svg")
|
||||
openapi_spec = request.path == "/api/openapi.json"
|
||||
|
||||
if static_file and not tracker_icon:
|
||||
response.headers["Cache-Control"] = "no-cache, must-revalidate"
|
||||
elif favicon:
|
||||
response.headers["Cache-Control"] = "public, max-age=7899999, immutable"
|
||||
elif openapi_spec:
|
||||
response.headers["Cache-Control"] = "private, no-cache, must-revalidate"
|
||||
else:
|
||||
response.headers["Cache-Control"] = "private, no-store"
|
||||
|
||||
return response
|
||||
|
||||
from .routes.main import bp as main_bp
|
||||
from .routes.api import bp as api_bp
|
||||
from .routes.planner import bp as planner_api_bp
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(api_bp)
|
||||
app.register_blueprint(planner_api_bp)
|
||||
register_error_pages(app)
|
||||
init_db()
|
||||
from .services.speed_peaks import load_cache
|
||||
load_cache()
|
||||
from .services.auth import install_guards
|
||||
install_guards(app)
|
||||
|
||||
socketio.init_app(app)
|
||||
from .services.workers import set_socketio, start_watchdog
|
||||
set_socketio(socketio)
|
||||
start_watchdog()
|
||||
from .services.websocket import register_socketio_handlers
|
||||
register_socketio_handlers(socketio)
|
||||
from .services.startup_config import schedule_startup_config_apply
|
||||
schedule_startup_config_apply(socketio)
|
||||
from .services.rss import start_scheduler as start_rss_scheduler
|
||||
from .services.ratio_rules import start_scheduler as start_ratio_scheduler
|
||||
from .services.download_planner import start_scheduler as start_download_planner_scheduler
|
||||
from .services.backup import start_scheduler as start_backup_scheduler
|
||||
start_rss_scheduler(socketio)
|
||||
start_ratio_scheduler(socketio)
|
||||
start_download_planner_scheduler(socketio)
|
||||
start_backup_scheduler()
|
||||
return app
|
||||
155
pytorrent/cli.py
Normal file
155
pytorrent/cli.py
Normal file
@@ -0,0 +1,155 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import sys
|
||||
import json
|
||||
|
||||
from .db import connect, init_db, utcnow
|
||||
from .services.auth import password_hash
|
||||
from .services import tracker_cache
|
||||
|
||||
|
||||
def reset_password(username: str, password: str) -> bool:
|
||||
"""Note: Reset the selected user password hash without changing role or permissions."""
|
||||
username = (username or "").strip()
|
||||
if not username:
|
||||
raise ValueError("Username is required")
|
||||
if password is None or password == "":
|
||||
raise ValueError("Password cannot be empty")
|
||||
|
||||
init_db()
|
||||
now = utcnow()
|
||||
hashed = password_hash(password)
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT id FROM users WHERE username=?", (username,)).fetchone()
|
||||
if not row:
|
||||
return False
|
||||
conn.execute(
|
||||
"UPDATE users SET password_hash=?, updated_at=? WHERE username=?",
|
||||
(hashed, now, username),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def revoke_api_token_cli(identifier: str, username: str = "") -> int:
|
||||
"""Note: Revoke an API token by numeric id or visible token prefix without starting the web UI."""
|
||||
token = str(identifier or "").strip()
|
||||
if not token:
|
||||
raise ValueError("Token id or prefix is required")
|
||||
init_db()
|
||||
now = utcnow()
|
||||
params: list = []
|
||||
where = ""
|
||||
if token.isdigit():
|
||||
where = "t.id=?"
|
||||
params.append(int(token))
|
||||
else:
|
||||
where = "t.token_prefix=?"
|
||||
params.append(token)
|
||||
if username:
|
||||
where += " AND u.username=?"
|
||||
params.append(str(username).strip())
|
||||
with connect() as conn:
|
||||
row = conn.execute(
|
||||
f"SELECT t.id FROM api_tokens t JOIN users u ON u.id=t.user_id WHERE {where} AND t.revoked_at IS NULL",
|
||||
tuple(params),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return 0
|
||||
conn.execute("UPDATE api_tokens SET revoked_at=?, updated_at=? WHERE id=?", (now, now, int(row["id"])))
|
||||
return 1
|
||||
|
||||
|
||||
|
||||
def fetch_tracker_favicon(domain: str, refresh: bool = True, debug: bool = False) -> str:
|
||||
"""Note: Download or refresh one tracker favicon from CLI without starting the web server."""
|
||||
clean = tracker_cache.tracker_domain(domain)
|
||||
if not clean:
|
||||
raise ValueError("Tracker domain is required")
|
||||
init_db()
|
||||
path, mime = tracker_cache.favicon_path(clean, enabled=True, force=refresh)
|
||||
row = tracker_cache.favicon_cache_row(clean)
|
||||
if not path:
|
||||
detail = (row or {}).get("error") if row else "favicon not found"
|
||||
if debug and row:
|
||||
raise RuntimeError(f"{detail or 'favicon not found'}; cache={json.dumps(dict(row), default=str)}")
|
||||
raise RuntimeError(str(detail or "favicon not found"))
|
||||
if debug and row:
|
||||
return f"{path} ({mime or 'unknown'}) cache={json.dumps(dict(row), default=str)}"
|
||||
return f"{path} ({mime or 'unknown'})"
|
||||
|
||||
def _password_from_args(args: argparse.Namespace) -> str:
|
||||
"""Note: Allow the password to be passed as an argument or entered securely in interactive mode."""
|
||||
if args.password is not None:
|
||||
return args.password
|
||||
first = getpass.getpass("New password: ")
|
||||
second = getpass.getpass("Repeat password: ")
|
||||
if first != second:
|
||||
raise ValueError("Passwords do not match")
|
||||
return first
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""Note: Define simple administrative commands launched with python -m pytorrent.cli."""
|
||||
parser = argparse.ArgumentParser(description="pyTorrent CLI")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
reset = sub.add_parser("reset-password", help="Reset password for an existing user")
|
||||
reset.add_argument("username", help="User login")
|
||||
reset.add_argument("password", nargs="?", help="New password; omit to type it interactively")
|
||||
reset.set_defaults(func=_cmd_reset_password)
|
||||
|
||||
token = sub.add_parser("revoke-api-token", help="Revoke an API token by id or visible prefix")
|
||||
token.add_argument("identifier", help="Token id or token_prefix shown in the Users tab")
|
||||
token.add_argument("--user", default="", help="Optional username filter for safety")
|
||||
token.set_defaults(func=_cmd_revoke_api_token)
|
||||
|
||||
icon = sub.add_parser("tracker-favicon", help="Download or refresh a tracker favicon cache file")
|
||||
icon.add_argument("domain", help="Tracker domain, e.g. t.pte.nu")
|
||||
icon.add_argument("--no-refresh", action="store_true", help="Use fresh cache when available")
|
||||
icon.add_argument("--debug", action="store_true", help="Print cache diagnostics on success or failure")
|
||||
icon.set_defaults(func=_cmd_tracker_favicon)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def _cmd_reset_password(args: argparse.Namespace) -> int:
|
||||
"""Note: Run the password reset and return a readable terminal status."""
|
||||
password = _password_from_args(args)
|
||||
if reset_password(args.username, password):
|
||||
print(f"Password reset for user: {args.username}")
|
||||
return 0
|
||||
print(f"User not found: {args.username}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def _cmd_revoke_api_token(args: argparse.Namespace) -> int:
|
||||
"""Note: Revoke API tokens safely from CLI when the web UI is unavailable."""
|
||||
count = revoke_api_token_cli(args.identifier, username=args.user or "")
|
||||
if count:
|
||||
print(f"API token revoked: {args.identifier}")
|
||||
return 0
|
||||
print(f"Active API token not found: {args.identifier}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def _cmd_tracker_favicon(args: argparse.Namespace) -> int:
|
||||
"""Note: Run favicon discovery from CLI and print the saved file path."""
|
||||
print(fetch_tracker_favicon(args.domain, refresh=not args.no_refresh, debug=bool(args.debug)))
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Note: Main CLI entrypoint with error handling and without starting the web app."""
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
return int(args.func(args) or 0)
|
||||
except Exception as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
85
pytorrent/config.py
Normal file
85
pytorrent/config.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
load_dotenv(BASE_DIR / ".env")
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool = False) -> bool:
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
_SECRET_KEY_ENV = os.getenv("PYTORRENT_SECRET_KEY")
|
||||
SECRET_KEY = _SECRET_KEY_ENV or "dev-change-me"
|
||||
DB_PATH = Path(os.getenv("PYTORRENT_DB_PATH", str(BASE_DIR / "data" / "pytorrent.sqlite3")))
|
||||
if not DB_PATH.is_absolute():
|
||||
DB_PATH = BASE_DIR / DB_PATH
|
||||
|
||||
HOST = os.getenv("PYTORRENT_HOST", "0.0.0.0")
|
||||
PORT = int(os.getenv("PYTORRENT_PORT", "8090"))
|
||||
DEBUG = _env_bool("PYTORRENT_DEBUG", False)
|
||||
# Note: Offline mode forces local JS/CSS and disables the CDN dependency.
|
||||
USE_OFFLINE_LIBS = _env_bool("PYTORRENT_USE_OFFLINE_LIBS", False)
|
||||
# Note: Optional authentication remains disabled unless explicitly enabled in .env.
|
||||
AUTH_ENABLE = _env_bool("PYTORRENT_AUTH_ENABLE", False)
|
||||
if AUTH_ENABLE and (not _SECRET_KEY_ENV or SECRET_KEY == "dev-change-me"):
|
||||
# Note: Auth mode cannot use Flask's development secret; persist a local random session key instead.
|
||||
_secret_file = BASE_DIR / "data" / ".session_secret"
|
||||
_secret_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
if _secret_file.exists():
|
||||
SECRET_KEY = _secret_file.read_text(encoding="utf-8").strip()
|
||||
if not SECRET_KEY or SECRET_KEY == "dev-change-me":
|
||||
SECRET_KEY = secrets.token_urlsafe(48)
|
||||
_secret_file.write_text(SECRET_KEY, encoding="utf-8")
|
||||
SESSION_COOKIE_SECURE = _env_bool("PYTORRENT_SESSION_COOKIE_SECURE", False)
|
||||
# Note: Keep Werkzeug opt-in only for explicit local/dev use, never by default in services.
|
||||
ALLOW_UNSAFE_WERKZEUG = _env_bool("PYTORRENT_ALLOW_UNSAFE_WERKZEUG", DEBUG)
|
||||
POLL_INTERVAL = float(os.getenv("PYTORRENT_POLL_INTERVAL", "0.5"))
|
||||
MIN_POLL_INTERVAL_SECONDS = float(os.getenv("MIN_POLL_INTERVAL_SECONDS", "0.5"))
|
||||
WORKERS = int(os.getenv("PYTORRENT_WORKERS", "16"))
|
||||
GEOIP_DB = Path(os.getenv("PYTORRENT_GEOIP_DB", str(BASE_DIR / "data" / "GeoLite2-City.mmdb")))
|
||||
if not GEOIP_DB.is_absolute():
|
||||
GEOIP_DB = BASE_DIR / GEOIP_DB
|
||||
|
||||
|
||||
|
||||
def _env_int(name: str, default: int, minimum: int = 0) -> int:
|
||||
try:
|
||||
return max(minimum, int(os.getenv(name, str(default))))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
PYTORRENT_TMP_DIR = Path(os.getenv("PYTORRENT_TMP_DIR", "/tmp"))
|
||||
if not PYTORRENT_TMP_DIR.is_absolute():
|
||||
PYTORRENT_TMP_DIR = BASE_DIR / PYTORRENT_TMP_DIR
|
||||
REMOTE_READ_CHUNK_BYTES = _env_int("PYTORRENT_REMOTE_READ_CHUNK_BYTES", 1048576, 65536)
|
||||
|
||||
|
||||
PROXY_FIX_ENABLE = _env_bool("PYTORRENT_PROXY_FIX_ENABLE", False)
|
||||
PROXY_FIX_X_FOR = _env_int("PYTORRENT_PROXY_FIX_X_FOR", 1, 0)
|
||||
PROXY_FIX_X_PROTO = _env_int("PYTORRENT_PROXY_FIX_X_PROTO", 1, 0)
|
||||
PROXY_FIX_X_HOST = _env_int("PYTORRENT_PROXY_FIX_X_HOST", 1, 0)
|
||||
PROXY_FIX_X_PORT = _env_int("PYTORRENT_PROXY_FIX_X_PORT", 1, 0)
|
||||
PROXY_FIX_X_PREFIX = _env_int("PYTORRENT_PROXY_FIX_X_PREFIX", 1, 0)
|
||||
|
||||
_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()]
|
||||
|
||||
TRAFFIC_HISTORY_RETENTION_DAYS = _env_int("PYTORRENT_TRAFFIC_HISTORY_RETENTION_DAYS", 90, 1)
|
||||
JOBS_RETENTION_DAYS = _env_int("PYTORRENT_JOBS_RETENTION_DAYS", 30, 1)
|
||||
SMART_QUEUE_HISTORY_RETENTION_DAYS = _env_int("PYTORRENT_SMART_QUEUE_HISTORY_RETENTION_DAYS", 30, 1)
|
||||
LOG_RETENTION_DAYS = _env_int("PYTORRENT_LOG_RETENTION_DAYS", 1, 1)
|
||||
LOG_RETENTION_HOURS = _env_int("PYTORRENT_LOG_RETENTION_HOURS", 24, 1)
|
||||
LOG_DIR = Path(os.getenv("PYTORRENT_LOG_DIR", "data/logs"))
|
||||
if not LOG_DIR.is_absolute():
|
||||
LOG_DIR = BASE_DIR / LOG_DIR
|
||||
SMART_QUEUE_LABEL = os.getenv("PYTORRENT_SMART_QUEUE_L.ABEL", "Smart Queue Stopped")
|
||||
SMART_QUEUE_STALLED_LABEL = os.getenv("PYTORRENT_SMART_QUEUE_STALLED_LABEL", "Stalled")
|
||||
654
pytorrent/db.py
Normal file
654
pytorrent/db.py
Normal file
@@ -0,0 +1,654 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from .config import DB_PATH
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT,
|
||||
role TEXT DEFAULT 'user',
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_profile_permissions (
|
||||
user_id INTEGER NOT NULL,
|
||||
profile_id INTEGER NOT NULL DEFAULT 0,
|
||||
access_level TEXT NOT NULL DEFAULT 'ro',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(user_id, profile_id),
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
token_prefix TEXT NOT NULL,
|
||||
last_used_at TEXT,
|
||||
revoked_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_active ON api_tokens(user_id, revoked_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_prefix ON api_tokens(token_prefix);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_active_user ON api_tokens(revoked_at, user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_profile_permissions_user ON user_profile_permissions(user_id, profile_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
theme TEXT DEFAULT 'dark',
|
||||
bootstrap_theme TEXT DEFAULT 'default',
|
||||
font_family TEXT DEFAULT 'default',
|
||||
active_rtorrent_id INTEGER,
|
||||
table_columns_json TEXT,
|
||||
keyboard_json TEXT,
|
||||
mobile_mode INTEGER DEFAULT 0,
|
||||
peers_refresh_seconds INTEGER DEFAULT 0,
|
||||
port_check_enabled INTEGER DEFAULT 0,
|
||||
footer_items_json TEXT,
|
||||
title_speed_enabled INTEGER DEFAULT 0,
|
||||
tracker_favicons_enabled INTEGER DEFAULT 0,
|
||||
automation_toasts_enabled INTEGER DEFAULT 1,
|
||||
smart_queue_toasts_enabled INTEGER DEFAULT 1,
|
||||
disk_monitor_paths_json TEXT,
|
||||
disk_monitor_mode TEXT DEFAULT 'default',
|
||||
disk_monitor_selected_path TEXT,
|
||||
disk_monitor_stop_enabled INTEGER DEFAULT 0,
|
||||
disk_monitor_stop_threshold INTEGER DEFAULT 98,
|
||||
interface_scale INTEGER DEFAULT 100,
|
||||
detail_panel_height INTEGER DEFAULT 255,
|
||||
torrent_sort_json TEXT,
|
||||
active_filter TEXT DEFAULT 'all',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rtorrent_profiles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
scgi_url TEXT NOT NULL,
|
||||
is_default INTEGER DEFAULT 0,
|
||||
timeout_seconds INTEGER DEFAULT 5,
|
||||
max_parallel_jobs INTEGER DEFAULT 5,
|
||||
light_parallel_jobs INTEGER DEFAULT 4,
|
||||
light_job_timeout_seconds INTEGER DEFAULT 300,
|
||||
heavy_job_timeout_seconds INTEGER DEFAULT 7200,
|
||||
pending_job_timeout_seconds INTEGER DEFAULT 900,
|
||||
is_remote INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rtorrent_profiles_user_default_name ON rtorrent_profiles(user_id, is_default, name COLLATE NOCASE);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
profile_id INTEGER,
|
||||
action TEXT NOT NULL,
|
||||
payload_json TEXT,
|
||||
status TEXT NOT NULL,
|
||||
attempts INTEGER DEFAULT 0,
|
||||
max_attempts INTEGER DEFAULT 2,
|
||||
error TEXT,
|
||||
result_json TEXT,
|
||||
state_json TEXT,
|
||||
progress_current INTEGER DEFAULT 0,
|
||||
progress_total INTEGER DEFAULT 0,
|
||||
heartbeat_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_profile_status ON jobs(profile_id, status, 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 TABLE IF NOT EXISTS disk_monitor_preferences (
|
||||
user_id INTEGER NOT NULL,
|
||||
profile_id INTEGER NOT NULL,
|
||||
paths_json TEXT,
|
||||
mode TEXT DEFAULT 'default',
|
||||
selected_path TEXT,
|
||||
stop_enabled INTEGER DEFAULT 0,
|
||||
stop_threshold INTEGER DEFAULT 98,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(user_id, profile_id),
|
||||
FOREIGN KEY(user_id) REFERENCES users(id),
|
||||
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS labels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
profile_id INTEGER,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT DEFAULT '#64748b',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(user_id, profile_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ratio_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
profile_id INTEGER,
|
||||
name TEXT NOT NULL,
|
||||
min_ratio REAL DEFAULT 1.0,
|
||||
max_ratio REAL DEFAULT 2.0,
|
||||
seed_time_minutes INTEGER DEFAULT 0,
|
||||
min_seed_time_minutes INTEGER DEFAULT 0,
|
||||
ignore_private INTEGER DEFAULT 1,
|
||||
ignore_active_upload INTEGER DEFAULT 1,
|
||||
active_upload_min_bytes INTEGER DEFAULT 1024,
|
||||
move_path TEXT,
|
||||
set_label TEXT,
|
||||
action TEXT DEFAULT 'stop',
|
||||
enabled INTEGER DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(user_id, profile_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rss_feeds (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
profile_id INTEGER,
|
||||
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
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rss_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
profile_id INTEGER,
|
||||
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
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rss_feeds_user_profile_enabled_next ON rss_feeds(user_id, profile_id, enabled, next_check_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_rss_rules_user_profile_enabled ON rss_rules(user_id, profile_id, enabled);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rss_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
profile_id INTEGER,
|
||||
feed_id INTEGER,
|
||||
rule_id INTEGER,
|
||||
title TEXT,
|
||||
link TEXT,
|
||||
status TEXT NOT NULL,
|
||||
message TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rss_history_profile_created ON rss_history(profile_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_created ON rss_history(user_id, profile_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_status ON rss_history(user_id, profile_id, status);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_rss_history_unique_success ON rss_history(profile_id, COALESCE(rule_id,0), link) WHERE status IN ('queued','added');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ratio_assignments (
|
||||
profile_id INTEGER NOT NULL,
|
||||
torrent_hash TEXT NOT NULL,
|
||||
group_id INTEGER,
|
||||
group_name TEXT,
|
||||
applied_at TEXT,
|
||||
last_status TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(profile_id, torrent_hash)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ratio_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
profile_id INTEGER NOT NULL,
|
||||
group_id INTEGER,
|
||||
group_name TEXT,
|
||||
torrent_hash TEXT NOT NULL,
|
||||
torrent_name TEXT,
|
||||
action TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
details_json TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ratio_history_profile_created ON ratio_history(profile_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ratio_history_user_profile_id ON ratio_history(user_id, profile_id, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ratio_assignments_profile_status ON ratio_assignments(profile_id, last_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_ratio_groups_user_profile_enabled ON ratio_groups(user_id, profile_id, enabled);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_backups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS smart_queue_settings (
|
||||
user_id INTEGER NOT NULL,
|
||||
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(user_id, profile_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS smart_queue_stalled (
|
||||
profile_id INTEGER NOT NULL,
|
||||
torrent_hash TEXT NOT NULL,
|
||||
first_stalled_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
timer_key TEXT DEFAULT '',
|
||||
PRIMARY KEY(profile_id, torrent_hash)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS smart_queue_start_grace (
|
||||
profile_id INTEGER NOT NULL,
|
||||
torrent_hash TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(profile_id, torrent_hash)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS smart_queue_exclusions (
|
||||
user_id INTEGER NOT NULL,
|
||||
profile_id INTEGER NOT NULL,
|
||||
torrent_hash TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(user_id, profile_id, torrent_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_user_profile_created ON smart_queue_exclusions(user_id, profile_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS smart_queue_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
profile_id INTEGER NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
paused_count INTEGER DEFAULT 0,
|
||||
resumed_count INTEGER DEFAULT 0,
|
||||
checked_count INTEGER DEFAULT 0,
|
||||
details_json TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_smart_queue_history_profile_created ON smart_queue_history(profile_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_smart_queue_history_user_profile_created ON smart_queue_history(user_id, profile_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS smart_queue_auto_labels (
|
||||
profile_id INTEGER NOT NULL,
|
||||
torrent_hash TEXT NOT NULL,
|
||||
previous_label TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(profile_id, torrent_hash)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
profile_id INTEGER NOT NULL,
|
||||
down_rate INTEGER DEFAULT 0,
|
||||
up_rate INTEGER DEFAULT 0,
|
||||
total_down INTEGER DEFAULT 0,
|
||||
total_up INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_traffic_history_profile_created ON traffic_history(profile_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transfer_speed_peaks (
|
||||
profile_id INTEGER PRIMARY KEY,
|
||||
session_started_at TEXT NOT NULL,
|
||||
session_down_peak INTEGER DEFAULT 0,
|
||||
session_up_peak INTEGER DEFAULT 0,
|
||||
session_down_peak_at TEXT,
|
||||
session_up_peak_at TEXT,
|
||||
all_time_down_peak INTEGER DEFAULT 0,
|
||||
all_time_up_peak INTEGER DEFAULT 0,
|
||||
all_time_down_peak_at TEXT,
|
||||
all_time_up_peak_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS automation_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
profile_id INTEGER,
|
||||
name TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
conditions_json TEXT NOT NULL,
|
||||
effects_json TEXT NOT NULL,
|
||||
cooldown_minutes INTEGER DEFAULT 60,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_automation_rules_profile_enabled ON automation_rules(profile_id, enabled);
|
||||
CREATE INDEX IF NOT EXISTS idx_automation_rules_user_profile_enabled ON automation_rules(user_id, profile_id, enabled);
|
||||
CREATE TABLE IF NOT EXISTS automation_rule_state (
|
||||
rule_id INTEGER NOT NULL,
|
||||
profile_id INTEGER NOT NULL,
|
||||
torrent_hash TEXT NOT NULL,
|
||||
condition_since_at TEXT,
|
||||
last_matched_at TEXT,
|
||||
last_applied_at TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(rule_id, profile_id, torrent_hash)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS automation_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
profile_id INTEGER NOT NULL,
|
||||
rule_id INTEGER,
|
||||
torrent_hash TEXT,
|
||||
torrent_name TEXT,
|
||||
rule_name TEXT,
|
||||
actions_json TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_automation_history_profile_created ON automation_history(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 (
|
||||
user_id INTEGER NOT NULL,
|
||||
profile_id INTEGER NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT,
|
||||
baseline_value TEXT,
|
||||
apply_on_start INTEGER DEFAULT 0,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(user_id, profile_id, key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rtorrent_config_overrides_profile ON rtorrent_config_overrides(profile_id, apply_on_start);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS download_plan_settings (
|
||||
user_id INTEGER NOT NULL,
|
||||
profile_id INTEGER NOT NULL,
|
||||
settings_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(user_id, profile_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS download_plan_paused (
|
||||
profile_id INTEGER NOT NULL,
|
||||
torrent_hash TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(profile_id, torrent_hash)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_download_plan_paused_profile ON download_plan_paused(profile_id, updated_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS torrent_stats_cache (
|
||||
profile_id INTEGER PRIMARY KEY,
|
||||
payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_epoch REAL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tracker_summary_cache (
|
||||
profile_id INTEGER NOT NULL,
|
||||
torrent_hash TEXT NOT NULL,
|
||||
trackers_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_epoch REAL DEFAULT 0,
|
||||
PRIMARY KEY(profile_id, torrent_hash)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tracker_summary_cache_profile ON tracker_summary_cache(profile_id, updated_epoch);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tracker_favicon_cache (
|
||||
domain TEXT PRIMARY KEY,
|
||||
source_url TEXT,
|
||||
file_path TEXT,
|
||||
mime_type TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_epoch REAL DEFAULT 0,
|
||||
error TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
MIGRATIONS = [
|
||||
"ALTER TABLE api_tokens ADD COLUMN last_used_at TEXT",
|
||||
"ALTER TABLE users ADD COLUMN role TEXT DEFAULT 'user'",
|
||||
"ALTER TABLE users ADD COLUMN is_active INTEGER DEFAULT 1",
|
||||
"ALTER TABLE users ADD COLUMN updated_at TEXT",
|
||||
"ALTER TABLE user_preferences ADD COLUMN mobile_mode INTEGER DEFAULT 0",
|
||||
"ALTER TABLE user_preferences ADD COLUMN peers_refresh_seconds INTEGER DEFAULT 0",
|
||||
"ALTER TABLE user_preferences ADD COLUMN port_check_enabled INTEGER DEFAULT 0",
|
||||
"ALTER TABLE user_preferences ADD COLUMN bootstrap_theme TEXT DEFAULT 'default'",
|
||||
"ALTER TABLE user_preferences ADD COLUMN font_family TEXT DEFAULT 'default'",
|
||||
"ALTER TABLE user_preferences ADD COLUMN footer_items_json TEXT",
|
||||
"ALTER TABLE user_preferences ADD COLUMN title_speed_enabled INTEGER DEFAULT 0",
|
||||
"ALTER TABLE user_preferences ADD COLUMN tracker_favicons_enabled INTEGER DEFAULT 0",
|
||||
"ALTER TABLE user_preferences ADD COLUMN interface_scale INTEGER DEFAULT 100",
|
||||
"ALTER TABLE user_preferences ADD COLUMN detail_panel_height INTEGER DEFAULT 255",
|
||||
"ALTER TABLE user_preferences ADD COLUMN torrent_sort_json TEXT",
|
||||
"ALTER TABLE user_preferences ADD COLUMN active_filter TEXT DEFAULT 'all'",
|
||||
"ALTER TABLE rtorrent_profiles ADD COLUMN max_parallel_jobs INTEGER DEFAULT 5",
|
||||
"ALTER TABLE rtorrent_profiles ADD COLUMN light_parallel_jobs INTEGER DEFAULT 4",
|
||||
"ALTER TABLE rtorrent_profiles ADD COLUMN light_job_timeout_seconds INTEGER DEFAULT 300",
|
||||
"ALTER TABLE rtorrent_profiles ADD COLUMN heavy_job_timeout_seconds INTEGER DEFAULT 7200",
|
||||
"ALTER TABLE rtorrent_profiles ADD COLUMN pending_job_timeout_seconds INTEGER DEFAULT 900",
|
||||
"ALTER TABLE rtorrent_profiles ADD COLUMN is_remote INTEGER DEFAULT 0",
|
||||
"ALTER TABLE jobs ADD COLUMN attempts INTEGER DEFAULT 0",
|
||||
"ALTER TABLE jobs ADD COLUMN max_attempts INTEGER DEFAULT 2",
|
||||
"ALTER TABLE jobs ADD COLUMN result_json TEXT",
|
||||
"ALTER TABLE jobs ADD COLUMN state_json TEXT",
|
||||
"ALTER TABLE jobs ADD COLUMN progress_current INTEGER DEFAULT 0",
|
||||
"ALTER TABLE jobs ADD COLUMN progress_total INTEGER DEFAULT 0",
|
||||
"ALTER TABLE jobs ADD COLUMN heartbeat_at TEXT",
|
||||
"ALTER TABLE jobs ADD COLUMN started_at TEXT",
|
||||
"ALTER TABLE jobs ADD COLUMN finished_at TEXT",
|
||||
"CREATE INDEX IF NOT EXISTS idx_jobs_status_updated ON jobs(status, updated_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_jobs_status_started ON jobs(status, started_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_jobs_status_heartbeat ON jobs(status, heartbeat_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_jobs_user_profile_created ON jobs(user_id, profile_id, created_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_jobs_profile_status_active ON jobs(profile_id, status)",
|
||||
"ALTER TABLE automation_rules ADD COLUMN cooldown_minutes INTEGER DEFAULT 60",
|
||||
"ALTER TABLE rtorrent_config_overrides ADD COLUMN apply_on_start INTEGER DEFAULT 0",
|
||||
"ALTER TABLE rtorrent_config_overrides ADD COLUMN baseline_value TEXT",
|
||||
"ALTER TABLE torrent_stats_cache ADD COLUMN updated_epoch REAL DEFAULT 0",
|
||||
"ALTER TABLE smart_queue_settings ADD COLUMN manage_stopped INTEGER DEFAULT 0",
|
||||
"ALTER TABLE smart_queue_settings ADD COLUMN min_peers INTEGER DEFAULT 0",
|
||||
"ALTER TABLE smart_queue_settings ADD COLUMN ignore_seed_peer INTEGER DEFAULT 0",
|
||||
"ALTER TABLE smart_queue_settings ADD COLUMN ignore_speed INTEGER DEFAULT 0",
|
||||
"ALTER TABLE smart_queue_stalled ADD COLUMN timer_key TEXT DEFAULT ''",
|
||||
"CREATE TABLE IF NOT EXISTS tracker_summary_cache (profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, trackers_json TEXT NOT NULL, updated_at TEXT NOT NULL, updated_epoch REAL DEFAULT 0, PRIMARY KEY(profile_id, torrent_hash))",
|
||||
"CREATE INDEX IF NOT EXISTS idx_tracker_summary_cache_profile ON tracker_summary_cache(profile_id, updated_epoch)",
|
||||
"CREATE TABLE IF NOT EXISTS tracker_favicon_cache (domain TEXT PRIMARY KEY, source_url TEXT, file_path TEXT, mime_type TEXT, updated_at TEXT NOT NULL, updated_epoch REAL DEFAULT 0, error TEXT)",
|
||||
"ALTER TABLE user_preferences ADD COLUMN automation_toasts_enabled INTEGER DEFAULT 1",
|
||||
"ALTER TABLE user_preferences ADD COLUMN smart_queue_toasts_enabled INTEGER DEFAULT 1",
|
||||
"ALTER TABLE user_preferences ADD COLUMN disk_monitor_paths_json TEXT",
|
||||
"ALTER TABLE user_preferences ADD COLUMN disk_monitor_mode TEXT DEFAULT 'default'",
|
||||
"ALTER TABLE user_preferences ADD COLUMN disk_monitor_selected_path TEXT",
|
||||
"ALTER TABLE user_preferences ADD COLUMN disk_monitor_stop_enabled INTEGER DEFAULT 0",
|
||||
"ALTER TABLE user_preferences ADD COLUMN disk_monitor_stop_threshold INTEGER DEFAULT 98",
|
||||
"ALTER TABLE smart_queue_settings ADD COLUMN cooldown_minutes INTEGER DEFAULT 10",
|
||||
"ALTER TABLE smart_queue_settings ADD COLUMN last_run_at TEXT",
|
||||
"ALTER TABLE smart_queue_settings ADD COLUMN refill_enabled INTEGER DEFAULT 1",
|
||||
"ALTER TABLE smart_queue_settings ADD COLUMN refill_interval_minutes INTEGER DEFAULT 0",
|
||||
"ALTER TABLE smart_queue_settings ADD COLUMN last_refill_at TEXT",
|
||||
"ALTER TABLE smart_queue_settings ADD COLUMN stop_batch_size INTEGER DEFAULT 50",
|
||||
"ALTER TABLE smart_queue_settings ADD COLUMN start_grace_seconds INTEGER DEFAULT 900",
|
||||
"ALTER TABLE smart_queue_settings ADD COLUMN protect_active_below_cap INTEGER DEFAULT 1",
|
||||
"ALTER TABLE smart_queue_settings ADD COLUMN auto_stop_idle INTEGER DEFAULT 0",
|
||||
"CREATE TABLE IF NOT EXISTS smart_queue_start_grace (profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, started_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id, torrent_hash))",
|
||||
"ALTER TABLE rss_feeds ADD COLUMN interval_minutes INTEGER DEFAULT 30",
|
||||
"ALTER TABLE rss_feeds ADD COLUMN next_check_at TEXT",
|
||||
"ALTER TABLE rss_rules ADD COLUMN exclude_pattern TEXT",
|
||||
"ALTER TABLE rss_rules ADD COLUMN min_size_mb INTEGER DEFAULT 0",
|
||||
"ALTER TABLE rss_rules ADD COLUMN max_size_mb INTEGER DEFAULT 0",
|
||||
"ALTER TABLE rss_rules ADD COLUMN category TEXT",
|
||||
"ALTER TABLE rss_rules ADD COLUMN quality TEXT",
|
||||
"ALTER TABLE rss_rules ADD COLUMN season INTEGER",
|
||||
"ALTER TABLE rss_rules ADD COLUMN episode INTEGER",
|
||||
"ALTER TABLE ratio_groups ADD COLUMN min_seed_time_minutes INTEGER DEFAULT 0",
|
||||
"ALTER TABLE ratio_groups ADD COLUMN ignore_private INTEGER DEFAULT 1",
|
||||
"ALTER TABLE ratio_groups ADD COLUMN ignore_active_upload INTEGER DEFAULT 1",
|
||||
"ALTER TABLE ratio_groups ADD COLUMN active_upload_min_bytes INTEGER DEFAULT 1024",
|
||||
"ALTER TABLE ratio_groups ADD COLUMN move_path TEXT",
|
||||
"ALTER TABLE ratio_groups ADD COLUMN set_label TEXT",
|
||||
"ALTER TABLE automation_history ADD COLUMN torrent_name TEXT",
|
||||
"ALTER TABLE automation_history ADD COLUMN rule_name TEXT",
|
||||
"ALTER TABLE automation_history ADD COLUMN actions_json TEXT",
|
||||
"ALTER TABLE automation_history ADD COLUMN torrent_hash TEXT",
|
||||
"CREATE TABLE IF NOT EXISTS rss_history (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, profile_id INTEGER, feed_id INTEGER, rule_id INTEGER, title TEXT, link TEXT, status TEXT NOT NULL, message TEXT, created_at TEXT NOT NULL)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_rss_history_profile_created ON rss_history(profile_id, created_at)",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_rss_history_unique_success ON rss_history(profile_id, COALESCE(rule_id,0), link) WHERE status IN ('queued','added')",
|
||||
"CREATE TABLE IF NOT EXISTS ratio_assignments (profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, group_id INTEGER, group_name TEXT, applied_at TEXT, last_status TEXT, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id, torrent_hash))",
|
||||
"CREATE TABLE IF NOT EXISTS ratio_history (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, group_id INTEGER, group_name TEXT, torrent_hash TEXT NOT NULL, torrent_name TEXT, action TEXT NOT NULL, status TEXT NOT NULL, reason TEXT, details_json TEXT, created_at TEXT NOT NULL)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_ratio_history_profile_created ON ratio_history(profile_id, created_at)",
|
||||
"CREATE TABLE IF NOT EXISTS app_backups (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, name TEXT NOT NULL, payload_json TEXT NOT NULL, created_at TEXT NOT NULL)",
|
||||
"CREATE TABLE IF NOT EXISTS disk_monitor_preferences (user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, paths_json TEXT, mode TEXT DEFAULT 'default', selected_path TEXT, stop_enabled INTEGER DEFAULT 0, stop_threshold INTEGER DEFAULT 98, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id), FOREIGN KEY(user_id) REFERENCES users(id), FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id))",
|
||||
"CREATE TABLE IF NOT EXISTS download_plan_settings (user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, settings_json TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id))",
|
||||
"CREATE TABLE IF NOT EXISTS download_plan_paused (profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, reason TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id, torrent_hash))",
|
||||
"CREATE INDEX IF NOT EXISTS idx_download_plan_paused_profile ON download_plan_paused(profile_id, updated_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_jobs_profile_created ON jobs(profile_id, created_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_rss_feeds_user_profile_enabled_next ON rss_feeds(user_id, profile_id, enabled, next_check_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_rss_rules_user_profile_enabled ON rss_rules(user_id, profile_id, enabled)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_created ON rss_history(user_id, profile_id, created_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_status ON rss_history(user_id, profile_id, status)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_ratio_groups_user_profile_enabled ON ratio_groups(user_id, profile_id, enabled)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_ratio_assignments_profile_status ON ratio_assignments(profile_id, last_status)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_ratio_history_user_profile_id ON ratio_history(user_id, profile_id, id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_user_profile_created ON smart_queue_exclusions(user_id, profile_id, created_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_smart_queue_history_user_profile_created ON smart_queue_history(user_id, profile_id, created_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_automation_rules_user_profile_enabled ON automation_rules(user_id, profile_id, enabled)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_automation_history_user_profile_created ON automation_history(user_id, profile_id, created_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_rtorrent_profiles_user_default_name ON rtorrent_profiles(user_id, is_default, name COLLATE NOCASE)",
|
||||
]
|
||||
|
||||
POST_MIGRATION_INDEXES = [
|
||||
"CREATE INDEX IF NOT EXISTS idx_api_tokens_active_user ON api_tokens(revoked_at, user_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_profile_permissions_user ON user_profile_permissions(user_id, profile_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_jobs_status_updated ON jobs(status, updated_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_jobs_status_started ON jobs(status, started_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_jobs_status_heartbeat ON jobs(status, heartbeat_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_jobs_user_profile_created ON jobs(user_id, profile_id, created_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_jobs_profile_status_active ON jobs(profile_id, status)",
|
||||
]
|
||||
|
||||
def utcnow() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def dict_factory(cursor, row):
|
||||
return {col[0]: row[idx] for idx, col in enumerate(cursor.description)}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def connect():
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH, timeout=30)
|
||||
conn.row_factory = dict_factory
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("PRAGMA busy_timeout = 30000")
|
||||
conn.execute("PRAGMA synchronous = NORMAL")
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
with connect() as conn:
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
conn.executescript(SCHEMA)
|
||||
for sql in MIGRATIONS:
|
||||
try:
|
||||
conn.execute(sql)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
for sql in POST_MIGRATION_INDEXES:
|
||||
try:
|
||||
conn.execute(sql)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
now = utcnow()
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users(id, username, password_hash, role, is_active, created_at, updated_at) VALUES(1, 'default', NULL, 'admin', 1, ?, ?)",
|
||||
(now, now),
|
||||
)
|
||||
conn.execute("UPDATE users SET role=COALESCE(role, 'admin'), is_active=COALESCE(is_active, 1), updated_at=COALESCE(updated_at, ?) WHERE id=1", (now,))
|
||||
pref = conn.execute("SELECT id FROM user_preferences WHERE user_id=1").fetchone()
|
||||
if not pref:
|
||||
conn.execute(
|
||||
"INSERT INTO user_preferences(user_id, theme, created_at, updated_at) VALUES(1, 'dark', ?, ?)",
|
||||
(now, now),
|
||||
)
|
||||
try:
|
||||
from .services.auth import ensure_admin_user
|
||||
ensure_admin_user()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def default_user_id() -> int:
|
||||
return 1
|
||||
87
pytorrent/logging_config.py
Normal file
87
pytorrent/logging_config.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, g, request
|
||||
|
||||
from .config import LOG_DIR, LOG_RETENTION_HOURS
|
||||
|
||||
_CONFIGURED = False
|
||||
|
||||
|
||||
def _make_handler(path: Path, level: int) -> TimedRotatingFileHandler:
|
||||
"""Create an hourly rotating log handler with retention configured in hours."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = TimedRotatingFileHandler(
|
||||
path,
|
||||
when="H",
|
||||
interval=1,
|
||||
backupCount=max(1, int(LOG_RETENTION_HOURS)),
|
||||
encoding="utf-8",
|
||||
utc=False,
|
||||
)
|
||||
handler.setLevel(level)
|
||||
handler.suffix = "%Y%m%d%H"
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s [%(name)s] %(message)s"))
|
||||
return handler
|
||||
|
||||
|
||||
def configure_logging(app: Flask | None = None) -> None:
|
||||
"""Route pyTorrent app, error and access logs to the configured data log directory."""
|
||||
global _CONFIGURED
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not _CONFIGURED:
|
||||
app_handler = _make_handler(LOG_DIR / "app.log", logging.INFO)
|
||||
error_handler = _make_handler(LOG_DIR / "error.log", logging.WARNING)
|
||||
|
||||
root = logging.getLogger()
|
||||
root.setLevel(logging.INFO)
|
||||
root.addHandler(app_handler)
|
||||
root.addHandler(error_handler)
|
||||
|
||||
for name in ("pytorrent", "werkzeug", "gunicorn.error"):
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = True
|
||||
|
||||
_CONFIGURED = True
|
||||
|
||||
if app is not None:
|
||||
app.logger.setLevel(logging.INFO)
|
||||
if not getattr(app, "_pytorrent_access_logging", False):
|
||||
access_logger = logging.getLogger("pytorrent.access")
|
||||
access_logger.setLevel(logging.INFO)
|
||||
access_logger.propagate = False
|
||||
access_logger.addHandler(_make_handler(LOG_DIR / "access.log", logging.INFO))
|
||||
|
||||
@app.before_request
|
||||
def _mark_access_start() -> None:
|
||||
g._access_started_at = time.perf_counter()
|
||||
|
||||
@app.after_request
|
||||
def _write_access_log(response):
|
||||
duration_ms = int((time.perf_counter() - getattr(g, "_access_started_at", time.perf_counter())) * 1000)
|
||||
# Note: Application access logging is rotated hourly, unlike raw gunicorn stdout logs.
|
||||
access_logger.info(
|
||||
'%s "%s %s" %s %s %sms "%s"',
|
||||
request.headers.get("X-Forwarded-For", request.remote_addr or "-"),
|
||||
request.method,
|
||||
request.full_path.rstrip("?"),
|
||||
response.status_code,
|
||||
response.calculate_content_length() or 0,
|
||||
duration_ms,
|
||||
request.headers.get("User-Agent", "-"),
|
||||
)
|
||||
return response
|
||||
|
||||
@app.teardown_request
|
||||
def _log_unhandled_error(error: BaseException | None) -> None:
|
||||
if error is not None:
|
||||
app.logger.error("Unhandled request error", exc_info=(type(error), error, error.__traceback__))
|
||||
|
||||
app._pytorrent_access_logging = True # type: ignore[attr-defined]
|
||||
5994
pytorrent/openapi/openapi.json
Normal file
5994
pytorrent/openapi/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
407
pytorrent/routes/_shared.py
Normal file
407
pytorrent/routes/_shared.py
Normal file
@@ -0,0 +1,407 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import time
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import socket
|
||||
import json
|
||||
import psutil
|
||||
import zipfile
|
||||
import tempfile
|
||||
import queue
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
from flask import Blueprint, jsonify, request, abort, send_file, redirect, Response, stream_with_context
|
||||
from ..config import DB_PATH, JOBS_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, WORKERS, PYTORRENT_TMP_DIR
|
||||
from ..db import connect, utcnow
|
||||
from ..services.auth import current_user_id as default_user_id, current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, require_profile_write
|
||||
from ..services import preferences, rtorrent, torrent_stats, speed_peaks, tracker_cache, rss as rss_service, ratio_rules, backup as backup_service, download_planner
|
||||
from ..services.torrent_cache import torrent_cache
|
||||
from ..services.torrent_summary import cached_summary
|
||||
from ..services.workers import enqueue, list_jobs, cancel_job, retry_job, force_job, clear_jobs, emergency_clear_jobs
|
||||
from ..services.geoip import lookup_ip
|
||||
from ..services.torrent_meta import parse_torrent
|
||||
|
||||
bp = Blueprint("api", __name__, url_prefix="/api")
|
||||
|
||||
MOVE_BULK_MAX_HASHES = 100
|
||||
|
||||
|
||||
from .auth_api import register_auth_routes
|
||||
register_auth_routes(bp)
|
||||
|
||||
|
||||
def _job_profile_id(job_id: str) -> int | None:
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT profile_id FROM jobs WHERE id=?", (job_id,)).fetchone()
|
||||
return int(row.get("profile_id") or 0) if row else None
|
||||
|
||||
def ok(payload=None):
|
||||
data = {"ok": True}
|
||||
if payload:
|
||||
data.update(payload)
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
|
||||
PORT_CHECK_CACHE_SECONDS = 6 * 60 * 60
|
||||
|
||||
|
||||
def _app_setting_get(key: str):
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (key,)).fetchone()
|
||||
return row.get("value") if row else None
|
||||
|
||||
|
||||
def _app_setting_set(key: str, value: str):
|
||||
with connect() as conn:
|
||||
conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (key, value))
|
||||
|
||||
|
||||
def _iso_from_epoch(value) -> str | None:
|
||||
try:
|
||||
return datetime.fromtimestamp(float(value), timezone.utc).isoformat(timespec="seconds")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _public_ip(profile: dict | None = None, force: bool = False) -> str:
|
||||
if profile and bool(profile.get("is_remote")):
|
||||
return rtorrent.remote_public_ip(profile, force=force)
|
||||
req = urllib.request.Request("https://api.ipify.org", headers={"User-Agent": "pyTorrent/port-check"})
|
||||
with urllib.request.urlopen(req, timeout=8) as res:
|
||||
return res.read(64).decode("utf-8", "replace").strip()
|
||||
|
||||
|
||||
MAX_PORT_CHECK_CANDIDATES = 256
|
||||
|
||||
|
||||
def _parse_port_candidates(value: str, limit: int = MAX_PORT_CHECK_CANDIDATES) -> tuple[list[int], bool]:
|
||||
"""Return valid incoming port candidates from rTorrent network.port_range.
|
||||
|
||||
Note: rTorrent may keep a range/list and pick a random port on start.
|
||||
The old checker used only the first number, which produced false "closed"
|
||||
results when another configured port was actually active.
|
||||
"""
|
||||
ports: list[int] = []
|
||||
seen: set[int] = set()
|
||||
truncated = False
|
||||
|
||||
def add(port: int) -> None:
|
||||
nonlocal truncated
|
||||
if not 1 <= port <= 65535 or port in seen:
|
||||
return
|
||||
if len(ports) >= limit:
|
||||
truncated = True
|
||||
return
|
||||
seen.add(port)
|
||||
ports.append(port)
|
||||
|
||||
for start, end in re.findall(r"(\d{1,5})\s*-\s*(\d{1,5})", value or ""):
|
||||
a, b = int(start), int(end)
|
||||
if a > b:
|
||||
a, b = b, a
|
||||
for port in range(a, b + 1):
|
||||
add(port)
|
||||
if truncated:
|
||||
break
|
||||
|
||||
without_ranges = re.sub(r"\d{1,5}\s*-\s*\d{1,5}", " ", value or "")
|
||||
for item in re.findall(r"\d{1,5}", without_ranges):
|
||||
add(int(item))
|
||||
|
||||
return ports, truncated
|
||||
|
||||
|
||||
def _incoming_ports(profile: dict) -> dict:
|
||||
try:
|
||||
raw_value = str(rtorrent.client_for(profile).call("network.port_range") or "")
|
||||
except Exception:
|
||||
raw_value = ""
|
||||
ports, truncated = _parse_port_candidates(raw_value)
|
||||
return {"ports": ports, "raw": raw_value, "truncated": truncated}
|
||||
|
||||
|
||||
def _yougetsignal_check(public_ip: str, port: int) -> dict:
|
||||
body = urllib.parse.urlencode({"remoteAddress": public_ip, "portNumber": str(port)}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
"https://ports.yougetsignal.com/check-port.php",
|
||||
data=body,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"User-Agent": "pyTorrent/port-check",
|
||||
"Accept": "text/html,application/json,*/*",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=12) as res:
|
||||
text = res.read(8192).decode("utf-8", "replace")
|
||||
low = text.lower()
|
||||
if "is open" in low:
|
||||
return {"status": "open", "source": "yougetsignal", "raw": text[:500]}
|
||||
if "is closed" in low:
|
||||
return {"status": "closed", "source": "yougetsignal", "raw": text[:500]}
|
||||
return {"status": "unknown", "source": "yougetsignal", "raw": text[:500]}
|
||||
|
||||
|
||||
def _local_port_fallback(public_ip: str, port: int) -> dict:
|
||||
try:
|
||||
with socket.create_connection((public_ip, port), timeout=3):
|
||||
return {"status": "open", "source": "local-fallback"}
|
||||
except Exception as exc:
|
||||
return {"status": "unknown", "source": "local-fallback", "error": f"Local fallback inconclusive: {exc}"}
|
||||
|
||||
|
||||
def _check_ports(public_ip: str, ports: list[int], checker) -> dict:
|
||||
checked: list[int] = []
|
||||
first_closed: dict | None = None
|
||||
last_result: dict = {"status": "unknown"}
|
||||
|
||||
for port in ports:
|
||||
checked.append(port)
|
||||
current = checker(public_ip, port)
|
||||
last_result = current
|
||||
if current.get("status") == "open":
|
||||
current.update({"port": port, "open_port": port, "checked_ports": checked})
|
||||
return current
|
||||
if current.get("status") == "closed" and first_closed is None:
|
||||
first_closed = current
|
||||
|
||||
result = first_closed or last_result
|
||||
result.update({"port": ports[0] if ports else None, "open_port": None, "checked_ports": checked})
|
||||
return result
|
||||
|
||||
|
||||
def port_check_status(force: bool = False) -> dict:
|
||||
profile = preferences.active_profile()
|
||||
prefs = preferences.get_preferences()
|
||||
enabled = bool((prefs or {}).get("port_check_enabled"))
|
||||
if not profile:
|
||||
return {"status": "unknown", "enabled": enabled, "error": "No profile"}
|
||||
|
||||
port_info = _incoming_ports(profile)
|
||||
ports = port_info["ports"]
|
||||
if not ports:
|
||||
return {"status": "unknown", "enabled": enabled, "error": "Cannot read rTorrent network.port_range"}
|
||||
|
||||
ports_key = ",".join(str(port) for port in ports)
|
||||
cache_key = f"port_check:{profile['id']}:{ports_key}:{int(bool(port_info['truncated']))}"
|
||||
if not force:
|
||||
cached = _app_setting_get(cache_key)
|
||||
if cached:
|
||||
try:
|
||||
data = json.loads(cached)
|
||||
if time.time() - float(data.get("checked_at_epoch") or 0) < PORT_CHECK_CACHE_SECONDS:
|
||||
data["cached"] = True
|
||||
data["enabled"] = enabled
|
||||
if not data.get("checked_at"):
|
||||
data["checked_at"] = _iso_from_epoch(data.get("checked_at_epoch"))
|
||||
return data
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
checked_at_epoch = time.time()
|
||||
result = {
|
||||
"status": "unknown",
|
||||
"enabled": enabled,
|
||||
"port": ports[0],
|
||||
"ports": ports,
|
||||
"port_range": port_info["raw"],
|
||||
"ports_truncated": port_info["truncated"],
|
||||
"checked_at_epoch": checked_at_epoch,
|
||||
"checked_at": _iso_from_epoch(checked_at_epoch),
|
||||
"cached": False,
|
||||
}
|
||||
try:
|
||||
public_ip = _public_ip(profile, force=force)
|
||||
result["public_ip"] = public_ip
|
||||
result["remote"] = bool(profile.get("is_remote"))
|
||||
result.update(_check_ports(public_ip, ports, _yougetsignal_check))
|
||||
except Exception as exc:
|
||||
result["error"] = f"YouGetSignal failed: {exc}"
|
||||
try:
|
||||
public_ip = result.get("public_ip") or _public_ip(profile, force=force)
|
||||
result["public_ip"] = public_ip
|
||||
result["remote"] = bool(profile.get("is_remote"))
|
||||
result.update(_check_ports(public_ip, ports, _local_port_fallback))
|
||||
except Exception as fallback_exc:
|
||||
result["fallback_error"] = str(fallback_exc)
|
||||
result["source"] = "none"
|
||||
_app_setting_set(cache_key, json.dumps(result))
|
||||
return result
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _safe_len(callable_obj) -> int | None:
|
||||
try:
|
||||
return len(callable_obj())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _table_count(table: str, where: str = "", params: tuple = ()) -> int:
|
||||
with connect() as conn:
|
||||
exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone()
|
||||
if not exists:
|
||||
return 0
|
||||
row = conn.execute(f"SELECT COUNT(*) AS n FROM {table} {where}", params).fetchone()
|
||||
return int((row or {}).get("n") or 0)
|
||||
|
||||
|
||||
def _db_size() -> dict:
|
||||
try:
|
||||
size = DB_PATH.stat().st_size if DB_PATH.exists() else 0
|
||||
return {"path": str(DB_PATH), "size": size, "size_h": rtorrent.human_size(size)}
|
||||
except Exception as exc:
|
||||
return {"path": str(DB_PATH), "size": 0, "size_h": "0 B", "error": str(exc)}
|
||||
|
||||
|
||||
def _active_profile_cache_summary(profile_id: int | None = None) -> dict:
|
||||
profile = preferences.active_profile() if profile_id is None else {"id": profile_id}
|
||||
profile_id = int((profile or {}).get("id") or 0)
|
||||
if not profile_id:
|
||||
return {"profile_id": 0, "profile_rows": 0, "runtime_items": 0}
|
||||
tracker_rows = _table_count("tracker_summary_cache", "WHERE profile_id=?", (profile_id,))
|
||||
stats_rows = _table_count("torrent_stats_cache", "WHERE profile_id=?", (profile_id,))
|
||||
runtime_items = 0
|
||||
try:
|
||||
runtime_items += len(torrent_cache.snapshot(profile_id))
|
||||
except Exception:
|
||||
pass
|
||||
return {"profile_id": profile_id, "profile_rows": tracker_rows + stats_rows, "tracker_rows": tracker_rows, "torrent_stats_rows": stats_rows, "runtime_items": runtime_items}
|
||||
|
||||
|
||||
def cleanup_summary() -> dict:
|
||||
return {
|
||||
"jobs_total": _table_count("jobs"),
|
||||
"jobs_clearable": _table_count("jobs", "WHERE status NOT IN ('pending', 'running')"),
|
||||
"smart_queue_history_total": _table_count("smart_queue_history"),
|
||||
"automation_history_total": _table_count("automation_history"),
|
||||
"planner_history_total": download_planner.history_count(int((preferences.active_profile() or {}).get("id") or 0)) if preferences.active_profile() else 0,
|
||||
"cache": _active_profile_cache_summary(),
|
||||
"retention_days": {
|
||||
"jobs": JOBS_RETENTION_DAYS,
|
||||
"smart_queue_history": SMART_QUEUE_HISTORY_RETENTION_DAYS,
|
||||
"automation_history": SMART_QUEUE_HISTORY_RETENTION_DAYS,
|
||||
"planner_history": SMART_QUEUE_HISTORY_RETENTION_DAYS,
|
||||
},
|
||||
"database": _db_size(),
|
||||
}
|
||||
|
||||
def active_default_download_path(profile: dict | None) -> str:
|
||||
if not profile:
|
||||
return ""
|
||||
try:
|
||||
return rtorrent.default_download_path(profile)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def enrich_bulk_payload(profile: dict, action_name: str, data: dict) -> dict:
|
||||
payload = dict(data or {})
|
||||
hashes = payload.get("hashes") or []
|
||||
if isinstance(hashes, str):
|
||||
hashes = [hashes]
|
||||
hashes = [str(h) for h in hashes if h]
|
||||
payload["hashes"] = hashes
|
||||
payload["job_context"] = {
|
||||
"source": "api",
|
||||
"action": action_name,
|
||||
"bulk": len(hashes) > 1,
|
||||
"hash_count": len(hashes),
|
||||
"requested_at": utcnow(),
|
||||
}
|
||||
if hashes:
|
||||
try:
|
||||
by_hash = {str(t.get("hash")): t for t in torrent_cache.snapshot(profile["id"])}
|
||||
payload["job_context"]["items"] = [
|
||||
{
|
||||
"hash": h,
|
||||
"name": str((by_hash.get(h) or {}).get("name") or ""),
|
||||
"path": str((by_hash.get(h) or {}).get("path") or ""),
|
||||
}
|
||||
for h in hashes
|
||||
]
|
||||
except Exception as exc:
|
||||
payload["job_context"]["items_error"] = str(exc)
|
||||
if action_name == "move":
|
||||
payload["job_context"]["target_path"] = str(payload.get("path") or "")
|
||||
payload["job_context"]["move_data"] = bool(payload.get("move_data"))
|
||||
if action_name == "remove":
|
||||
payload["job_context"]["remove_data"] = bool(payload.get("remove_data"))
|
||||
return payload
|
||||
|
||||
|
||||
def _chunk_hashes(hashes: list[str], size: int = MOVE_BULK_MAX_HASHES) -> list[list[str]]:
|
||||
# Note: Splits very large torrent selections into predictable chunks so each queued job stays small and recoverable.
|
||||
safe_size = max(1, int(size or MOVE_BULK_MAX_HASHES))
|
||||
return [hashes[index:index + safe_size] for index in range(0, len(hashes), safe_size)]
|
||||
|
||||
|
||||
def enqueue_bulk_parts(profile: dict, action_name: str, data: dict) -> list[dict]:
|
||||
# Note: One shared helper splits large move/remove operations into small ordered parts without changing other actions.
|
||||
base_payload = enrich_bulk_payload(profile, action_name, data)
|
||||
hashes = base_payload.get("hashes") or []
|
||||
chunks = _chunk_hashes(hashes)
|
||||
if len(chunks) <= 1:
|
||||
job_id = enqueue(action_name, profile["id"], base_payload)
|
||||
return [{"job_id": job_id, "label": "bulk-1", "part": 1, "parts": 1, "hashes": hashes, "hash_count": len(hashes)}]
|
||||
|
||||
jobs = []
|
||||
items_by_hash = {str(item.get("hash")): item for item in (base_payload.get("job_context") or {}).get("items") or []}
|
||||
for index, chunk in enumerate(chunks, start=1):
|
||||
payload = dict(base_payload)
|
||||
payload["hashes"] = chunk
|
||||
context = dict(base_payload.get("job_context") or {})
|
||||
context.update({
|
||||
"bulk": True,
|
||||
"bulk_label": f"bulk-{index}",
|
||||
"bulk_part": index,
|
||||
"bulk_parts": len(chunks),
|
||||
"hash_count": len(chunk),
|
||||
"parent_hash_count": len(hashes),
|
||||
"items": [items_by_hash[h] for h in chunk if h in items_by_hash],
|
||||
})
|
||||
payload["job_context"] = context
|
||||
job_id = enqueue(action_name, profile["id"], payload)
|
||||
jobs.append({"job_id": job_id, "label": context["bulk_label"], "part": index, "parts": len(chunks), "hashes": chunk, "hash_count": len(chunk)})
|
||||
return jobs
|
||||
|
||||
|
||||
def enqueue_move_bulk_parts(profile: dict, data: dict) -> list[dict]:
|
||||
# Note: Keep the old public move helper while using the same partitioning logic.
|
||||
return enqueue_bulk_parts(profile, "move", data)
|
||||
|
||||
|
||||
def enqueue_remove_bulk_parts(profile: dict, data: dict) -> list[dict]:
|
||||
# Note: Remove/rm uses the same partitioning as move, which lowers rTorrent load.
|
||||
return enqueue_bulk_parts(profile, "remove", data)
|
||||
|
||||
|
||||
def _user_disk_status(profile: dict) -> dict:
|
||||
# Note: Disk usage is user-preference aware, so it is read separately from the shared Socket.IO poller.
|
||||
prefs = preferences.get_disk_monitor_preferences(profile.get("id") if profile else None)
|
||||
try:
|
||||
paths = json.loads((prefs or {}).get("disk_monitor_paths_json") or "[]") if prefs else []
|
||||
except Exception:
|
||||
paths = []
|
||||
return rtorrent.disk_usage_for_paths(
|
||||
profile,
|
||||
paths,
|
||||
(prefs or {}).get("disk_monitor_mode") or "default",
|
||||
(prefs or {}).get("disk_monitor_selected_path") or "",
|
||||
)
|
||||
|
||||
|
||||
|
||||
# Note: Route modules import shared helpers with wildcard imports; include private helper names intentionally.
|
||||
__all__ = [name for name in globals() if not name.startswith('__')]
|
||||
14
pytorrent/routes/api.py
Normal file
14
pytorrent/routes/api.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import bp
|
||||
|
||||
# Note: Route modules are imported for their decorators; this keeps the public API unchanged.
|
||||
from . import torrents as _torrents_routes
|
||||
from . import profiles as _profiles_routes
|
||||
from . import rss as _rss_routes
|
||||
from . import automations as _automations_routes
|
||||
from . import smart_queue as _smart_queue_routes
|
||||
from . import system as _system_routes
|
||||
from . import backup as _backup_routes
|
||||
|
||||
__all__ = ["bp"]
|
||||
97
pytorrent/routes/auth_api.py
Normal file
97
pytorrent/routes/auth_api.py
Normal file
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import abort, jsonify, request
|
||||
|
||||
from ..services.auth import current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, list_api_tokens, create_api_token, revoke_api_token
|
||||
|
||||
|
||||
def _ok(payload=None):
|
||||
data = {"ok": True}
|
||||
if payload:
|
||||
data.update(payload)
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
def register_auth_routes(bp):
|
||||
@bp.post("/auth/login")
|
||||
def auth_login():
|
||||
if not auth_enabled():
|
||||
abort(404)
|
||||
data = request.get_json(silent=True) or {}
|
||||
user = login_user(str(data.get("username") or ""), str(data.get("password") or ""))
|
||||
if not user:
|
||||
return jsonify({"ok": False, "error": "Invalid username or password"}), 401
|
||||
return _ok({"user": user, "auth_enabled": auth_enabled()})
|
||||
|
||||
@bp.get("/auth/me")
|
||||
def auth_me():
|
||||
if not auth_enabled():
|
||||
abort(404)
|
||||
return _ok({"user": current_user(), "auth_enabled": auth_enabled()})
|
||||
|
||||
@bp.post("/auth/logout")
|
||||
def auth_logout():
|
||||
if not auth_enabled():
|
||||
abort(404)
|
||||
logout_user()
|
||||
return _ok()
|
||||
|
||||
@bp.get("/auth/users")
|
||||
def auth_users_list():
|
||||
if not auth_enabled():
|
||||
abort(404)
|
||||
return _ok({"users": list_users()})
|
||||
|
||||
@bp.post("/auth/users")
|
||||
def auth_users_create():
|
||||
if not auth_enabled():
|
||||
abort(404)
|
||||
try:
|
||||
return _ok({"user": save_user(request.get_json(silent=True) or {})})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
@bp.put("/auth/users/<int:user_id>")
|
||||
def auth_users_update(user_id: int):
|
||||
if not auth_enabled():
|
||||
abort(404)
|
||||
try:
|
||||
return _ok({"user": save_user(request.get_json(silent=True) or {}, user_id)})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
@bp.delete("/auth/users/<int:user_id>")
|
||||
def auth_users_delete(user_id: int):
|
||||
if not auth_enabled():
|
||||
abort(404)
|
||||
try:
|
||||
delete_user(user_id)
|
||||
return _ok()
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
@bp.get("/auth/users/<int:user_id>/tokens")
|
||||
def auth_user_tokens_list(user_id: int):
|
||||
if not auth_enabled():
|
||||
abort(404)
|
||||
return _ok({"tokens": list_api_tokens(user_id)})
|
||||
|
||||
@bp.post("/auth/users/<int:user_id>/tokens")
|
||||
def auth_user_tokens_create(user_id: int):
|
||||
if not auth_enabled():
|
||||
abort(404)
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
return _ok({"token": create_api_token(user_id, str(data.get("name") or "API token"))})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
@bp.delete("/auth/users/<int:user_id>/tokens/<int:token_id>")
|
||||
def auth_user_tokens_delete(user_id: int, token_id: int):
|
||||
if not auth_enabled():
|
||||
abort(404)
|
||||
try:
|
||||
revoke_api_token(user_id, token_id)
|
||||
return _ok({"tokens": list_api_tokens(user_id)})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
116
pytorrent/routes/automations.py
Normal file
116
pytorrent/routes/automations.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
|
||||
@bp.get('/automations')
|
||||
def automations_get():
|
||||
from ..services import automation_rules
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return ok({'rules': [], 'history': [], 'error': 'No profile'})
|
||||
try:
|
||||
return ok({'rules': automation_rules.list_rules(profile['id']), 'history': automation_rules.list_history(profile['id'])})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc), 'rules': [], 'history': []}), 500
|
||||
|
||||
|
||||
|
||||
@bp.get('/automations/export')
|
||||
def automations_export():
|
||||
from ..services import automation_rules
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
# Note: JSON export is profile-scoped and excludes execution history/cooldown state.
|
||||
data = automation_rules.export_rules(profile['id'])
|
||||
return ok({'export': data, 'count': len(data.get('rules') or [])})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.post('/automations/import')
|
||||
def automations_import():
|
||||
from ..services import automation_rules
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
replace = str(request.args.get('replace') or '').lower() in {'1', 'true', 'yes'} or bool(payload.get('replace')) if isinstance(payload, dict) else False
|
||||
# Note: Import appends rules by default, so existing automations remain untouched.
|
||||
imported = automation_rules.import_rules(profile['id'], payload, replace=replace)
|
||||
return ok({'imported': len(imported), 'rules': automation_rules.list_rules(profile['id'])})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.post('/automations')
|
||||
def automations_save():
|
||||
from ..services import automation_rules
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
rule = automation_rules.save_rule(profile['id'], request.get_json(silent=True) or {})
|
||||
return ok({'rule': rule, 'rules': automation_rules.list_rules(profile['id'])})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.delete('/automations/<int:rule_id>')
|
||||
def automations_delete(rule_id: int):
|
||||
from ..services import automation_rules
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
automation_rules.delete_rule(rule_id, profile['id'])
|
||||
return ok({'rules': automation_rules.list_rules(profile['id'])})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.post('/automations/<int:rule_id>/run')
|
||||
def automations_run_rule(rule_id: int):
|
||||
from ..services import automation_rules
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
# Note: Single-rule run ignores disabled state and cooldown for manual troubleshooting.
|
||||
return ok({'result': automation_rules.check(profile, force=True, rule_id=rule_id), 'rules': automation_rules.list_rules(profile['id']), 'history': automation_rules.list_history(profile['id'])})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc)}), 500
|
||||
|
||||
|
||||
@bp.post('/automations/check')
|
||||
def automations_check():
|
||||
from ..services import automation_rules
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
# Note: Force check ignores disabled state and cooldown, allowing a one-off manual automation pass.
|
||||
return ok({'result': automation_rules.check(profile, force=True), 'rules': automation_rules.list_rules(profile['id']), 'history': automation_rules.list_history(profile['id'])})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc)}), 500
|
||||
|
||||
|
||||
|
||||
@bp.delete('/automations/history')
|
||||
def automations_history_clear():
|
||||
from ..services import automation_rules
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
# Note: Clear only automation execution logs; rules and cooldown state stay unchanged.
|
||||
deleted = automation_rules.clear_history(profile['id'])
|
||||
return ok({'deleted': deleted, 'history': automation_rules.list_history(profile['id']), 'cleanup': cleanup_summary()})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc)}), 500
|
||||
69
pytorrent/routes/backup.py
Normal file
69
pytorrent/routes/backup.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
|
||||
@bp.get("/backup")
|
||||
def backup_list():
|
||||
return ok({"backups": backup_service.list_backups(default_user_id()), "auto": backup_service.get_auto_backup_settings(default_user_id())})
|
||||
|
||||
|
||||
|
||||
@bp.post("/backup")
|
||||
def backup_create():
|
||||
data = request.get_json(silent=True) or {}
|
||||
return ok({"backup": backup_service.create_backup(str(data.get("name") or "Manual backup"), default_user_id()), "backups": backup_service.list_backups(default_user_id())})
|
||||
|
||||
|
||||
@bp.get("/backup/settings")
|
||||
def backup_settings_get():
|
||||
return ok({"settings": backup_service.get_auto_backup_settings(default_user_id())})
|
||||
|
||||
|
||||
@bp.post("/backup/settings")
|
||||
def backup_settings_save():
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
return ok({"settings": backup_service.save_auto_backup_settings(data, default_user_id())})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@bp.get("/backup/<int:backup_id>/preview")
|
||||
def backup_preview(backup_id: int):
|
||||
try:
|
||||
return ok({"preview": backup_service.preview_backup(backup_id, default_user_id())})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.post("/backup/<int:backup_id>/restore")
|
||||
def backup_restore(backup_id: int):
|
||||
try:
|
||||
return ok({"result": backup_service.restore_backup(backup_id, default_user_id())})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.delete("/backup/<int:backup_id>")
|
||||
def backup_delete(backup_id: int):
|
||||
try:
|
||||
return ok({"result": backup_service.delete_backup(backup_id, default_user_id())})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.get("/backup/<int:backup_id>/download")
|
||||
def backup_download(backup_id: int):
|
||||
try:
|
||||
payload = backup_service.payload_for_backup(backup_id, default_user_id())
|
||||
tmp = tempfile.NamedTemporaryFile(prefix="pytorrent-backup-", suffix=".json", delete=False, mode="w", encoding="utf-8")
|
||||
json.dump(payload, tmp, ensure_ascii=False, indent=2)
|
||||
tmp.close()
|
||||
return send_file(tmp.name, as_attachment=True, download_name=f"pytorrent-backup-{backup_id}.json")
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
79
pytorrent/routes/main.py
Normal file
79
pytorrent/routes/main.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, render_template, Response, request, redirect, url_for, abort, send_file
|
||||
from ..services.preferences import get_preferences, list_profiles, active_profile, BOOTSTRAP_THEMES, FONT_FAMILIES
|
||||
from ..services import auth
|
||||
from ..services.frontend_assets import asset_path
|
||||
|
||||
# for favicon
|
||||
from flask import current_app, send_from_directory
|
||||
|
||||
bp = Blueprint("main", __name__)
|
||||
|
||||
|
||||
def _asset_url(key: str) -> str:
|
||||
path = asset_path(key)
|
||||
return path if path.startswith("http") else url_for("static", filename=path)
|
||||
|
||||
|
||||
@bp.get("/favicon.ico")
|
||||
def favicon_ico():
|
||||
response = send_from_directory(
|
||||
current_app.static_folder,
|
||||
"favicon.svg",
|
||||
mimetype="image/svg+xml",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
# Note: When optional authentication is disabled, /login is intentionally unavailable.
|
||||
if not auth.enabled():
|
||||
abort(404)
|
||||
error = ""
|
||||
if request.method == "POST":
|
||||
user = auth.login_user(request.form.get("username", ""), request.form.get("password", ""))
|
||||
if user:
|
||||
return redirect(request.args.get("next") or url_for("main.index"))
|
||||
error = "Invalid username or password"
|
||||
return render_template("login.html", error=error)
|
||||
|
||||
|
||||
@bp.get("/logout")
|
||||
def logout():
|
||||
auth.logout_user()
|
||||
if not auth.enabled():
|
||||
return redirect(url_for("main.index"))
|
||||
return redirect(url_for("main.login"))
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
def index():
|
||||
prefs = get_preferences()
|
||||
return render_template(
|
||||
"index.html",
|
||||
prefs=prefs,
|
||||
profiles=list_profiles(),
|
||||
active_profile=active_profile(),
|
||||
bootstrap_themes=BOOTSTRAP_THEMES,
|
||||
font_families=FONT_FAMILIES,
|
||||
auth_enabled=auth.enabled(),
|
||||
current_user=auth.current_user(),
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/docs")
|
||||
def docs():
|
||||
html = f"""<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>pyTorrent API Docs</title><link rel="stylesheet" href="{_asset_url('swagger_css')}"></head><body><div id="swagger-ui"></div><script src="{_asset_url('swagger_js')}"></script><script>window.onload=()=>SwaggerUIBundle({{url:'/api/openapi.json',dom_id:'#swagger-ui',deepLinking:true,persistAuthorization:true}});</script></body></html>"""
|
||||
return Response(html, mimetype="text/html")
|
||||
|
||||
|
||||
@bp.get("/api/openapi.json")
|
||||
def openapi():
|
||||
spec_path = Path(current_app.root_path) / "openapi" / "openapi.json"
|
||||
response = send_file(spec_path, mimetype="application/json", conditional=False, max_age=0)
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, private"
|
||||
return response
|
||||
109
pytorrent/routes/planner.py
Normal file
109
pytorrent/routes/planner.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from ..services import preferences, download_planner, poller_control
|
||||
from ..services.auth import current_user_id
|
||||
|
||||
bp = Blueprint("planner_api", __name__, url_prefix="/api")
|
||||
|
||||
|
||||
def ok(payload=None):
|
||||
data = {"ok": True}
|
||||
if payload:
|
||||
data.update(payload)
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
def _profile_or_error():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return None, (jsonify({"ok": False, "error": "No profile"}), 400)
|
||||
return profile, None
|
||||
|
||||
|
||||
@bp.get("/download-planner")
|
||||
def download_planner_get():
|
||||
profile, error = _profile_or_error()
|
||||
if error:
|
||||
return error
|
||||
return ok({"settings": download_planner.get_settings(int(profile["id"]), current_user_id())})
|
||||
|
||||
|
||||
@bp.post("/download-planner")
|
||||
def download_planner_save():
|
||||
profile, error = _profile_or_error()
|
||||
if error:
|
||||
return error
|
||||
try:
|
||||
settings = download_planner.save_settings(int(profile["id"]), request.get_json(silent=True) or {}, current_user_id())
|
||||
return ok({"settings": settings})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@bp.post("/download-planner/check")
|
||||
def download_planner_check():
|
||||
profile, error = _profile_or_error()
|
||||
if error:
|
||||
return error
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
run_profile = dict(profile)
|
||||
if data.get("dry_run"):
|
||||
run_profile["dry_run"] = "true"
|
||||
return ok({"result": download_planner.enforce(run_profile, force=True)})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@bp.get("/download-planner/preview")
|
||||
def download_planner_preview():
|
||||
profile, error = _profile_or_error()
|
||||
if error:
|
||||
return error
|
||||
return ok({"preview": download_planner.preview(profile), "history": download_planner.history(int(profile["id"]), int(request.args.get("history_limit") or 40)), "history_total": download_planner.history_count(int(profile["id"]))})
|
||||
|
||||
|
||||
@bp.delete("/download-planner/history")
|
||||
def download_planner_history_clear():
|
||||
profile, error = _profile_or_error()
|
||||
if error:
|
||||
return error
|
||||
try:
|
||||
deleted = download_planner.clear_history(int(profile["id"]))
|
||||
return ok({"deleted": deleted, "history": [], "history_total": 0})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@bp.post("/download-planner/override")
|
||||
def download_planner_override():
|
||||
profile, error = _profile_or_error()
|
||||
if error:
|
||||
return error
|
||||
try:
|
||||
seconds = int((request.get_json(silent=True) or {}).get("seconds") or 0)
|
||||
return ok(download_planner.set_manual_override(int(profile["id"]), seconds))
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@bp.get("/poller/settings")
|
||||
def poller_settings_get():
|
||||
profile, error = _profile_or_error()
|
||||
if error:
|
||||
return error
|
||||
pid = int(profile["id"])
|
||||
return ok({"settings": poller_control.get_settings(pid), "runtime": poller_control.snapshot(pid)})
|
||||
|
||||
|
||||
@bp.post("/poller/settings")
|
||||
def poller_settings_save():
|
||||
profile, error = _profile_or_error()
|
||||
if error:
|
||||
return error
|
||||
try:
|
||||
return ok({"settings": poller_control.save_settings(int(profile["id"]), request.get_json(silent=True) or {})})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
182
pytorrent/routes/profiles.py
Normal file
182
pytorrent/routes/profiles.py
Normal file
@@ -0,0 +1,182 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
from ..services.rtorrent.diagnostics import profile_diagnostics
|
||||
|
||||
@bp.get("/profiles")
|
||||
def profiles_list():
|
||||
return ok({"profiles": preferences.list_profiles(), "active": preferences.active_profile()})
|
||||
|
||||
|
||||
|
||||
@bp.post("/profiles")
|
||||
def profiles_create():
|
||||
try:
|
||||
return ok({"profile": preferences.save_profile(request.json or {})})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.put("/profiles/<int:profile_id>")
|
||||
def profiles_update(profile_id: int):
|
||||
try:
|
||||
return ok({"profile": preferences.update_profile(profile_id, request.json or {})})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.delete("/profiles/<int:profile_id>")
|
||||
def profiles_delete(profile_id: int):
|
||||
preferences.delete_profile(profile_id)
|
||||
return ok({"profiles": preferences.list_profiles(), "active": preferences.active_profile()})
|
||||
|
||||
|
||||
|
||||
@bp.post("/profiles/<int:profile_id>/activate")
|
||||
def profiles_activate(profile_id: int):
|
||||
try:
|
||||
return ok({"profile": preferences.activate_profile(profile_id)})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 404
|
||||
|
||||
|
||||
|
||||
@bp.post("/profiles/test")
|
||||
def profiles_test_unsaved():
|
||||
data = request.get_json(silent=True) or {}
|
||||
profile = {
|
||||
"id": data.get("id"),
|
||||
"name": data.get("name") or "test",
|
||||
"scgi_url": data.get("scgi_url") or "",
|
||||
"timeout_seconds": data.get("timeout_seconds") or 5,
|
||||
}
|
||||
return ok({"diagnostics": profile_diagnostics(profile)})
|
||||
|
||||
|
||||
@bp.get("/profiles/<int:profile_id>/diagnostics")
|
||||
def profiles_diagnostics(profile_id: int):
|
||||
profile = preferences.get_profile(profile_id)
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "Profile not found"}), 404
|
||||
return ok({"diagnostics": profile_diagnostics(profile)})
|
||||
|
||||
|
||||
@bp.get("/profiles/diagnostics")
|
||||
def profiles_diagnostics_all():
|
||||
rows = preferences.list_profiles()
|
||||
diagnostics = []
|
||||
for profile in rows:
|
||||
diagnostics.append(profile_diagnostics(profile))
|
||||
return ok({"diagnostics": diagnostics})
|
||||
|
||||
|
||||
@bp.get("/profiles/export")
|
||||
def profiles_export():
|
||||
return ok(preferences.export_profiles())
|
||||
|
||||
|
||||
@bp.post("/profiles/import")
|
||||
def profiles_import():
|
||||
try:
|
||||
rows = preferences.import_profiles(request.get_json(silent=True) or {})
|
||||
return ok({"profiles": rows})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@bp.get("/preferences")
|
||||
def prefs_get():
|
||||
return ok({"preferences": preferences.get_preferences()})
|
||||
|
||||
|
||||
|
||||
@bp.post("/preferences")
|
||||
def prefs_save():
|
||||
return ok({"preferences": preferences.save_preferences(request.json or {})})
|
||||
|
||||
|
||||
@bp.post("/preferences/table-columns/recommended")
|
||||
def prefs_table_columns_recommended():
|
||||
# Note: Applies the backend-owned recommended desktop and mobile column layout.
|
||||
return ok({"preferences": preferences.apply_recommended_table_columns()})
|
||||
|
||||
|
||||
|
||||
@bp.get("/labels")
|
||||
def labels_list():
|
||||
profile = preferences.active_profile()
|
||||
pid = profile["id"] if profile else None
|
||||
with connect() as conn:
|
||||
rows = conn.execute("SELECT * FROM labels WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY name COLLATE NOCASE", (default_user_id(), pid)).fetchall()
|
||||
return ok({"labels": rows})
|
||||
|
||||
|
||||
|
||||
@bp.post("/labels")
|
||||
def labels_save():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
name = str(data.get("name") or "").strip()
|
||||
if not name:
|
||||
return jsonify({"ok": False, "error": "Missing label name"}), 400
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
conn.execute("INSERT OR IGNORE INTO labels(user_id,profile_id,name,color,created_at,updated_at) VALUES(?,?,?,?,?,?)", (default_user_id(), profile["id"], name, data.get("color") or "#64748b", now, now))
|
||||
return labels_list()
|
||||
|
||||
|
||||
|
||||
@bp.delete("/labels/<int:label_id>")
|
||||
def labels_delete(label_id: int):
|
||||
profile = preferences.active_profile()
|
||||
pid = profile["id"] if profile else None
|
||||
with connect() as conn:
|
||||
conn.execute("DELETE FROM labels WHERE id=? AND user_id=? AND (profile_id=? OR profile_id IS NULL)", (label_id, default_user_id(), pid))
|
||||
return labels_list()
|
||||
|
||||
|
||||
|
||||
@bp.get("/ratio-groups")
|
||||
def ratio_groups_list():
|
||||
profile = preferences.active_profile()
|
||||
pid = profile["id"] if profile else None
|
||||
with connect() as conn:
|
||||
rows = conn.execute("SELECT * FROM ratio_groups WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY name COLLATE NOCASE", (default_user_id(), pid)).fetchall()
|
||||
history = conn.execute("SELECT * FROM ratio_history WHERE user_id=? AND profile_id=? ORDER BY id DESC LIMIT 50", (default_user_id(), pid or 0)).fetchall() if pid else []
|
||||
return ok({"groups": rows, "history": history})
|
||||
|
||||
|
||||
|
||||
@bp.post("/ratio-groups")
|
||||
def ratio_groups_save():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
name = str(data.get("name") or "").strip()
|
||||
if not name:
|
||||
return jsonify({"ok": False, "error": "Missing group name"}), 400
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO ratio_groups(user_id,profile_id,name,min_ratio,max_ratio,seed_time_minutes,min_seed_time_minutes,ignore_private,ignore_active_upload,active_upload_min_bytes,move_path,set_label,action,enabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(user_id,profile_id,name) DO UPDATE SET min_ratio=excluded.min_ratio,max_ratio=excluded.max_ratio,seed_time_minutes=excluded.seed_time_minutes,min_seed_time_minutes=excluded.min_seed_time_minutes,ignore_private=excluded.ignore_private,ignore_active_upload=excluded.ignore_active_upload,active_upload_min_bytes=excluded.active_upload_min_bytes,move_path=excluded.move_path,set_label=excluded.set_label,action=excluded.action,enabled=excluded.enabled,updated_at=excluded.updated_at""",
|
||||
(default_user_id(), profile["id"], name, float(data.get("min_ratio") or 1), float(data.get("max_ratio") or 2), int(data.get("seed_time_minutes") or 0), int(data.get("min_seed_time_minutes") or 0), 1 if data.get("ignore_private", True) else 0, 1 if data.get("ignore_active_upload", True) else 0, int(data.get("active_upload_min_bytes") or 1024), data.get("move_path") or "", data.get("set_label") or "", data.get("action") or "stop", 1 if data.get("enabled", True) else 0, now, now),
|
||||
)
|
||||
return ratio_groups_list()
|
||||
|
||||
|
||||
|
||||
@bp.post("/ratio-groups/check")
|
||||
def ratio_groups_check():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
return ok({"result": ratio_rules.check(profile, default_user_id())})
|
||||
|
||||
|
||||
82
pytorrent/routes/rss.py
Normal file
82
pytorrent/routes/rss.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
|
||||
@bp.get("/rss")
|
||||
def rss_list():
|
||||
profile = preferences.active_profile()
|
||||
pid = profile["id"] if profile else None
|
||||
with connect() as conn:
|
||||
feeds = conn.execute("SELECT * FROM rss_feeds WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY name", (default_user_id(), pid)).fetchall()
|
||||
rules = conn.execute("SELECT * FROM rss_rules WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY name", (default_user_id(), pid)).fetchall()
|
||||
history = conn.execute("SELECT * FROM rss_history WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY id DESC LIMIT 80", (default_user_id(), pid)).fetchall()
|
||||
return ok({"feeds": feeds, "rules": rules, "history": history})
|
||||
|
||||
|
||||
|
||||
@bp.post("/rss/feeds")
|
||||
def rss_feed_save():
|
||||
profile = preferences.active_profile()
|
||||
data = request.get_json(silent=True) or {}
|
||||
now = utcnow()
|
||||
feed_id = data.get("id")
|
||||
with connect() as conn:
|
||||
if feed_id:
|
||||
conn.execute("UPDATE rss_feeds SET name=?,url=?,enabled=?,interval_minutes=?,updated_at=? WHERE id=? AND user_id=?", (data.get("name") or "RSS", data.get("url") or "", 1 if data.get("enabled", True) else 0, int(data.get("interval_minutes") or 30), now, feed_id, default_user_id()))
|
||||
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))
|
||||
return rss_list()
|
||||
|
||||
|
||||
|
||||
@bp.delete("/rss/feeds/<int:feed_id>")
|
||||
def rss_feed_delete(feed_id: int):
|
||||
with connect() as conn:
|
||||
conn.execute("DELETE FROM rss_feeds WHERE id=? AND user_id=?", (feed_id, default_user_id()))
|
||||
return rss_list()
|
||||
|
||||
|
||||
|
||||
@bp.post("/rss/rules")
|
||||
def rss_rule_save():
|
||||
profile = preferences.active_profile()
|
||||
data = request.get_json(silent=True) or {}
|
||||
now = utcnow()
|
||||
rule_id = data.get("id")
|
||||
values = (data.get("name") or "Rule", data.get("pattern") or ".*", data.get("exclude_pattern") or "", int(data.get("min_size_mb") or 0), int(data.get("max_size_mb") or 0), data.get("category") or "", data.get("quality") or "", data.get("season") or None, data.get("episode") or None, data.get("save_path") or active_default_download_path(profile), data.get("label") or "", 1 if data.get("start", True) else 0, 1 if data.get("enabled", True) else 0, now)
|
||||
with connect() as conn:
|
||||
if rule_id:
|
||||
conn.execute("UPDATE rss_rules SET name=?,pattern=?,exclude_pattern=?,min_size_mb=?,max_size_mb=?,category=?,quality=?,season=?,episode=?,save_path=?,label=?,start=?,enabled=?,updated_at=? WHERE id=? AND user_id=?", (*values, rule_id, default_user_id()))
|
||||
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))
|
||||
return rss_list()
|
||||
|
||||
|
||||
|
||||
@bp.delete("/rss/rules/<int:rule_id>")
|
||||
def rss_rule_delete(rule_id: int):
|
||||
with connect() as conn:
|
||||
conn.execute("DELETE FROM rss_rules WHERE id=? AND user_id=?", (rule_id, default_user_id()))
|
||||
return rss_list()
|
||||
|
||||
|
||||
|
||||
@bp.post("/rss/rules/test")
|
||||
def rss_rule_test():
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
result = rss_service.test_rule(str(data.get("feed_url") or ""), data.get("rule") or data)
|
||||
return ok({"result": result})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.post("/rss/check")
|
||||
def rss_check():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
return ok(rss_service.check(profile, default_user_id(), only_due=False))
|
||||
|
||||
|
||||
90
pytorrent/routes/smart_queue.py
Normal file
90
pytorrent/routes/smart_queue.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
|
||||
@bp.get('/smart-queue')
|
||||
def smart_queue_get():
|
||||
from ..services import smart_queue
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return ok({'settings': {}, 'exclusions': [], 'error': 'No profile'})
|
||||
try:
|
||||
history_limit = max(1, min(int(request.args.get('history_limit', 10) or 10), 100))
|
||||
settings = smart_queue.get_settings(profile['id'])
|
||||
exclusions = smart_queue.list_exclusions(profile['id'])
|
||||
history = smart_queue.list_history(profile['id'], limit=history_limit)
|
||||
history_total = smart_queue.count_history(profile['id'])
|
||||
return ok({'settings': settings, 'exclusions': exclusions, 'history': history, 'history_total': history_total, 'cooldown_remaining_seconds': smart_queue.cooldown_remaining(settings), 'refill_remaining_seconds': smart_queue.refill_remaining(settings)})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc), 'settings': {}, 'exclusions': []})
|
||||
|
||||
|
||||
|
||||
@bp.post('/smart-queue')
|
||||
def smart_queue_save():
|
||||
from ..services import smart_queue
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return ok({'settings': {}, 'error': 'No profile'})
|
||||
try:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
settings = smart_queue.save_settings(profile['id'], payload)
|
||||
return ok({'settings': settings, 'cooldown_remaining_seconds': smart_queue.cooldown_remaining(settings), 'refill_remaining_seconds': smart_queue.refill_remaining(settings)})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc)})
|
||||
|
||||
|
||||
|
||||
@bp.post('/smart-queue/check')
|
||||
def smart_queue_check():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return ok({'result': {'ok': False, 'error': 'No profile'}})
|
||||
if str(request.args.get('sync') or '').lower() in {'1', 'true', 'yes'}:
|
||||
from ..services import smart_queue
|
||||
try:
|
||||
result = smart_queue.check(profile, force=True)
|
||||
diff = torrent_cache.refresh(profile)
|
||||
rows = torrent_cache.snapshot(profile['id'])
|
||||
return ok({'result': result, 'torrent_patch': {**diff, 'summary': cached_summary(profile['id'], rows, force=True)}})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc)}), 500
|
||||
try:
|
||||
job_id = enqueue(
|
||||
'smart_queue_check',
|
||||
int(profile['id']),
|
||||
{'job_context': {'source': 'user', 'bulk_label': 'Smart Queue manual check'}},
|
||||
force=True,
|
||||
max_attempts=1,
|
||||
)
|
||||
return ok({'queued': True, 'job_id': job_id, 'result': {'ok': True, 'queued': True, 'job_id': job_id}})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc)}), 500
|
||||
|
||||
|
||||
|
||||
@bp.post('/smart-queue/exclusion')
|
||||
def smart_queue_exclusion():
|
||||
from ..services import smart_queue
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
torrent_hash = str(data.get('hash') or '').strip()
|
||||
if not torrent_hash:
|
||||
return jsonify({'ok': False, 'error': 'Missing torrent hash'}), 400
|
||||
smart_queue.set_exclusion(profile['id'], torrent_hash, bool(data.get('excluded', True)), str(data.get('reason') or 'manual'))
|
||||
return ok({'exclusions': smart_queue.list_exclusions(profile['id'])})
|
||||
|
||||
@bp.delete('/smart-queue/history')
|
||||
def smart_queue_history_clear():
|
||||
from ..services import smart_queue
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
removed = smart_queue.clear_history(profile['id'])
|
||||
return ok({'removed': removed, 'history': [], 'history_total': 0})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc)}), 500
|
||||
|
||||
378
pytorrent/routes/system.py
Normal file
378
pytorrent/routes/system.py
Normal file
@@ -0,0 +1,378 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
|
||||
@bp.get("/system/disk")
|
||||
def system_disk():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"})
|
||||
try:
|
||||
return ok({"disk": _user_disk_status(profile)})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)})
|
||||
|
||||
|
||||
|
||||
@bp.get("/system/status")
|
||||
def system_status():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"})
|
||||
try:
|
||||
status = rtorrent.system_status(profile)
|
||||
status["disk"] = _user_disk_status(profile)
|
||||
if bool(profile.get("is_remote")):
|
||||
try:
|
||||
# Note: Remote profiles must report CPU/RAM from the rTorrent host, not hide the footer stats.
|
||||
usage = rtorrent.remote_system_usage(profile)
|
||||
status.update(usage)
|
||||
status["usage_available"] = True
|
||||
except Exception as exc:
|
||||
status["usage_source"] = "rtorrent-remote"
|
||||
status["usage_available"] = False
|
||||
status["usage_error"] = str(exc)
|
||||
else:
|
||||
status["cpu"] = psutil.cpu_percent(interval=None)
|
||||
status["ram"] = psutil.virtual_memory().percent
|
||||
status["usage_source"] = "local"
|
||||
status["usage_available"] = True
|
||||
# Note: REST status returns the latest records without waiting for the next Socket.IO message.
|
||||
status["speed_peaks"] = speed_peaks.record(profile["id"], status.get("down_rate", 0), status.get("up_rate", 0))
|
||||
return ok({"status": status})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)})
|
||||
|
||||
|
||||
|
||||
@bp.get("/health")
|
||||
def health_check():
|
||||
# Note: Lightweight health endpoint avoids rTorrent calls, making it safe for frequent monitoring.
|
||||
try:
|
||||
with connect() as conn:
|
||||
conn.execute("SELECT 1").fetchone()
|
||||
return ok({"status": "ok"})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "status": "error", "error": str(exc)}), 500
|
||||
|
||||
|
||||
@bp.get("/health/nagios")
|
||||
def health_check_nagios():
|
||||
# Note: Plain-text response is compatible with simple Nagios check_http probes.
|
||||
try:
|
||||
with connect() as conn:
|
||||
conn.execute("SELECT 1").fetchone()
|
||||
return "OK - pyTorrent API healthy\n", 200, {"Content-Type": "text/plain; charset=utf-8"}
|
||||
except Exception as exc:
|
||||
return f"CRITICAL - pyTorrent API unhealthy: {exc}\n", 500, {"Content-Type": "text/plain; charset=utf-8"}
|
||||
|
||||
|
||||
@bp.get("/app/status")
|
||||
def app_status():
|
||||
started = time.perf_counter()
|
||||
profile = preferences.active_profile()
|
||||
proc = psutil.Process(os.getpid())
|
||||
try:
|
||||
jobs = list_jobs(10, 0)
|
||||
jobs_total = jobs.get("total", 0)
|
||||
except Exception:
|
||||
jobs_total = 0
|
||||
status = {
|
||||
"pytorrent": {
|
||||
"ok": True,
|
||||
"pid": os.getpid(),
|
||||
"uptime_seconds": round(time.time() - proc.create_time(), 1),
|
||||
"memory_rss": proc.memory_info().rss,
|
||||
"memory_rss_h": rtorrent.human_size(proc.memory_info().rss),
|
||||
"threads": proc.num_threads(),
|
||||
"cpu_percent": proc.cpu_percent(interval=None),
|
||||
"jobs_total": jobs_total,
|
||||
"python": platform.python_version(),
|
||||
"platform": platform.platform(),
|
||||
"executable": sys.executable,
|
||||
"worker_threads": WORKERS,
|
||||
"open_files": _safe_len(proc.open_files) if hasattr(proc, "open_files") else None,
|
||||
"connections": _safe_len(lambda: proc.net_connections(kind="inet")) if hasattr(proc, "net_connections") else None,
|
||||
},
|
||||
"cleanup": cleanup_summary(),
|
||||
"profile": profile,
|
||||
"scgi": None,
|
||||
}
|
||||
if profile:
|
||||
try:
|
||||
status["scgi"] = rtorrent.scgi_diagnostics(profile)
|
||||
except Exception as exc:
|
||||
status["scgi"] = {"ok": False, "error": str(exc), "url": profile.get("scgi_url")}
|
||||
try:
|
||||
# Note: The diagnostics panel shows the same DL/UL records as the footer.
|
||||
status["speed_peaks"] = speed_peaks.current(profile["id"])
|
||||
except Exception as exc:
|
||||
status["speed_peaks"] = {"error": str(exc)}
|
||||
try:
|
||||
prefs = preferences.get_preferences()
|
||||
status["port_check"] = {"status": "disabled", "enabled": False} if not bool((prefs or {}).get("port_check_enabled")) else port_check_status(force=False)
|
||||
except Exception as exc:
|
||||
status["port_check"] = {"status": "error", "error": str(exc)}
|
||||
status["api_ms"] = round((time.perf_counter() - started) * 1000, 2)
|
||||
return ok({"status": status})
|
||||
|
||||
|
||||
|
||||
@bp.get("/port-check")
|
||||
def port_check_get():
|
||||
prefs = preferences.get_preferences()
|
||||
if not bool((prefs or {}).get("port_check_enabled")):
|
||||
return ok({"port_check": {"status": "disabled", "enabled": False}})
|
||||
return ok({"port_check": port_check_status(force=False)})
|
||||
|
||||
|
||||
|
||||
@bp.post("/port-check")
|
||||
def port_check_post():
|
||||
return ok({"port_check": port_check_status(force=True)})
|
||||
|
||||
|
||||
|
||||
@bp.get("/jobs")
|
||||
def jobs_list():
|
||||
limit = int(request.args.get("limit", 50))
|
||||
offset = int(request.args.get("offset", 0))
|
||||
data = list_jobs(limit, offset)
|
||||
return ok({"jobs": data["rows"], "total": data["total"], "limit": data["limit"], "offset": data["offset"]})
|
||||
|
||||
|
||||
|
||||
@bp.post("/jobs/clear")
|
||||
def jobs_clear():
|
||||
if str(request.args.get("force") or "").lower() in {"1", "true", "yes"}:
|
||||
# Note: Emergency cleanup keeps the endpoint behavior unchanged, while force=1 enables rescue mode.
|
||||
deleted = emergency_clear_jobs()
|
||||
return ok({"deleted": deleted, "emergency": True})
|
||||
deleted = clear_jobs()
|
||||
return ok({"deleted": deleted, "emergency": False})
|
||||
|
||||
|
||||
|
||||
@bp.get("/cleanup/summary")
|
||||
def cleanup_status():
|
||||
return ok({"cleanup": cleanup_summary()})
|
||||
|
||||
|
||||
|
||||
@bp.post("/cleanup/cache")
|
||||
def cleanup_profile_cache():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
profile_id = int(profile["id"])
|
||||
deleted: dict[str, int | dict] = {}
|
||||
# Note: Profile cache cleanup removes derived cache only. Torrents, preferences, rules and history stay intact.
|
||||
deleted["torrent_cache_rows"] = torrent_cache.clear_profile(profile_id)
|
||||
try:
|
||||
from ..services.torrent_summary import invalidate_summary
|
||||
invalidate_summary(profile_id)
|
||||
deleted["torrent_summary"] = 1
|
||||
except Exception:
|
||||
deleted["torrent_summary"] = 0
|
||||
try:
|
||||
runtime = rtorrent.clear_profile_runtime_caches(profile_id)
|
||||
except Exception as exc:
|
||||
runtime = {"error": str(exc)}
|
||||
deleted["runtime"] = runtime
|
||||
with connect() as conn:
|
||||
exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='torrent_stats_cache'").fetchone()
|
||||
deleted["torrent_stats_cache"] = int((conn.execute("DELETE FROM torrent_stats_cache WHERE profile_id=?", (profile_id,)).rowcount if exists else 0) or 0)
|
||||
exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='tracker_summary_cache'").fetchone()
|
||||
deleted["tracker_summary_cache"] = int((conn.execute("DELETE FROM tracker_summary_cache WHERE profile_id=?", (profile_id,)).rowcount if exists else 0) or 0)
|
||||
conn.execute("DELETE FROM app_settings WHERE key LIKE ?", (f"port_check:{profile_id}:%",))
|
||||
return ok({"deleted": deleted, "cleanup": cleanup_summary()})
|
||||
|
||||
|
||||
@bp.post("/cleanup/jobs")
|
||||
def cleanup_jobs():
|
||||
deleted = clear_jobs()
|
||||
return ok({"deleted": deleted, "cleanup": cleanup_summary()})
|
||||
|
||||
|
||||
|
||||
@bp.post("/cleanup/smart-queue")
|
||||
def cleanup_smart_queue():
|
||||
with connect() as conn:
|
||||
exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='smart_queue_history'").fetchone()
|
||||
if not exists:
|
||||
deleted = 0
|
||||
else:
|
||||
cur = conn.execute("DELETE FROM smart_queue_history")
|
||||
deleted = int(cur.rowcount or 0)
|
||||
return ok({"deleted": deleted, "cleanup": cleanup_summary()})
|
||||
|
||||
|
||||
|
||||
@bp.post("/cleanup/planner")
|
||||
def cleanup_planner():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
# Note: Planner cleanup removes only the active profile action history, not saved Planner settings.
|
||||
deleted = download_planner.clear_history(int(profile["id"]))
|
||||
return ok({"deleted": deleted, "cleanup": cleanup_summary()})
|
||||
|
||||
|
||||
@bp.post("/cleanup/automations")
|
||||
def cleanup_automations():
|
||||
with connect() as conn:
|
||||
exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='automation_history'").fetchone()
|
||||
if not exists:
|
||||
deleted = 0
|
||||
else:
|
||||
# Note: Cleanup panel removes only automation logs, not saved automation rules.
|
||||
cur = conn.execute("DELETE FROM automation_history")
|
||||
deleted = int(cur.rowcount or 0)
|
||||
return ok({"deleted": deleted, "cleanup": cleanup_summary()})
|
||||
|
||||
|
||||
|
||||
@bp.post("/cleanup/all")
|
||||
def cleanup_all():
|
||||
deleted_jobs = clear_jobs()
|
||||
active_profile = preferences.active_profile()
|
||||
deleted_planner = download_planner.clear_history(int(active_profile["id"])) if active_profile else 0
|
||||
with connect() as conn:
|
||||
exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='smart_queue_history'").fetchone()
|
||||
if not exists:
|
||||
deleted_smart = 0
|
||||
else:
|
||||
cur = conn.execute("DELETE FROM smart_queue_history")
|
||||
deleted_smart = int(cur.rowcount or 0)
|
||||
exists_auto = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='automation_history'").fetchone()
|
||||
if not exists_auto:
|
||||
deleted_auto = 0
|
||||
else:
|
||||
cur = conn.execute("DELETE FROM automation_history")
|
||||
deleted_auto = int(cur.rowcount or 0)
|
||||
return ok({"deleted": {"jobs": deleted_jobs, "smart_queue_history": deleted_smart, "planner_history": deleted_planner, "automation_history": deleted_auto}, "cleanup": cleanup_summary()})
|
||||
|
||||
|
||||
|
||||
@bp.post("/jobs/<job_id>/cancel")
|
||||
def jobs_cancel(job_id: str):
|
||||
require_profile_write(_job_profile_id(job_id))
|
||||
if not cancel_job(job_id):
|
||||
return jsonify({"ok": False, "error": "Only unfinished jobs can be cancelled"}), 400
|
||||
return ok({"emergency": True})
|
||||
|
||||
|
||||
|
||||
@bp.post("/jobs/<job_id>/force")
|
||||
def jobs_force(job_id: str):
|
||||
require_profile_write(_job_profile_id(job_id))
|
||||
if not force_job(job_id):
|
||||
return jsonify({"ok": False, "error": "Only pending jobs can be forced"}), 400
|
||||
return ok({"job_id": job_id})
|
||||
|
||||
|
||||
@bp.post("/jobs/<job_id>/retry")
|
||||
def jobs_retry(job_id: str):
|
||||
require_profile_write(_job_profile_id(job_id))
|
||||
if not retry_job(job_id):
|
||||
return jsonify({"ok": False, "error": "Only failed or cancelled jobs can be retried"}), 400
|
||||
return ok()
|
||||
|
||||
|
||||
|
||||
@bp.get("/path/default")
|
||||
def path_default():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
return ok({"path": rtorrent.default_download_path(profile)})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.get("/path/browse")
|
||||
def path_browse():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
base = request.args.get("path") or ""
|
||||
try:
|
||||
return ok(rtorrent.browse_path(profile, base))
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.get('/rtorrent-config')
|
||||
def rtorrent_config_get():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
return ok({'config': rtorrent.get_config(profile)})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc)}), 500
|
||||
|
||||
|
||||
@bp.post('/rtorrent-config')
|
||||
def rtorrent_config_save():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
result = rtorrent.set_config(profile, data.get('values') or {}, bool(data.get('apply_now', True)), bool(data.get('apply_on_start')), data.get('clear_keys') or [])
|
||||
if not result.get('ok'):
|
||||
return jsonify({'ok': False, 'error': 'Some settings were not saved', 'result': result}), 400
|
||||
return ok({'result': result})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc)}), 500
|
||||
|
||||
|
||||
|
||||
|
||||
@bp.post('/rtorrent-config/reset')
|
||||
def rtorrent_config_reset():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
# Note: This clears only pyTorrent-saved interface overrides and then reloads live rTorrent values.
|
||||
return ok({'config': rtorrent.reset_config_overrides(profile)})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc)}), 400
|
||||
|
||||
@bp.post('/rtorrent-config/generate')
|
||||
def rtorrent_config_generate():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
return ok({'config_text': rtorrent.generate_config_text(data.get('values') or {})})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc)}), 500
|
||||
|
||||
|
||||
@bp.get('/traffic/history')
|
||||
def traffic_history_get():
|
||||
from ..services import traffic_history
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return ok({'history': {'range': request.args.get('range') or '7d', 'bucket': 'day', 'rows': []}})
|
||||
range_name = request.args.get('range') or '7d'
|
||||
if range_name not in {'15m', '1h', '3h', '6h', '24h', '7d', '30d', '90d'}:
|
||||
range_name = '7d'
|
||||
try:
|
||||
try:
|
||||
from ..services import rtorrent
|
||||
status = rtorrent.system_status(profile)
|
||||
traffic_history.record(profile['id'], status.get('down_rate', 0), status.get('up_rate', 0), status.get('total_down', 0), status.get('total_up', 0), force=True)
|
||||
except Exception:
|
||||
pass
|
||||
return ok({'history': traffic_history.history(profile['id'], range_name)})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc), 'history': {'range': range_name, 'rows': []}})
|
||||
|
||||
585
pytorrent/routes/torrents.py
Normal file
585
pytorrent/routes/torrents.py
Normal file
@@ -0,0 +1,585 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
from ..services import torrent_creator
|
||||
|
||||
@bp.get("/torrents")
|
||||
def torrents():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return ok({"torrents": [], "summary": cached_summary(0, []), "error": "No rTorrent profile"})
|
||||
rows = torrent_cache.snapshot(profile["id"])
|
||||
return ok({
|
||||
"profile_id": profile["id"],
|
||||
"torrents": rows,
|
||||
"summary": cached_summary(profile["id"], rows),
|
||||
"error": torrent_cache.error(profile["id"]),
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
@bp.get("/trackers/summary")
|
||||
def trackers_summary():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return ok({"summary": {"hashes": {}, "trackers": [], "errors": [], "scanned": 0, "pending": 0}, "error": "No profile"})
|
||||
try:
|
||||
# Note: Tracker summary returns cached data immediately; optional warmup scans rTorrent in the background for very large libraries.
|
||||
scan_limit = min(250, max(0, int(request.args.get("scan_limit") or 0)))
|
||||
bg_limit = min(250, max(1, int(request.args.get("bg_limit") or 80)))
|
||||
warm = str(request.args.get("warm") or "").lower() in {"1", "true", "yes"}
|
||||
hashes = [t.get("hash") for t in torrent_cache.snapshot(profile["id"]) if t.get("hash")]
|
||||
prefs = preferences.get_preferences()
|
||||
include_favicons = bool(prefs and prefs.get("tracker_favicons_enabled"))
|
||||
loader = lambda h: rtorrent.torrent_trackers(profile, h)
|
||||
summary = tracker_cache.summary(profile, hashes, loader, scan_limit=scan_limit, include_favicons=include_favicons)
|
||||
if warm and int(summary.get("pending") or 0) > 0:
|
||||
summary["warming"] = tracker_cache.warm_summary_cache(profile, hashes, loader, batch_size=bg_limit)
|
||||
return ok({"summary": summary})
|
||||
except Exception as exc:
|
||||
return ok({"summary": {"hashes": {}, "trackers": [], "errors": [{"error": str(exc)}], "scanned": 0, "pending": 0}, "error": str(exc)})
|
||||
|
||||
|
||||
|
||||
@bp.get("/trackers/favicon/<path:domain>")
|
||||
|
||||
@bp.get("/tracker-favicon/<path:domain>")
|
||||
def tracker_favicon(domain: str):
|
||||
prefs = preferences.get_preferences()
|
||||
force = str(request.args.get("refresh") or "").lower() in {"1", "true", "yes", "force"}
|
||||
# Note: Manual refresh must work from CLI even when tracker favicons are disabled in Preferences.
|
||||
enabled = force or bool(prefs and prefs.get("tracker_favicons_enabled"))
|
||||
static_url = tracker_cache.favicon_public_url(domain, enabled=enabled, create=True, force=force)
|
||||
if static_url:
|
||||
# Note: The API only discovers/cache-warms the icon; the browser receives the file from /static/tracker_favicons/.
|
||||
return redirect(static_url, code=302)
|
||||
cached = tracker_cache.favicon_cache_row(domain)
|
||||
return jsonify({
|
||||
"ok": False,
|
||||
"error": "favicon not found",
|
||||
"domain": tracker_cache.tracker_domain(domain),
|
||||
"enabled": bool(enabled),
|
||||
"cached_error": (cached or {}).get("error") if cached else None,
|
||||
}), 404
|
||||
|
||||
|
||||
|
||||
@bp.get("/trackers/favicon")
|
||||
def tracker_favicon_query():
|
||||
# Note: Query-string alias makes cache warming easier from shell scripts where path routing/proxies may differ.
|
||||
domain = str(request.args.get("domain") or "").strip()
|
||||
if not domain:
|
||||
return jsonify({"ok": False, "error": "domain is required"}), 400
|
||||
return tracker_favicon(domain)
|
||||
|
||||
|
||||
@bp.get("/torrent-stats")
|
||||
def torrent_stats_get():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return ok({"stats": {}, "error": "No profile"})
|
||||
force = str(request.args.get("force") or "").lower() in {"1", "true", "yes"}
|
||||
try:
|
||||
# Note: Heavy file metadata is served from a 15-minute DB cache unless the user explicitly refreshes it.
|
||||
return ok({"stats": torrent_stats.get(profile, force=force)})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/files")
|
||||
def torrent_files(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
return ok({"files": rtorrent.torrent_files(profile, torrent_hash)})
|
||||
|
||||
|
||||
|
||||
@bp.post("/torrents/<torrent_hash>/files/priority")
|
||||
def torrent_file_priority(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 {}
|
||||
files = data.get("files") or []
|
||||
if not isinstance(files, list) or not files:
|
||||
return jsonify({"ok": False, "error": "No files selected"}), 400
|
||||
result = rtorrent.set_file_priorities(profile, torrent_hash, files)
|
||||
status = 207 if result.get("errors") else 200
|
||||
return ok(result), status
|
||||
|
||||
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/files/tree")
|
||||
def torrent_file_tree(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
return ok({"tree": rtorrent.torrent_file_tree(profile, torrent_hash)})
|
||||
|
||||
|
||||
|
||||
@bp.post("/torrents/<torrent_hash>/files/folder-priority")
|
||||
def torrent_folder_priority(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 {}
|
||||
result = rtorrent.set_folder_priority(profile, torrent_hash, str(data.get("path") or ""), int(data.get("priority") or 0))
|
||||
status = 207 if result.get("errors") else 200
|
||||
return ok(result), status
|
||||
|
||||
|
||||
def _attachment_headers(download_name: str, content_type: str = "application/octet-stream") -> dict:
|
||||
safe = Path(download_name or "download.bin").name or "download.bin"
|
||||
return {
|
||||
"Content-Type": content_type,
|
||||
"Content-Disposition": f"attachment; 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()
|
||||
chunks = []
|
||||
for chunk in rtorrent.iter_remote_file_chunks(profile, path):
|
||||
if chunk:
|
||||
chunks.append(bytes(chunk))
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def _send_staged_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)
|
||||
|
||||
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/files/<int:file_index>/download")
|
||||
def torrent_file_download(torrent_hash: str, file_index: int):
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
item = rtorrent.torrent_download_file_info(profile, torrent_hash, file_index)
|
||||
size = int(item.get("size") or 0)
|
||||
headers = _attachment_headers(item.get("download_name") or "file.bin")
|
||||
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)
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
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 _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
|
||||
|
||||
|
||||
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-zip-stream", daemon=True).start()
|
||||
while True:
|
||||
chunk = writer.queue.get()
|
||||
if chunk is None:
|
||||
break
|
||||
yield chunk
|
||||
if errors:
|
||||
raise errors[0]
|
||||
|
||||
|
||||
|
||||
@bp.post("/torrents/<torrent_hash>/files/download.zip")
|
||||
def torrent_files_download_zip(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:
|
||||
items = rtorrent.torrent_download_zip_items(profile, torrent_hash, data.get("indexes") or None)
|
||||
headers = _attachment_headers(f"{torrent_hash[:12]}-files.zip", "application/zip")
|
||||
headers["X-PyTorrent-Download-Mode"] = "rtorrent-stream"
|
||||
return Response(stream_with_context(_stream_torrent_files_zip(profile, items)), headers=headers, direct_passthrough=True)
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/torrent-file")
|
||||
def torrent_file_export(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
item = rtorrent.export_torrent_file(profile, torrent_hash)
|
||||
return _send_staged_file(profile, item["path"], item["download_name"], bool(item.get("local")))
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.post("/torrents/torrent-files.zip")
|
||||
def torrent_files_export_zip():
|
||||
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
|
||||
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 h in hashes:
|
||||
item = rtorrent.export_torrent_file(profile, h)
|
||||
staged_paths.append((item["path"], bool(item.get("local"))))
|
||||
name = Path(item["download_name"]).name or f"{h}.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 as exc:
|
||||
for path, is_local in staged_paths:
|
||||
_cleanup_staged_file(profile, path, is_local)
|
||||
try:
|
||||
Path(tmp.name).unlink()
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/chunks")
|
||||
def torrent_chunks(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
max_cells = min(10000, max(64, int(request.args.get("max_cells") or 2048)))
|
||||
return ok({"chunks": rtorrent.torrent_chunks(profile, torrent_hash, max_cells=max_cells)})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@bp.post("/torrents/<torrent_hash>/chunks/<action_name>")
|
||||
def torrent_chunk_action(torrent_hash: str, action_name: str):
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
# Note: Chunk actions are intentionally limited to rTorrent-safe operations; XML-RPC has no supported single-piece redownload call.
|
||||
result = rtorrent.torrent_chunk_action(profile, torrent_hash, action_name, request.get_json(silent=True) or {})
|
||||
return ok({"result": result, "message": result.get("message") or f"Chunk action {action_name} done"})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/peers")
|
||||
def torrent_peers(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
peers = rtorrent.torrent_peers(profile, torrent_hash)
|
||||
for peer in peers:
|
||||
peer.update(lookup_ip(peer.get("ip", "")))
|
||||
return ok({"peers": peers})
|
||||
|
||||
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/trackers")
|
||||
def torrent_trackers(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
return ok({"trackers": rtorrent.torrent_trackers(profile, torrent_hash)})
|
||||
|
||||
|
||||
|
||||
@bp.post("/torrents/<torrent_hash>/trackers/<action_name>")
|
||||
def torrent_tracker_action(torrent_hash: str, action_name: str):
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
result = rtorrent.tracker_action(profile, torrent_hash, action_name, request.get_json(silent=True) or {})
|
||||
return ok({"result": result, "message": f"Tracker {action_name} via {result.get('method', 'XMLRPC')}"})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.post("/torrents/<action_name>")
|
||||
def torrent_action(action_name: str):
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
allowed = {"start", "pause", "unpause", "stop", "resume", "recheck", "reannounce", "remove", "move", "set_label", "set_ratio_group"}
|
||||
if action_name not in allowed:
|
||||
return jsonify({"ok": False, "error": "Unknown action"}), 400
|
||||
if action_name in {"move", "remove"}:
|
||||
# Note: Large move/remove requests are split into ordered bulk parts; smaller requests keep the old single-job response shape.
|
||||
jobs = enqueue_bulk_parts(profile, action_name, data)
|
||||
first_job_id = jobs[0]["job_id"] if jobs else None
|
||||
total_hashes = sum(int(job.get("hash_count") or 0) for job in jobs)
|
||||
return ok({
|
||||
"job_id": first_job_id,
|
||||
"job_ids": [job["job_id"] for job in jobs],
|
||||
"jobs": jobs,
|
||||
"hash_count": total_hashes,
|
||||
"bulk": total_hashes > 1,
|
||||
"bulk_parts": len(jobs),
|
||||
"chunk_size": MOVE_BULK_MAX_HASHES,
|
||||
})
|
||||
payload = enrich_bulk_payload(profile, action_name, data)
|
||||
job_id = enqueue(action_name, profile["id"], payload)
|
||||
return ok({"job_id": job_id, "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1})
|
||||
|
||||
|
||||
|
||||
@bp.post("/torrents/create")
|
||||
def torrent_create():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
form = request.form if request.content_type and request.content_type.startswith("multipart/form-data") else (request.get_json(silent=True) or {})
|
||||
try:
|
||||
created = torrent_creator.build_torrent(
|
||||
source_path=form.get("source_path", ""),
|
||||
trackers=form.get("trackers", ""),
|
||||
comment=form.get("comment", ""),
|
||||
source=form.get("source", ""),
|
||||
piece_size_kib=form.get("piece_size_kib", 256),
|
||||
private=str(form.get("private", "0")).lower() in {"1", "true", "on", "yes"},
|
||||
)
|
||||
share = str(form.get("share", "0")).lower() in {"1", "true", "on", "yes"}
|
||||
if share:
|
||||
size_check = rtorrent.validate_torrent_upload_size(profile, created["data"], True, created["source_parent"], form.get("label", ""))
|
||||
if not size_check.get("ok"):
|
||||
return jsonify({"ok": False, "error": f"Created torrent is too large for the current rTorrent XML-RPC limit: request {size_check['request_h']} > limit {size_check['limit_h']}. Change {size_check['setting']}.set to e.g. {size_check['suggested_value']} in rTorrent settings.", "xmlrpc_limit": size_check}), 413
|
||||
rtorrent.add_torrent_raw(profile, created["data"], True, created["source_parent"], form.get("label", ""))
|
||||
headers = _attachment_headers(created["filename"], "application/x-bittorrent")
|
||||
headers["Content-Length"] = str(len(created["data"]))
|
||||
headers["X-PyTorrent-Info-Hash"] = created["info_hash"]
|
||||
headers["X-PyTorrent-Create-Message"] = f"Created {created['filename']} ({created['file_count']} file(s))"
|
||||
return Response(created["data"], headers=headers)
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@bp.post("/torrents/add")
|
||||
def torrent_add():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
job_ids = []
|
||||
if request.content_type and request.content_type.startswith("multipart/form-data"):
|
||||
start = request.form.get("start", "1") in {"1", "true", "on", "yes"}
|
||||
directory = request.form.get("directory", "") or active_default_download_path(profile)
|
||||
label = request.form.get("label", "")
|
||||
uris = [x.strip() for x in request.form.get("uris", "").splitlines() if x.strip()]
|
||||
for uri in uris:
|
||||
job_ids.append(enqueue("add_magnet", profile["id"], {"uri": uri, "start": start, "directory": directory, "label": label}))
|
||||
existing_hashes = {str(t.get("hash") or "").upper() for t in torrent_cache.snapshot(profile["id"])}
|
||||
try:
|
||||
priority_payload = json.loads(request.form.get("file_priorities") or "{}")
|
||||
except Exception:
|
||||
priority_payload = {}
|
||||
allow_duplicates = request.form.get("allow_duplicates", "0") in {"1", "true", "on", "yes"}
|
||||
skipped_duplicates = []
|
||||
for uploaded in request.files.getlist("files"):
|
||||
raw = uploaded.read()
|
||||
meta = parse_torrent(raw)
|
||||
info_hash = str(meta.get("info_hash") or "").upper()
|
||||
filename = uploaded.filename or meta.get("name") or info_hash
|
||||
if info_hash and info_hash in existing_hashes and not allow_duplicates:
|
||||
skipped_duplicates.append({"filename": filename, "info_hash": info_hash})
|
||||
continue
|
||||
file_priorities = []
|
||||
if isinstance(priority_payload, dict):
|
||||
file_priorities = priority_payload.get(filename) or priority_payload.get(info_hash) or []
|
||||
elif isinstance(priority_payload, list):
|
||||
file_priorities = priority_payload
|
||||
|
||||
size_check = rtorrent.validate_torrent_upload_size(profile, raw, start, directory, label, file_priorities or None)
|
||||
if not size_check.get("ok"):
|
||||
return jsonify({
|
||||
"ok": False,
|
||||
"error": (
|
||||
f"Torrent file is too large for the current rTorrent XML-RPC limit: "
|
||||
f"request {size_check['request_h']} > limit {size_check['limit_h']}. "
|
||||
f"Change {size_check['setting']}.set to e.g. {size_check['suggested_value']} in rTorrent settings."
|
||||
),
|
||||
"xmlrpc_limit": size_check,
|
||||
}), 413
|
||||
data_b64 = base64.b64encode(raw).decode("ascii")
|
||||
job_ids.append(enqueue("add_torrent_raw", profile["id"], {"filename": filename, "data_b64": data_b64, "start": start, "directory": directory, "label": label, "file_priorities": file_priorities or None}))
|
||||
return ok({"job_ids": job_ids, "skipped_duplicates": skipped_duplicates})
|
||||
data = request.get_json(silent=True) or {}
|
||||
uris = data.get("uris") or []
|
||||
if isinstance(uris, str):
|
||||
uris = [x.strip() for x in uris.splitlines() if x.strip()]
|
||||
for uri in uris:
|
||||
job_ids.append(enqueue("add_magnet", profile["id"], {"uri": uri, "start": data.get("start", True), "directory": data.get("directory", "") or active_default_download_path(profile), "label": data.get("label", "")}))
|
||||
return ok({"job_ids": job_ids})
|
||||
|
||||
|
||||
@bp.post("/torrents/preview")
|
||||
def torrent_preview():
|
||||
profile = preferences.active_profile()
|
||||
existing_hashes = set()
|
||||
if profile:
|
||||
try:
|
||||
existing_hashes = {str(t.get("hash") or "").upper() for t in torrent_cache.snapshot(profile["id"])}
|
||||
except Exception:
|
||||
existing_hashes = set()
|
||||
previews = []
|
||||
xmlrpc_limit = rtorrent.xmlrpc_size_limit(profile) if profile else None
|
||||
try:
|
||||
uploads = request.files.getlist("files") if request.content_type and request.content_type.startswith("multipart/form-data") else []
|
||||
for uploaded in uploads:
|
||||
raw = uploaded.read()
|
||||
meta = parse_torrent(raw)
|
||||
meta["filename"] = uploaded.filename
|
||||
meta["duplicate"] = bool(meta.get("info_hash") and meta["info_hash"].upper() in existing_hashes)
|
||||
if profile:
|
||||
size_check = rtorrent.validate_torrent_upload_size(profile, raw)
|
||||
meta["xmlrpc_request_bytes"] = size_check["request_bytes"]
|
||||
meta["xmlrpc_request_h"] = size_check["request_h"]
|
||||
meta["xmlrpc_too_large"] = not size_check.get("ok")
|
||||
previews.append(meta)
|
||||
return ok({"previews": previews, "xmlrpc_limit": xmlrpc_limit})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.post("/speed/limits")
|
||||
def speed_limits():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
job_id = enqueue("set_limits", profile["id"], {"down": data.get("down"), "up": data.get("up")})
|
||||
return ok({"job_id": job_id})
|
||||
|
||||
|
||||
def _user_disk_status(profile: dict) -> dict:
|
||||
# Note: Disk usage is user-preference aware, so it is read separately from the shared Socket.IO poller.
|
||||
prefs = preferences.get_disk_monitor_preferences(profile.get("id") if profile else None)
|
||||
try:
|
||||
paths = json.loads((prefs or {}).get("disk_monitor_paths_json") or "[]") if prefs else []
|
||||
except Exception:
|
||||
paths = []
|
||||
return rtorrent.disk_usage_for_paths(
|
||||
profile,
|
||||
paths,
|
||||
(prefs or {}).get("disk_monitor_mode") or "default",
|
||||
(prefs or {}).get("disk_monitor_selected_path") or "",
|
||||
)
|
||||
|
||||
|
||||
489
pytorrent/services/auth.py
Normal file
489
pytorrent/services/auth.py
Normal file
@@ -0,0 +1,489 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import wraps
|
||||
from typing import Any
|
||||
import secrets
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import abort, g, jsonify, redirect, request, session, url_for
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
from ..config import AUTH_ENABLE
|
||||
from ..db import connect, default_user_id, utcnow
|
||||
|
||||
PUBLIC_ENDPOINTS = {"main.login", "main.logout", "api.auth_login", "api.auth_me", "static"}
|
||||
RTORRENT_WRITE_PREFIXES = (
|
||||
"/api/torrents/",
|
||||
"/api/speed/limits",
|
||||
"/api/labels",
|
||||
"/api/ratio-groups",
|
||||
"/api/rss",
|
||||
"/api/smart-queue",
|
||||
"/api/automations",
|
||||
"/api/jobs",
|
||||
)
|
||||
RTORRENT_CONFIG_PREFIXES = ("/api/rtorrent-config",)
|
||||
ADMIN_PREFIXES = ("/api/auth/users", "/api/profiles")
|
||||
# Note: API reads that expose rTorrent/profile data must also respect profile permissions.
|
||||
PROFILE_READ_PREFIXES = (
|
||||
"/api/torrents",
|
||||
"/api/torrent-stats",
|
||||
"/api/system/status",
|
||||
"/api/app/status",
|
||||
"/api/port-check",
|
||||
"/api/path",
|
||||
"/api/labels",
|
||||
"/api/ratio-groups",
|
||||
"/api/rss",
|
||||
"/api/rtorrent-config",
|
||||
"/api/smart-queue",
|
||||
"/api/traffic/history",
|
||||
"/api/automations",
|
||||
)
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
return bool(AUTH_ENABLE)
|
||||
|
||||
|
||||
def password_hash(password: str) -> str:
|
||||
return generate_password_hash(password or "")
|
||||
|
||||
|
||||
def current_user_id() -> int:
|
||||
if not enabled():
|
||||
return default_user_id()
|
||||
api_user_id = getattr(g, "api_user_id", None)
|
||||
if api_user_id:
|
||||
return int(api_user_id)
|
||||
try:
|
||||
return int(session.get("user_id") or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def current_user() -> dict[str, Any] | None:
|
||||
uid = current_user_id()
|
||||
if not uid:
|
||||
return None
|
||||
with connect() as conn:
|
||||
return conn.execute(
|
||||
"SELECT id, username, role, is_active, created_at, updated_at FROM users WHERE id=?",
|
||||
(uid,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def is_admin(user: dict[str, Any] | None = None) -> bool:
|
||||
if not enabled():
|
||||
return True
|
||||
user = user or current_user()
|
||||
return bool(user and user.get("role") == "admin" and int(user.get("is_active") or 0))
|
||||
|
||||
|
||||
def _permissions(user_id: int | None = None) -> list[dict[str, Any]]:
|
||||
if not enabled():
|
||||
return [{"profile_id": 0, "access_level": "full"}]
|
||||
uid = user_id or current_user_id()
|
||||
if not uid:
|
||||
return []
|
||||
with connect() as conn:
|
||||
return conn.execute(
|
||||
"SELECT profile_id, access_level FROM user_profile_permissions WHERE user_id=?",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
|
||||
|
||||
def can_access_profile(profile_id: int | None, user_id: int | None = None) -> bool:
|
||||
if not enabled():
|
||||
return True
|
||||
uid = user_id or current_user_id()
|
||||
if not uid:
|
||||
return False
|
||||
with connect() as conn:
|
||||
user = conn.execute("SELECT role, is_active FROM users WHERE id=?", (uid,)).fetchone()
|
||||
if not user or not int(user.get("is_active") or 0):
|
||||
return False
|
||||
if user.get("role") == "admin":
|
||||
return True
|
||||
pid = int(profile_id or 0)
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM user_profile_permissions WHERE user_id=? AND (profile_id=0 OR profile_id=?) LIMIT 1",
|
||||
(uid, pid),
|
||||
).fetchone()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def can_write_profile(profile_id: int | None, user_id: int | None = None) -> bool:
|
||||
if not enabled():
|
||||
return True
|
||||
uid = user_id or current_user_id()
|
||||
if not uid:
|
||||
return False
|
||||
with connect() as conn:
|
||||
user = conn.execute("SELECT role, is_active FROM users WHERE id=?", (uid,)).fetchone()
|
||||
if not user or not int(user.get("is_active") or 0):
|
||||
return False
|
||||
if user.get("role") == "admin":
|
||||
return True
|
||||
pid = int(profile_id or 0)
|
||||
row = conn.execute(
|
||||
"SELECT access_level FROM user_profile_permissions WHERE user_id=? AND (profile_id=0 OR profile_id=?) ORDER BY profile_id DESC LIMIT 1",
|
||||
(uid, pid),
|
||||
).fetchone()
|
||||
return bool(row and row.get("access_level") == "full")
|
||||
|
||||
|
||||
def visible_profile_ids(user_id: int | None = None) -> set[int] | None:
|
||||
if not enabled():
|
||||
return None
|
||||
uid = user_id or current_user_id()
|
||||
if not uid:
|
||||
return set()
|
||||
with connect() as conn:
|
||||
user = conn.execute("SELECT role, is_active FROM users WHERE id=?", (uid,)).fetchone()
|
||||
if not user or not int(user.get("is_active") or 0):
|
||||
return set()
|
||||
if user.get("role") == "admin":
|
||||
return None
|
||||
rows = conn.execute("SELECT profile_id FROM user_profile_permissions WHERE user_id=?", (uid,)).fetchall()
|
||||
if any(int(row.get("profile_id") or 0) == 0 for row in rows):
|
||||
return None
|
||||
return {int(row.get("profile_id") or 0) for row in rows}
|
||||
|
||||
|
||||
|
||||
def same_origin_request() -> bool:
|
||||
"""Return False only when an unsafe request clearly comes from another origin."""
|
||||
origin = request.headers.get("Origin") or request.headers.get("Referer")
|
||||
if not origin:
|
||||
return True
|
||||
try:
|
||||
parsed = urlparse(origin)
|
||||
return parsed.scheme == request.scheme and parsed.netloc == request.host
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def writable_profile_ids(user_id: int | None = None) -> set[int] | None:
|
||||
if not enabled():
|
||||
return None
|
||||
uid = user_id or current_user_id()
|
||||
if not uid:
|
||||
return set()
|
||||
with connect() as conn:
|
||||
user = conn.execute("SELECT role, is_active FROM users WHERE id=?", (uid,)).fetchone()
|
||||
if not user or not int(user.get("is_active") or 0):
|
||||
return set()
|
||||
if user.get("role") == "admin":
|
||||
return None
|
||||
rows = conn.execute("SELECT profile_id FROM user_profile_permissions WHERE user_id=? AND access_level='full'", (uid,)).fetchall()
|
||||
if any(int(row.get("profile_id") or 0) == 0 for row in rows):
|
||||
return None
|
||||
return {int(row.get("profile_id") or 0) for row in rows}
|
||||
|
||||
def require_admin() -> None:
|
||||
if enabled() and not is_admin():
|
||||
abort(403)
|
||||
|
||||
|
||||
def require_profile_read(profile_id: int | None) -> None:
|
||||
if enabled() and not can_access_profile(profile_id):
|
||||
abort(403)
|
||||
|
||||
|
||||
def require_profile_write(profile_id: int | None) -> None:
|
||||
if enabled() and not can_write_profile(profile_id):
|
||||
abort(403)
|
||||
|
||||
|
||||
def login_user(username: str, password: str) -> dict[str, Any] | None:
|
||||
if not enabled():
|
||||
return {"id": default_user_id(), "username": "default", "role": "admin", "is_active": 1}
|
||||
with connect() as conn:
|
||||
user = conn.execute("SELECT * FROM users WHERE username=?", (username.strip(),)).fetchone()
|
||||
if not user or not int(user.get("is_active") or 0):
|
||||
return None
|
||||
if not user.get("password_hash") or not check_password_hash(user.get("password_hash"), password or ""):
|
||||
return None
|
||||
session.clear()
|
||||
session["user_id"] = int(user["id"])
|
||||
session["username"] = user["username"]
|
||||
session["role"] = user.get("role") or "user"
|
||||
return current_user()
|
||||
|
||||
|
||||
def logout_user() -> None:
|
||||
session.clear()
|
||||
|
||||
|
||||
def ensure_admin_user() -> None:
|
||||
if not enabled():
|
||||
return
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT id FROM users WHERE username='admin'").fetchone()
|
||||
if not row:
|
||||
conn.execute(
|
||||
"INSERT INTO users(username,password_hash,role,is_active,created_at,updated_at) VALUES(?,?,?,?,?,?)",
|
||||
("admin", password_hash("admin"), "admin", 1, now, now),
|
||||
)
|
||||
else:
|
||||
conn.execute("UPDATE users SET role='admin', is_active=1, updated_at=? WHERE username='admin'", (now,))
|
||||
|
||||
|
||||
def list_users() -> list[dict[str, Any]]:
|
||||
require_admin()
|
||||
with connect() as conn:
|
||||
users = conn.execute(
|
||||
"SELECT id, username, role, is_active, created_at, updated_at FROM users ORDER BY username COLLATE NOCASE"
|
||||
).fetchall()
|
||||
perms = conn.execute(
|
||||
"SELECT user_id, profile_id, access_level FROM user_profile_permissions ORDER BY user_id, profile_id"
|
||||
).fetchall()
|
||||
token_counts = conn.execute(
|
||||
"SELECT user_id, COUNT(*) AS token_count FROM api_tokens WHERE revoked_at IS NULL GROUP BY user_id"
|
||||
).fetchall()
|
||||
by_token_user = {int(row["user_id"]): int(row.get("token_count") or 0) for row in token_counts}
|
||||
by_user: dict[int, list[dict[str, Any]]] = {}
|
||||
for perm in perms:
|
||||
by_user.setdefault(int(perm["user_id"]), []).append({
|
||||
"profile_id": int(perm.get("profile_id") or 0),
|
||||
"access_level": perm.get("access_level") or "ro",
|
||||
})
|
||||
for user in users:
|
||||
user["permissions"] = by_user.get(int(user["id"]), [])
|
||||
user["api_tokens"] = by_token_user.get(int(user["id"]), 0)
|
||||
return users
|
||||
|
||||
|
||||
def save_user(data: dict[str, Any], user_id: int | None = None) -> dict[str, Any]:
|
||||
require_admin()
|
||||
now = utcnow()
|
||||
username = str(data.get("username") or "").strip()
|
||||
role = "admin" if data.get("role") == "admin" else "user"
|
||||
is_active = 1 if data.get("is_active", True) else 0
|
||||
if not username:
|
||||
raise ValueError("Username is required")
|
||||
with connect() as conn:
|
||||
if user_id:
|
||||
row = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not row:
|
||||
raise ValueError("User does not exist")
|
||||
conn.execute(
|
||||
"UPDATE users SET username=?, role=?, is_active=?, updated_at=? WHERE id=?",
|
||||
(username, role, is_active, now, user_id),
|
||||
)
|
||||
else:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users(username,password_hash,role,is_active,created_at,updated_at) VALUES(?,?,?,?,?,?)",
|
||||
(username, password_hash(str(data.get("password") or username)), role, is_active, now, now),
|
||||
)
|
||||
user_id = int(cur.lastrowid)
|
||||
if data.get("password"):
|
||||
conn.execute("UPDATE users SET password_hash=?, updated_at=? WHERE id=?", (password_hash(str(data.get("password"))), now, user_id))
|
||||
if role != "admin":
|
||||
conn.execute("DELETE FROM user_profile_permissions WHERE user_id=?", (user_id,))
|
||||
for item in data.get("permissions") or []:
|
||||
profile_id = int(item.get("profile_id") or 0)
|
||||
access = "full" if item.get("access_level") == "full" else "ro"
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO user_profile_permissions(user_id,profile_id,access_level,created_at,updated_at) VALUES(?,?,?,?,?)",
|
||||
(user_id, profile_id, access, now, now),
|
||||
)
|
||||
else:
|
||||
conn.execute("DELETE FROM user_profile_permissions WHERE user_id=?", (user_id,))
|
||||
return conn.execute("SELECT id, username, role, is_active, created_at, updated_at FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
|
||||
|
||||
def delete_user(user_id: int) -> None:
|
||||
require_admin()
|
||||
uid = int(user_id or 0)
|
||||
if uid == current_user_id():
|
||||
raise ValueError("Cannot delete current user")
|
||||
if uid == default_user_id():
|
||||
# Note: The built-in fallback account must stay in the database for auth-disabled and recovery flows.
|
||||
raise ValueError("Cannot delete the default user")
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT username FROM users WHERE id=?", (uid,)).fetchone()
|
||||
if not row:
|
||||
raise ValueError("User does not exist")
|
||||
if str(row.get("username") or "").lower() in {"default", "admin"}:
|
||||
# Note: Protect bootstrap accounts by name as well as by id.
|
||||
raise ValueError("Cannot delete built-in user")
|
||||
conn.execute("DELETE FROM user_profile_permissions WHERE user_id=?", (uid,))
|
||||
conn.execute("UPDATE api_tokens SET revoked_at=COALESCE(revoked_at, ?), updated_at=? WHERE user_id=?", (utcnow(), utcnow(), uid))
|
||||
conn.execute("DELETE FROM users WHERE id=?", (uid,))
|
||||
|
||||
|
||||
|
||||
def _public_user(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"id": int(row["id"]),
|
||||
"username": row.get("username"),
|
||||
"role": row.get("role") or "user",
|
||||
"is_active": int(row.get("is_active") or 0),
|
||||
"created_at": row.get("created_at"),
|
||||
"updated_at": row.get("updated_at"),
|
||||
}
|
||||
|
||||
|
||||
def _token_response(row: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": int(row["id"]),
|
||||
"user_id": int(row["user_id"]),
|
||||
"name": row.get("name") or "API token",
|
||||
"token_prefix": row.get("token_prefix") or "",
|
||||
"last_used_at": row.get("last_used_at"),
|
||||
"created_at": row.get("created_at"),
|
||||
"revoked_at": row.get("revoked_at"),
|
||||
}
|
||||
|
||||
|
||||
def list_api_tokens(user_id: int) -> list[dict[str, Any]]:
|
||||
if not enabled():
|
||||
return []
|
||||
uid = int(user_id or 0)
|
||||
if not uid:
|
||||
return []
|
||||
if not is_admin() and current_user_id() != uid:
|
||||
abort(403)
|
||||
with connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id,user_id,name,token_prefix,last_used_at,created_at,updated_at,revoked_at FROM api_tokens WHERE user_id=? ORDER BY created_at DESC",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
return [_token_response(row) for row in rows]
|
||||
|
||||
|
||||
def create_api_token(user_id: int, name: str = "API token") -> dict[str, Any]:
|
||||
if not enabled():
|
||||
raise ValueError("API tokens are available only when authentication is enabled")
|
||||
uid = int(user_id or 0)
|
||||
if not uid:
|
||||
raise ValueError("User is required")
|
||||
if not is_admin() and current_user_id() != uid:
|
||||
abort(403)
|
||||
clean_name = str(name or "API token").strip()[:80] or "API token"
|
||||
secret = "pt_" + secrets.token_urlsafe(32)
|
||||
prefix = secret[:14]
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
user = conn.execute("SELECT id,is_active FROM users WHERE id=?", (uid,)).fetchone()
|
||||
if not user or not int(user.get("is_active") or 0):
|
||||
raise ValueError("User is inactive or does not exist")
|
||||
cur = conn.execute(
|
||||
"INSERT INTO api_tokens(user_id,name,token_hash,token_prefix,created_at,updated_at) VALUES(?,?,?,?,?,?)",
|
||||
(uid, clean_name, password_hash(secret), prefix, now, now),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT id,user_id,name,token_prefix,last_used_at,created_at,updated_at,revoked_at FROM api_tokens WHERE id=?",
|
||||
(int(cur.lastrowid),),
|
||||
).fetchone()
|
||||
data = _token_response(row)
|
||||
data["token"] = secret
|
||||
return data
|
||||
|
||||
|
||||
def revoke_api_token(user_id: int, token_id: int) -> None:
|
||||
if not enabled():
|
||||
abort(404)
|
||||
uid = int(user_id or 0)
|
||||
tid = int(token_id or 0)
|
||||
if not is_admin() and current_user_id() != uid:
|
||||
abort(403)
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE api_tokens SET revoked_at=COALESCE(revoked_at, ?), updated_at=? WHERE id=? AND user_id=?",
|
||||
(now, now, tid, uid),
|
||||
)
|
||||
|
||||
|
||||
def authenticate_api_token(token: str) -> dict[str, Any] | None:
|
||||
if not enabled():
|
||||
return None
|
||||
raw = str(token or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
prefix = raw[:14]
|
||||
with connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT t.id AS token_id,t.token_hash,t.user_id,u.username,u.role,u.is_active
|
||||
FROM api_tokens t JOIN users u ON u.id=t.user_id
|
||||
WHERE t.revoked_at IS NULL AND t.token_prefix=?""",
|
||||
(prefix,),
|
||||
).fetchall()
|
||||
matched = None
|
||||
for row in rows:
|
||||
if check_password_hash(row.get("token_hash") or "", raw):
|
||||
matched = row
|
||||
break
|
||||
if not matched or not int(matched.get("is_active") or 0):
|
||||
return None
|
||||
conn.execute("UPDATE api_tokens SET last_used_at=?, updated_at=? WHERE id=?", (utcnow(), utcnow(), int(matched["token_id"])))
|
||||
return {"id": int(matched["user_id"]), "username": matched.get("username"), "role": matched.get("role") or "user", "is_active": 1}
|
||||
|
||||
|
||||
def _request_api_token() -> str:
|
||||
header = request.headers.get("Authorization") or ""
|
||||
if header.lower().startswith("bearer "):
|
||||
return header.split(None, 1)[1].strip()
|
||||
return (request.headers.get("X-API-Key") or request.args.get("api_key") or "").strip()
|
||||
|
||||
|
||||
def install_guards(app) -> None:
|
||||
@app.before_request
|
||||
def _auth_guard():
|
||||
if not enabled():
|
||||
return None
|
||||
g.api_token_authenticated = False
|
||||
if request.path.startswith("/api/"):
|
||||
token_user = authenticate_api_token(_request_api_token())
|
||||
if token_user:
|
||||
g.api_user_id = int(token_user["id"])
|
||||
g.api_token_authenticated = True
|
||||
endpoint = request.endpoint or ""
|
||||
if endpoint in PUBLIC_ENDPOINTS or endpoint.startswith("static"):
|
||||
return None
|
||||
if not current_user_id():
|
||||
if request.path.startswith("/api/"):
|
||||
return jsonify({"ok": False, "error": "Authentication required"}), 401
|
||||
return redirect(url_for("main.login", next=request.full_path if request.query_string else request.path))
|
||||
user = current_user()
|
||||
if not user or not int(user.get("is_active") or 0):
|
||||
logout_user()
|
||||
return jsonify({"ok": False, "error": "Authentication required"}), 401 if request.path.startswith("/api/") else redirect(url_for("main.login"))
|
||||
if request.path.startswith("/api/auth/users") and not is_admin(user):
|
||||
return jsonify({"ok": False, "error": "Admin only"}), 403
|
||||
if request.path.startswith(PROFILE_READ_PREFIXES):
|
||||
profile_id = _request_profile_id()
|
||||
if profile_id and not can_access_profile(profile_id):
|
||||
return jsonify({"ok": False, "error": "Profile access denied"}), 403
|
||||
if request.method not in {"GET", "HEAD", "OPTIONS"}:
|
||||
if request.path.startswith("/api/") and not getattr(g, "api_token_authenticated", False) and not same_origin_request():
|
||||
return jsonify({"ok": False, "error": "Cross-origin API request blocked"}), 403
|
||||
if request.path.startswith("/api/profiles") and not request.path.endswith("/activate") and not is_admin(user):
|
||||
return jsonify({"ok": False, "error": "Admin only"}), 403
|
||||
profile_id = _request_profile_id()
|
||||
if request.path.startswith(RTORRENT_CONFIG_PREFIXES) and not can_write_profile(profile_id):
|
||||
return jsonify({"ok": False, "error": "Read-only profile access"}), 403
|
||||
if request.path.startswith(RTORRENT_WRITE_PREFIXES) and not can_write_profile(profile_id):
|
||||
return jsonify({"ok": False, "error": "Read-only profile access"}), 403
|
||||
return None
|
||||
|
||||
|
||||
def _request_profile_id() -> int | None:
|
||||
if request.view_args and request.view_args.get("profile_id"):
|
||||
return int(request.view_args["profile_id"])
|
||||
try:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
if payload.get("profile_id"):
|
||||
return int(payload.get("profile_id"))
|
||||
except Exception:
|
||||
pass
|
||||
from . import preferences
|
||||
profile = preferences.active_profile()
|
||||
return int(profile["id"]) if profile else None
|
||||
382
pytorrent/services/automation_rules.py
Normal file
382
pytorrent/services/automation_rules.py
Normal file
@@ -0,0 +1,382 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
import json
|
||||
from ..db import connect, default_user_id, utcnow
|
||||
from . import rtorrent
|
||||
from .preferences import active_profile
|
||||
from .workers import enqueue
|
||||
|
||||
AUTOMATION_JOB_CHUNK_SIZE = 100
|
||||
AUTOMATION_LIGHT_ACTIONS = {'start', 'stop', 'pause', 'resume', 'set_label'}
|
||||
|
||||
|
||||
|
||||
def _loads(value: str | None, default: Any) -> Any:
|
||||
try: return json.loads(value or '')
|
||||
except Exception: return default
|
||||
|
||||
|
||||
def _ts(value: str | None) -> float:
|
||||
if not value: return 0.0
|
||||
try: return datetime.fromisoformat(str(value).replace('Z', '+00:00')).timestamp()
|
||||
except Exception: return 0.0
|
||||
|
||||
|
||||
def _now_ts() -> float:
|
||||
return datetime.now(timezone.utc).timestamp()
|
||||
|
||||
|
||||
def _label_names(value: str | None) -> list[str]:
|
||||
seen = []
|
||||
for part in str(value or '').replace(';', ',').replace('|', ',').split(','):
|
||||
item = part.strip()
|
||||
if item and item not in seen: seen.append(item)
|
||||
return seen
|
||||
|
||||
|
||||
def _label_value(labels: list[str]) -> str:
|
||||
out = []
|
||||
for label in labels:
|
||||
label = str(label or '').strip()
|
||||
if label and label not in out: out.append(label)
|
||||
return ', '.join(out)
|
||||
|
||||
|
||||
def _rule_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
item = dict(row)
|
||||
item['conditions'] = _loads(item.pop('conditions_json', '[]'), [])
|
||||
item['effects'] = _loads(item.pop('effects_json', '[]'), [])
|
||||
return item
|
||||
|
||||
|
||||
def list_rules(profile_id: int | None = None, user_id: int | None = None) -> list[dict[str, Any]]:
|
||||
user_id = user_id or default_user_id()
|
||||
if profile_id is None:
|
||||
profile = active_profile(); profile_id = int(profile['id']) if profile else None
|
||||
with connect() as conn:
|
||||
rows = conn.execute('SELECT * FROM automation_rules WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY enabled DESC, name COLLATE NOCASE', (user_id, profile_id)).fetchall()
|
||||
rules = [_rule_row(r) for r in rows]
|
||||
if profile_id is not None:
|
||||
with connect() as conn:
|
||||
for rule in rules:
|
||||
row = conn.execute('SELECT last_applied_at FROM automation_rule_state WHERE rule_id=? AND profile_id=? AND torrent_hash=?', (rule['id'], profile_id, '__rule__')).fetchone()
|
||||
last = row.get('last_applied_at') if row else None
|
||||
cooldown = int(rule.get('cooldown_minutes') or 0)
|
||||
remaining = max(0, int((_ts(last) + cooldown * 60) - _now_ts())) if last and cooldown > 0 else 0
|
||||
# Note: Exposes live cooldown timers for the Automations tab without changing rule behavior.
|
||||
rule['last_applied_at'] = last
|
||||
rule['cooldown_remaining_seconds'] = remaining
|
||||
return rules
|
||||
|
||||
|
||||
def get_rule(rule_id: int, profile_id: int, user_id: int | None = None) -> dict[str, Any]:
|
||||
user_id = user_id or default_user_id()
|
||||
with connect() as conn:
|
||||
row = conn.execute('SELECT * FROM automation_rules WHERE id=? AND user_id=? AND profile_id=?', (rule_id, user_id, profile_id)).fetchone()
|
||||
if not row: raise ValueError('Rule not found')
|
||||
return _rule_row(row)
|
||||
|
||||
|
||||
def _portable_rule(rule: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
'name': str(rule.get('name') or 'Automation rule'),
|
||||
'enabled': bool(rule.get('enabled', True)),
|
||||
'cooldown_minutes': max(0, int(rule.get('cooldown_minutes') or 0)),
|
||||
'conditions': list(rule.get('conditions') or []),
|
||||
'effects': list(rule.get('effects') or []),
|
||||
}
|
||||
|
||||
|
||||
def export_rules(profile_id: int, user_id: int | None = None) -> dict[str, Any]:
|
||||
# Note: Export contains only portable rule definitions, never DB ids or execution history.
|
||||
rules = [_portable_rule(rule) for rule in list_rules(profile_id, user_id)]
|
||||
return {'version': 1, 'app': 'pyTorrent', 'exported_at': utcnow(), 'rules': rules}
|
||||
|
||||
|
||||
def import_rules(profile_id: int, payload: dict[str, Any] | list[Any], user_id: int | None = None, replace: bool = False) -> list[dict[str, Any]]:
|
||||
user_id = user_id or default_user_id()
|
||||
raw_rules = payload if isinstance(payload, list) else payload.get('rules', []) if isinstance(payload, dict) else []
|
||||
if not isinstance(raw_rules, list) or not raw_rules:
|
||||
raise ValueError('Import file does not contain automation rules')
|
||||
if replace:
|
||||
with connect() as conn:
|
||||
# Note: Optional replace is profile-scoped; it does not touch other profiles or history tables.
|
||||
conn.execute('DELETE FROM automation_rules WHERE user_id=? AND profile_id=?', (user_id, profile_id))
|
||||
conn.execute('DELETE FROM automation_rule_state WHERE profile_id=?', (profile_id,))
|
||||
imported = []
|
||||
for raw in raw_rules:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
rule = _portable_rule(raw)
|
||||
rule.pop('id', None)
|
||||
imported.append(save_rule(profile_id, rule, user_id))
|
||||
if not imported:
|
||||
raise ValueError('No valid automation rules found')
|
||||
return imported
|
||||
|
||||
|
||||
def save_rule(profile_id: int, data: dict[str, Any], user_id: int | None = None) -> dict[str, Any]:
|
||||
user_id = user_id or default_user_id()
|
||||
name = str(data.get('name') or 'Automation rule').strip() or 'Automation rule'
|
||||
conditions = data.get('conditions') or []
|
||||
effects = data.get('effects') or []
|
||||
if not isinstance(conditions, list) or not conditions: raise ValueError('Rule needs at least one condition')
|
||||
if not isinstance(effects, list) or not effects: raise ValueError('Rule needs at least one effect')
|
||||
cooldown = max(0, int(data.get('cooldown_minutes') or 0))
|
||||
enabled = 1 if data.get('enabled', True) else 0
|
||||
now = utcnow(); rule_id = int(data.get('id') or 0)
|
||||
with connect() as conn:
|
||||
if rule_id:
|
||||
conn.execute('UPDATE automation_rules SET name=?, enabled=?, conditions_json=?, effects_json=?, cooldown_minutes=?, updated_at=? WHERE id=? AND user_id=? AND profile_id=?', (name, enabled, json.dumps(conditions), json.dumps(effects), cooldown, now, rule_id, user_id, profile_id))
|
||||
else:
|
||||
cur = conn.execute('INSERT INTO automation_rules(user_id,profile_id,name,enabled,conditions_json,effects_json,cooldown_minutes,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)', (user_id, profile_id, name, enabled, json.dumps(conditions), json.dumps(effects), cooldown, now, now))
|
||||
rule_id = int(cur.lastrowid)
|
||||
return get_rule(rule_id, profile_id, user_id)
|
||||
|
||||
|
||||
def delete_rule(rule_id: int, profile_id: int, user_id: int | None = None) -> None:
|
||||
user_id = user_id or default_user_id()
|
||||
with connect() as conn:
|
||||
conn.execute('DELETE FROM automation_rules WHERE id=? AND user_id=? AND profile_id=?', (rule_id, user_id, profile_id))
|
||||
conn.execute('DELETE FROM automation_rule_state WHERE rule_id=? AND profile_id=?', (rule_id, profile_id))
|
||||
|
||||
|
||||
def list_history(profile_id: int, user_id: int | None = None, limit: int = 30) -> list[dict[str, Any]]:
|
||||
user_id = user_id or default_user_id()
|
||||
with connect() as conn:
|
||||
return conn.execute('SELECT * FROM automation_history WHERE user_id=? AND profile_id=? ORDER BY created_at DESC LIMIT ?', (user_id, profile_id, max(1, min(int(limit or 30), 100)))).fetchall()
|
||||
|
||||
|
||||
def clear_history(profile_id: int, user_id: int | None = None) -> int:
|
||||
user_id = user_id or default_user_id()
|
||||
with connect() as conn:
|
||||
# Note: Manual automation log cleanup is scoped to the active profile and current user.
|
||||
cur = conn.execute('DELETE FROM automation_history WHERE user_id=? AND profile_id=?', (user_id, profile_id))
|
||||
return int(cur.rowcount or 0)
|
||||
|
||||
|
||||
def _condition_true(t: dict[str, Any], cond: dict[str, Any]) -> bool:
|
||||
typ = str(cond.get('type') or '')
|
||||
if typ == 'completed': return bool(int(t.get('complete') or 0))
|
||||
if typ == 'no_seeds': return int(t.get('seeds') or 0) <= int(cond.get('seeds') or 0)
|
||||
if typ == 'ratio_gte': return float(t.get('ratio') or 0) >= float(cond.get('ratio') or 0)
|
||||
if typ == 'progress_gte': return float(t.get('progress') or 0) >= float(cond.get('progress') or 0)
|
||||
if typ == 'progress_lte': return float(t.get('progress') or 0) <= float(cond.get('progress') or 0)
|
||||
if typ == 'label_missing': return str(cond.get('label') or '').strip() not in _label_names(t.get('label'))
|
||||
if typ == 'label_has': return str(cond.get('label') or '').strip() in _label_names(t.get('label'))
|
||||
if typ == 'status': return str(t.get('status') or '').lower() == str(cond.get('status') or '').lower()
|
||||
if typ == 'path_contains': return str(cond.get('text') or '').lower() in str(t.get('path') or '').lower()
|
||||
return False
|
||||
|
||||
|
||||
def _conditions_match(conn, rule: dict[str, Any], profile_id: int, t: dict[str, Any]) -> bool:
|
||||
h = str(t.get('hash') or '')
|
||||
if not h: return False
|
||||
immediate_ok = True; delayed_ok = True; now = utcnow(); now_ts = _now_ts()
|
||||
for cond in rule.get('conditions') or []:
|
||||
raw_ok = _condition_true(t, cond)
|
||||
negated = bool(cond.get('negate'))
|
||||
# Note: Negation is applied in the backend, so UI and API only store the condition flag.
|
||||
ok = (not raw_ok) if negated else raw_ok
|
||||
if cond.get('type') == 'no_seeds' and int(cond.get('minutes') or 0) > 0 and not negated:
|
||||
row = conn.execute('SELECT condition_since_at FROM automation_rule_state WHERE rule_id=? AND profile_id=? AND torrent_hash=?', (rule['id'], profile_id, h)).fetchone()
|
||||
if ok:
|
||||
since = row['condition_since_at'] if row and row.get('condition_since_at') else now
|
||||
conn.execute('INSERT INTO automation_rule_state(rule_id,profile_id,torrent_hash,condition_since_at,last_matched_at,updated_at) VALUES(?,?,?,?,?,?) ON CONFLICT(rule_id,profile_id,torrent_hash) DO UPDATE SET condition_since_at=COALESCE(automation_rule_state.condition_since_at, excluded.condition_since_at), last_matched_at=excluded.last_matched_at, updated_at=excluded.updated_at', (rule['id'], profile_id, h, since, now, now))
|
||||
delayed_ok = delayed_ok and (now_ts - _ts(since) >= int(cond.get('minutes') or 0) * 60)
|
||||
else:
|
||||
conn.execute('UPDATE automation_rule_state SET condition_since_at=NULL, updated_at=? WHERE rule_id=? AND profile_id=? AND torrent_hash=?', (now, rule['id'], profile_id, h)); delayed_ok = False
|
||||
else:
|
||||
immediate_ok = immediate_ok and ok
|
||||
return immediate_ok and delayed_ok
|
||||
|
||||
|
||||
def _cooldown_ok(conn, rule: dict[str, Any], profile_id: int, torrent_hash: str = '__rule__') -> bool:
|
||||
cooldown = int(rule.get('cooldown_minutes') or 0)
|
||||
if cooldown <= 0: return True
|
||||
row = conn.execute('SELECT last_applied_at FROM automation_rule_state WHERE rule_id=? AND profile_id=? AND torrent_hash=?', (rule['id'], profile_id, torrent_hash)).fetchone()
|
||||
if not row or not row.get('last_applied_at'): return True
|
||||
return _now_ts() - _ts(row['last_applied_at']) >= cooldown * 60
|
||||
|
||||
|
||||
def _mark_rule_cooldown(conn, rule: dict[str, Any], profile_id: int, now: str) -> None:
|
||||
# Note: Cooldown is rule-level, so one batch execution blocks the whole automation until the cooldown expires.
|
||||
conn.execute('INSERT INTO automation_rule_state(rule_id,profile_id,torrent_hash,last_applied_at,updated_at) VALUES(?,?,?,?,?) ON CONFLICT(rule_id,profile_id,torrent_hash) DO UPDATE SET last_applied_at=excluded.last_applied_at, updated_at=excluded.updated_at', (rule['id'], profile_id, '__rule__', now, now))
|
||||
|
||||
|
||||
def _chunk_hashes(hashes: list[str], size: int = AUTOMATION_JOB_CHUNK_SIZE) -> list[list[str]]:
|
||||
# Note: Automation jobs use the same small-batch idea as manual bulk jobs, so long move/remove/actions remain visible and recoverable.
|
||||
safe_size = max(1, int(size or AUTOMATION_JOB_CHUNK_SIZE))
|
||||
return [hashes[index:index + safe_size] for index in range(0, len(hashes), safe_size)]
|
||||
|
||||
|
||||
def _job_context(rule: dict[str, Any], eff_type: str, hashes: list[str], torrents_by_hash: dict[str, dict[str, Any]], extra: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
# Note: Job context marks jobs created by automations, making the Jobs log explain what rule queued the work.
|
||||
ctx = {
|
||||
'source': 'automation',
|
||||
'rule_id': rule.get('id'),
|
||||
'rule_name': str(rule.get('name') or ''),
|
||||
'effect': eff_type,
|
||||
'bulk': len(hashes) > 1,
|
||||
'hash_count': len(hashes),
|
||||
'requested_at': utcnow(),
|
||||
'items': [
|
||||
{
|
||||
'hash': h,
|
||||
'name': str((torrents_by_hash.get(h) or {}).get('name') or ''),
|
||||
'path': str((torrents_by_hash.get(h) or {}).get('path') or ''),
|
||||
}
|
||||
for h in hashes
|
||||
],
|
||||
}
|
||||
if extra:
|
||||
ctx.update(extra)
|
||||
return ctx
|
||||
|
||||
|
||||
def _enqueue_automation_job(profile: dict[str, Any], rule: dict[str, Any], action_name: str, hashes: list[str], payload: dict[str, Any], torrents_by_hash: dict[str, dict[str, Any]], user_id: int | None = None, context_extra: dict[str, Any] | None = None) -> list[str]:
|
||||
# Note: Light automation actions stay in one job; heavy actions are chunked for recoverability.
|
||||
job_ids: list[str] = []
|
||||
chunks = [hashes] if action_name in AUTOMATION_LIGHT_ACTIONS else _chunk_hashes(hashes)
|
||||
for index, chunk in enumerate(chunks, start=1):
|
||||
part_payload = dict(payload or {})
|
||||
part_payload['hashes'] = chunk
|
||||
part_payload['source'] = 'automation'
|
||||
if action_name not in AUTOMATION_LIGHT_ACTIONS:
|
||||
part_payload['requires_order'] = True
|
||||
extra = dict(context_extra or {})
|
||||
if len(chunks) > 1:
|
||||
extra.update({'bulk_label': f'automation-{index}', 'bulk_part': index, 'bulk_parts': len(chunks), 'parent_hash_count': len(hashes)})
|
||||
if action_name == 'move':
|
||||
extra.update({'target_path': str(part_payload.get('path') or ''), 'move_data': bool(part_payload.get('move_data'))})
|
||||
if action_name == 'remove':
|
||||
extra.update({'remove_data': bool(part_payload.get('remove_data'))})
|
||||
part_payload['job_context'] = _job_context(rule, str(context_extra.get('effect_type') if context_extra else action_name), chunk, torrents_by_hash, extra)
|
||||
job_ids.append(enqueue(action_name, int(profile['id']), part_payload, user_id=user_id))
|
||||
return job_ids
|
||||
|
||||
|
||||
def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str, Any]], effects: list[dict[str, Any]], rule: dict[str, Any], user_id: int | None = None) -> list[dict[str, Any]]:
|
||||
hashes = [str(t.get('hash') or '') for t in torrents if str(t.get('hash') or '')]
|
||||
torrents_by_hash = {str(t.get('hash') or ''): t for t in torrents if str(t.get('hash') or '')}
|
||||
labels_by_hash = {str(t.get('hash') or ''): _label_names(t.get('label')) for t in torrents}
|
||||
applied: list[dict[str, Any]] = []
|
||||
if not hashes: return applied
|
||||
for eff in effects:
|
||||
typ = str(eff.get('type') or '')
|
||||
if typ == 'move':
|
||||
path = str(eff.get('path') or '').strip() or rtorrent.default_download_path(profile)
|
||||
payload = {
|
||||
'path': path,
|
||||
'move_data': bool(eff.get('move_data')),
|
||||
'recheck': bool(eff.get('recheck', eff.get('move_data'))),
|
||||
'keep_seeding': bool(eff.get('keep_seeding')),
|
||||
}
|
||||
job_ids = _enqueue_automation_job(profile, rule, 'move', hashes, payload, torrents_by_hash, user_id, {'effect_type': 'move'})
|
||||
applied.append({'type': 'move', 'path': path, 'count': len(hashes), 'target_hashes': hashes, 'move_data': payload['move_data'], 'recheck': payload['recheck'], 'keep_seeding': payload['keep_seeding'], 'job_ids': job_ids})
|
||||
elif typ == 'add_label':
|
||||
label = str(eff.get('label') or '').strip()
|
||||
if label:
|
||||
# Note: Add-label automations are idempotent and queue only torrents that need a changed label value.
|
||||
grouped: dict[str, list[str]] = {}
|
||||
for h in hashes:
|
||||
labels = labels_by_hash.get(h, [])
|
||||
if label in labels:
|
||||
continue
|
||||
new_labels = list(labels) + [label]
|
||||
value = _label_value(new_labels)
|
||||
labels_by_hash[h] = _label_names(value)
|
||||
grouped.setdefault(value, []).append(h)
|
||||
target_hashes = [h for group in grouped.values() for h in group]
|
||||
job_ids: list[str] = []
|
||||
for value, group_hashes in grouped.items():
|
||||
job_ids.extend(_enqueue_automation_job(profile, rule, 'set_label', group_hashes, {'label': value}, torrents_by_hash, user_id, {'effect_type': 'add_label', 'label': label}))
|
||||
if target_hashes:
|
||||
applied.append({'type': 'add_label', 'label': label, 'count': len(target_hashes), 'target_hashes': target_hashes, 'job_ids': job_ids})
|
||||
elif typ == 'remove_label':
|
||||
label = str(eff.get('label') or '').strip()
|
||||
if label:
|
||||
# Note: Remove-label automations are queued only for torrents where the requested label exists.
|
||||
grouped: dict[str, list[str]] = {}
|
||||
for h in hashes:
|
||||
labels = labels_by_hash.get(h, [])
|
||||
if label not in labels:
|
||||
continue
|
||||
value = _label_value([x for x in labels if x != label])
|
||||
labels_by_hash[h] = _label_names(value)
|
||||
grouped.setdefault(value, []).append(h)
|
||||
target_hashes = [h for group in grouped.values() for h in group]
|
||||
job_ids: list[str] = []
|
||||
for value, group_hashes in grouped.items():
|
||||
job_ids.extend(_enqueue_automation_job(profile, rule, 'set_label', group_hashes, {'label': value}, torrents_by_hash, user_id, {'effect_type': 'remove_label', 'label': label}))
|
||||
if target_hashes:
|
||||
applied.append({'type': 'remove_label', 'label': label, 'count': len(target_hashes), 'target_hashes': target_hashes, 'job_ids': job_ids})
|
||||
elif typ == 'set_labels':
|
||||
value = _label_value(_label_names(eff.get('labels')))
|
||||
target_labels = _label_names(value)
|
||||
# Note: Set-labels queues a job only if the current labels differ from the requested exact list.
|
||||
target_hashes = [h for h in hashes if labels_by_hash.get(h, []) != target_labels]
|
||||
for h in target_hashes:
|
||||
labels_by_hash[h] = list(target_labels)
|
||||
if target_hashes:
|
||||
job_ids = _enqueue_automation_job(profile, rule, 'set_label', target_hashes, {'label': value}, torrents_by_hash, user_id, {'effect_type': 'set_labels', 'labels': value})
|
||||
applied.append({'type': 'set_labels', 'labels': value, 'count': len(target_hashes), 'target_hashes': target_hashes, 'job_ids': job_ids})
|
||||
elif typ in {'pause', 'stop', 'start', 'resume', 'recheck', 'reannounce'}:
|
||||
# Note: Runtime actions are queued as jobs too, so automation activity is visible in the Jobs panel.
|
||||
job_ids = _enqueue_automation_job(profile, rule, typ, hashes, {}, torrents_by_hash, user_id, {'effect_type': typ})
|
||||
applied.append({'type': typ, 'count': len(hashes), 'target_hashes': hashes, 'job_ids': job_ids})
|
||||
elif typ == 'remove':
|
||||
# Note: Remove is supported for automation payloads and still goes through ordered worker jobs.
|
||||
payload = {'remove_data': bool(eff.get('remove_data'))}
|
||||
job_ids = _enqueue_automation_job(profile, rule, 'remove', hashes, payload, torrents_by_hash, user_id, {'effect_type': 'remove'})
|
||||
applied.append({'type': 'remove', 'count': len(hashes), 'target_hashes': hashes, 'remove_data': payload['remove_data'], 'job_ids': job_ids})
|
||||
return applied
|
||||
|
||||
|
||||
def check(profile: dict | None = None, user_id: int | None = None, force: bool = False, rule_id: int | None = None) -> dict[str, Any]:
|
||||
profile = profile or active_profile()
|
||||
if not profile: return {'ok': False, 'error': 'No active rTorrent profile'}
|
||||
user_id = user_id or default_user_id(); profile_id = int(profile['id'])
|
||||
rules = [r for r in list_rules(profile_id, user_id) if (rule_id is None or int(r.get('id') or 0) == int(rule_id)) and (force or int(r.get('enabled') or 0))]
|
||||
if not rules: return {'ok': True, 'checked': 0, 'applied': [], 'batches': [], 'rules': 0}
|
||||
torrents = rtorrent.list_torrents(profile); applied = []; batches = []; now = utcnow()
|
||||
planned: list[dict[str, Any]] = []
|
||||
with connect() as conn:
|
||||
for rule in rules:
|
||||
# Note: This pass only matches rules and updates condition timers; job creation is intentionally delayed until after this DB transaction commits.
|
||||
if not force and not _cooldown_ok(conn, rule, profile_id):
|
||||
continue
|
||||
matched = [t for t in torrents if _conditions_match(conn, rule, profile_id, t)]
|
||||
if not matched:
|
||||
continue
|
||||
hashes = [str(t.get('hash') or '') for t in matched if str(t.get('hash') or '')]
|
||||
if hashes:
|
||||
planned.append({'rule': rule, 'matched': matched, 'hashes': hashes})
|
||||
for item in planned:
|
||||
rule = item['rule']
|
||||
matched = item['matched']
|
||||
hashes = item['hashes']
|
||||
# Note: Automation jobs are enqueued outside the rule-state transaction, preventing SQLite self-locks when enqueue() writes to jobs.
|
||||
try:
|
||||
actions = _apply_effects_bulk(None, profile, matched, rule.get('effects') or [], rule, user_id)
|
||||
except Exception as exc:
|
||||
actions = [{'error': str(exc), 'count': len(hashes), 'target_hashes': hashes}]
|
||||
changed_hashes = sorted({h for a in actions for h in (a.get('target_hashes') or [])})
|
||||
if not actions or not changed_hashes:
|
||||
# Note: Matching torrents with no real action are not logged and do not restart the cooldown.
|
||||
continue
|
||||
history_actions = [{k: v for k, v in a.items() if k != 'target_hashes'} for a in actions]
|
||||
matched_by_hash = {str(t.get('hash') or ''): t for t in matched}
|
||||
with connect() as conn:
|
||||
# Note: State/history writes happen after enqueue succeeds, so failed job creation does not create misleading automation history.
|
||||
for h in changed_hashes:
|
||||
t = matched_by_hash.get(h, {})
|
||||
conn.execute('INSERT INTO automation_rule_state(rule_id,profile_id,torrent_hash,last_matched_at,last_applied_at,updated_at) VALUES(?,?,?,?,?,?) ON CONFLICT(rule_id,profile_id,torrent_hash) DO UPDATE SET last_matched_at=excluded.last_matched_at, last_applied_at=excluded.last_applied_at, updated_at=excluded.updated_at', (rule['id'], profile_id, h, now, now, now))
|
||||
applied.append({'rule_id': rule['id'], 'rule_name': rule.get('name'), 'hash': h, 'name': t.get('name'), 'actions': [{'type': a.get('type', 'error'), 'count': a.get('count', len(changed_hashes))} for a in actions]})
|
||||
_mark_rule_cooldown(conn, rule, profile_id, now)
|
||||
torrent_name = str(matched_by_hash.get(changed_hashes[0], {}).get('name') or '') if len(changed_hashes) == 1 else f'{len(changed_hashes)} torrents'
|
||||
torrent_hash = changed_hashes[0] if len(changed_hashes) == 1 else f'batch:{rule["id"]}:{now}'
|
||||
conn.execute('INSERT INTO automation_history(user_id,profile_id,rule_id,torrent_hash,torrent_name,rule_name,actions_json,created_at) VALUES(?,?,?,?,?,?,?,?)', (user_id, profile_id, rule['id'], torrent_hash, torrent_name, str(rule.get('name') or ''), json.dumps(history_actions), now))
|
||||
batches.append({'rule_id': rule['id'], 'rule_name': rule.get('name'), 'count': len(changed_hashes), 'actions': history_actions})
|
||||
return {'ok': True, 'checked': len(torrents), 'rules': len(rules), 'applied': applied, 'batches': batches}
|
||||
286
pytorrent/services/backup.py
Normal file
286
pytorrent/services/backup.py
Normal file
@@ -0,0 +1,286 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from ..db import connect, utcnow, default_user_id
|
||||
|
||||
# Note: Settings backups include persistent configuration tables only; volatile queues, caches, histories and tokens are intentionally skipped.
|
||||
BACKUP_TABLES = [
|
||||
"users", "user_profile_permissions", "user_preferences", "rtorrent_profiles",
|
||||
"disk_monitor_preferences", "labels", "ratio_groups", "rss_feeds", "rss_rules",
|
||||
"smart_queue_settings", "smart_queue_exclusions", "automation_rules",
|
||||
"rtorrent_config_overrides", "app_settings", "download_plan_settings",
|
||||
]
|
||||
|
||||
DEFAULT_AUTO_BACKUP_SETTINGS = {
|
||||
"enabled": False,
|
||||
"interval_hours": 24,
|
||||
"retention_days": 30,
|
||||
"last_run_at": None,
|
||||
}
|
||||
BACKUP_PREVIEW_VALUE_LIMIT = 80
|
||||
BACKUP_PREVIEW_ROW_LIMIT = 3
|
||||
BACKUP_PREVIEW_SENSITIVE_KEYS = {
|
||||
"password",
|
||||
"password_hash",
|
||||
"token",
|
||||
"token_hash",
|
||||
"api_key",
|
||||
"secret",
|
||||
}
|
||||
AUTO_BACKUP_SETTINGS_KEY = "backup:auto"
|
||||
_scheduler_started = False
|
||||
_scheduler_lock = threading.Lock()
|
||||
|
||||
|
||||
def create_backup(name: str, user_id: int | None = None, automatic: bool = False) -> dict:
|
||||
"""Create a settings backup and return a table-count summary.
|
||||
|
||||
Note: The automatic flag is metadata only; restore/download behavior remains unchanged.
|
||||
"""
|
||||
user_id = user_id or default_user_id()
|
||||
payload = {"version": 1, "created_at": utcnow(), "automatic": bool(automatic), "tables": {}}
|
||||
with connect() as conn:
|
||||
for table in BACKUP_TABLES:
|
||||
try:
|
||||
payload["tables"][table] = conn.execute(f"SELECT * FROM {table}").fetchall()
|
||||
except Exception:
|
||||
payload["tables"][table] = []
|
||||
cur = conn.execute(
|
||||
"INSERT INTO app_backups(user_id,name,payload_json,created_at) VALUES(?,?,?,?)",
|
||||
(user_id, name or f"Backup {payload['created_at']}", json.dumps(payload), payload["created_at"]),
|
||||
)
|
||||
backup_id = cur.lastrowid
|
||||
return {"id": backup_id, "name": name, "created_at": payload["created_at"], "automatic": bool(automatic), "tables": {k: len(v) for k, v in payload["tables"].items()}}
|
||||
|
||||
|
||||
def list_backups(user_id: int | None = None) -> list[dict]:
|
||||
user_id = user_id or default_user_id()
|
||||
with connect() as conn:
|
||||
rows = conn.execute("SELECT id,name,created_at,payload_json FROM app_backups WHERE user_id=? ORDER BY id DESC", (user_id,)).fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
payload = _loads(row.get("payload_json") or "{}")
|
||||
tables = payload.get("tables") or {}
|
||||
result.append({
|
||||
"id": row.get("id"),
|
||||
"name": row.get("name"),
|
||||
"created_at": row.get("created_at"),
|
||||
"automatic": bool(payload.get("automatic")),
|
||||
"tables": {key: len(value or []) for key, value in tables.items()},
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def payload_for_backup(backup_id: int, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or default_user_id()
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT payload_json FROM app_backups WHERE id=? AND user_id=?", (backup_id, user_id)).fetchone()
|
||||
if not row:
|
||||
raise ValueError("Backup not found")
|
||||
return json.loads(row["payload_json"] or "{}")
|
||||
|
||||
|
||||
def restore_backup(backup_id: int, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or default_user_id()
|
||||
payload = payload_for_backup(backup_id, user_id)
|
||||
tables = payload.get("tables") or {}
|
||||
restored = {}
|
||||
with connect() as conn:
|
||||
conn.execute("PRAGMA foreign_keys = OFF")
|
||||
try:
|
||||
for table in BACKUP_TABLES:
|
||||
rows = tables.get(table) or []
|
||||
if not rows:
|
||||
continue
|
||||
columns = list(rows[0].keys())
|
||||
placeholders = ",".join("?" for _ in columns)
|
||||
conn.execute(f"DELETE FROM {table}")
|
||||
for row in rows:
|
||||
conn.execute(f"INSERT INTO {table}({','.join(columns)}) VALUES({placeholders})", [row.get(col) for col in columns])
|
||||
restored[table] = len(rows)
|
||||
finally:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return {"restored": restored}
|
||||
|
||||
def delete_backup(backup_id: int, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or default_user_id()
|
||||
with connect() as conn:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM app_backups WHERE id=? AND user_id=?",
|
||||
(backup_id, user_id),
|
||||
)
|
||||
if not cur.rowcount:
|
||||
raise ValueError("Backup not found")
|
||||
return {"deleted": backup_id}
|
||||
|
||||
|
||||
|
||||
|
||||
def _loads(value: str) -> dict:
|
||||
try:
|
||||
data = json.loads(value or "{}")
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _settings_row_key(user_id: int | None = None) -> str:
|
||||
return f"{AUTO_BACKUP_SETTINGS_KEY}:{user_id or default_user_id()}"
|
||||
|
||||
|
||||
def _latest_backup_created_at(user_id: int) -> str | None:
|
||||
"""Return the newest persisted backup timestamp for scheduler recovery after restarts.
|
||||
|
||||
Note: Automatic scheduling is based on the latest database backup record, so process
|
||||
restarts cannot create repeated backups before the configured interval elapses.
|
||||
"""
|
||||
with connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT created_at FROM app_backups WHERE user_id=? ORDER BY created_at DESC, id DESC LIMIT 1",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
return str(row["created_at"] or "") if row and row.get("created_at") else None
|
||||
|
||||
|
||||
def _preview_value(value: object) -> object:
|
||||
"""Return a safe, compact value for backup previews without exposing secrets."""
|
||||
if value is None or isinstance(value, (int, float, bool)):
|
||||
return value
|
||||
text = str(value)
|
||||
return text if len(text) <= BACKUP_PREVIEW_VALUE_LIMIT else f"{text[:BACKUP_PREVIEW_VALUE_LIMIT]}..."
|
||||
|
||||
|
||||
def _preview_row(row: dict) -> dict:
|
||||
output = {}
|
||||
for key, value in row.items():
|
||||
lowered = str(key).lower()
|
||||
if any(secret in lowered for secret in BACKUP_PREVIEW_SENSITIVE_KEYS):
|
||||
output[key] = "[hidden]"
|
||||
else:
|
||||
output[key] = _preview_value(value)
|
||||
return output
|
||||
|
||||
|
||||
def get_auto_backup_settings(user_id: int | None = None) -> dict:
|
||||
"""Return automatic backup schedule settings for the current user.
|
||||
|
||||
Note: The UI uses this as the single source for interval and retention controls.
|
||||
"""
|
||||
key = _settings_row_key(user_id)
|
||||
with connect() as conn:
|
||||
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["enabled"] = bool(settings.get("enabled"))
|
||||
settings["interval_hours"] = max(1, int(settings.get("interval_hours") or 24))
|
||||
settings["retention_days"] = max(1, int(settings.get("retention_days") or 30))
|
||||
return settings
|
||||
|
||||
|
||||
def save_auto_backup_settings(data: dict, user_id: int | None = None) -> dict:
|
||||
"""Persist automatic backup schedule settings after validating UI input.
|
||||
|
||||
Note: Minimum interval is one hour to avoid creating excessive database rows.
|
||||
"""
|
||||
current = get_auto_backup_settings(user_id)
|
||||
settings = {
|
||||
**current,
|
||||
"enabled": bool(data.get("enabled")),
|
||||
"interval_hours": max(1, int(data.get("interval_hours") or current["interval_hours"])),
|
||||
"retention_days": max(1, int(data.get("retention_days") or current["retention_days"])),
|
||||
"last_run_at": data.get("last_run_at", current.get("last_run_at")),
|
||||
}
|
||||
key = _settings_row_key(user_id)
|
||||
with connect() as conn:
|
||||
conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (key, json.dumps(settings)))
|
||||
return settings
|
||||
|
||||
|
||||
def preview_backup(backup_id: int, user_id: int | None = None) -> dict:
|
||||
"""Return a compact backup preview without exposing the full JSON payload in the list view.
|
||||
|
||||
Note: The preview shows included tables and example keys so users can verify settings coverage.
|
||||
"""
|
||||
payload = payload_for_backup(backup_id, user_id)
|
||||
tables = payload.get("tables") or {}
|
||||
return {
|
||||
"version": payload.get("version"),
|
||||
"created_at": payload.get("created_at"),
|
||||
"automatic": bool(payload.get("automatic")),
|
||||
"tables": [
|
||||
{
|
||||
"name": table,
|
||||
"rows": len(rows or []),
|
||||
"columns": list((rows[0] or {}).keys()) if rows else [],
|
||||
"sample": [_preview_row(dict(row)) for row in (rows or [])[:BACKUP_PREVIEW_ROW_LIMIT]],
|
||||
}
|
||||
for table, rows in tables.items()
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def prune_old_backups(user_id: int | None = None, retention_days: int = 30) -> int:
|
||||
"""Delete backups older than the configured retention window for the selected user.
|
||||
|
||||
Note: Retention is applied only to backup records, not to restored application settings.
|
||||
"""
|
||||
user_id = user_id or default_user_id()
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=max(1, int(retention_days)))).isoformat(timespec="seconds")
|
||||
with connect() as conn:
|
||||
cur = conn.execute("DELETE FROM app_backups WHERE user_id=? AND created_at<?", (user_id, cutoff))
|
||||
return int(cur.rowcount or 0)
|
||||
|
||||
|
||||
def maybe_create_automatic_backup(user_id: int | None = None) -> dict | None:
|
||||
"""Create an automatic backup when the saved interval has elapsed.
|
||||
|
||||
Note: The scheduler calls this periodically, while the UI controls the interval and retention values.
|
||||
"""
|
||||
user_id = user_id or default_user_id()
|
||||
settings = get_auto_backup_settings(user_id)
|
||||
if not settings.get("enabled"):
|
||||
return None
|
||||
now = datetime.now(timezone.utc)
|
||||
last_value = settings.get("last_run_at") or _latest_backup_created_at(user_id)
|
||||
try:
|
||||
last = datetime.fromisoformat(str(last_value).replace("Z", "+00:00")) if last_value else None
|
||||
except Exception:
|
||||
last = None
|
||||
if last and now - last < timedelta(hours=settings["interval_hours"]):
|
||||
if settings.get("last_run_at") != last_value:
|
||||
settings["last_run_at"] = last_value
|
||||
save_auto_backup_settings(settings, user_id)
|
||||
return None
|
||||
backup = create_backup(f"Automatic backup {now.isoformat(timespec='seconds')}", user_id, automatic=True)
|
||||
settings["last_run_at"] = backup.get("created_at") or now.isoformat(timespec="seconds")
|
||||
save_auto_backup_settings(settings, user_id)
|
||||
prune_old_backups(user_id, settings["retention_days"])
|
||||
return backup
|
||||
|
||||
|
||||
def start_scheduler() -> None:
|
||||
"""Start a lightweight automatic-backup scheduler.
|
||||
|
||||
Note: It scans configured users and never blocks normal request handling.
|
||||
"""
|
||||
global _scheduler_started
|
||||
with _scheduler_lock:
|
||||
if _scheduler_started:
|
||||
return
|
||||
_scheduler_started = True
|
||||
|
||||
def loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
with connect() as conn:
|
||||
rows = conn.execute("SELECT id FROM users WHERE is_active=1").fetchall()
|
||||
user_ids = [int(row["id"]) for row in rows] or [default_user_id()]
|
||||
for uid in user_ids:
|
||||
maybe_create_automatic_backup(uid)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(300)
|
||||
|
||||
threading.Thread(target=loop, daemon=True, name="pytorrent-backup-scheduler").start()
|
||||
41
pytorrent/services/disk_guard.py
Normal file
41
pytorrent/services/disk_guard.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from . import download_planner
|
||||
|
||||
|
||||
def check(profile: dict, force: bool = False) -> dict[str, Any]:
|
||||
"""Compatibility check for disk protection.
|
||||
|
||||
Disk protection is now configured in Download Planner. The planner performs
|
||||
the pause/resume action; this helper only reports whether the current disk
|
||||
source is over the planner threshold.
|
||||
"""
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
if not profile_id:
|
||||
return {"ok": False, "enabled": False, "error": "Missing profile id"}
|
||||
settings = download_planner.get_settings(profile_id)
|
||||
enabled = bool(settings.get("enabled") and settings.get("auto_pause_disk_enabled"))
|
||||
if not enabled:
|
||||
return {"ok": True, "enabled": False, "profile_id": profile_id}
|
||||
usage = download_planner.disk_usage(profile, int(settings.get("user_id") or 0) or None) or {}
|
||||
threshold = max(1, min(100, int(settings.get("auto_pause_disk_percent") or 95)))
|
||||
percent = float(usage.get("percent") or 0)
|
||||
triggered = bool(usage.get("ok") and percent >= threshold)
|
||||
return {
|
||||
"ok": True,
|
||||
"enabled": True,
|
||||
"profile_id": profile_id,
|
||||
"triggered": triggered,
|
||||
"rules": [{"threshold": threshold, "percent": percent, "mode": usage.get("mode"), "path": usage.get("path"), "usage": usage}] if triggered else [],
|
||||
}
|
||||
|
||||
|
||||
def assert_can_start_download(profile: dict) -> None:
|
||||
result = check(profile, force=True)
|
||||
if result.get("enabled") and result.get("triggered"):
|
||||
rule = (result.get("rules") or [{}])[0]
|
||||
raise RuntimeError(
|
||||
f"Planner disk protection blocked download start: {rule.get('percent')}% >= {rule.get('threshold')}% ({rule.get('path')})"
|
||||
)
|
||||
551
pytorrent/services/download_planner.py
Normal file
551
pytorrent/services/download_planner.py
Normal file
@@ -0,0 +1,551 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import psutil
|
||||
|
||||
from ..db import connect, default_user_id, utcnow
|
||||
from . import rtorrent
|
||||
|
||||
DEFAULTS = {
|
||||
"enabled": False,
|
||||
"name": "Default download plan",
|
||||
"profile_name": "night mode",
|
||||
"dry_run": False,
|
||||
"manual_override_until": "",
|
||||
"night_only_enabled": False,
|
||||
"night_start": "23:00",
|
||||
"night_end": "07:00",
|
||||
"quiet_hours_enabled": False,
|
||||
"quiet_start": "22:00",
|
||||
"quiet_end": "06:00",
|
||||
"weekday_down": 0,
|
||||
"weekday_up": 0,
|
||||
"weekend_down": 0,
|
||||
"weekend_up": 0,
|
||||
"hourly_schedule_enabled": False,
|
||||
"hourly_schedule": [],
|
||||
"auto_pause_cpu_enabled": False,
|
||||
"auto_pause_cpu_percent": 90,
|
||||
"auto_pause_disk_enabled": False,
|
||||
"auto_pause_disk_percent": 95,
|
||||
"network_protection_enabled": False,
|
||||
"network_max_down": 0,
|
||||
"network_max_up": 0,
|
||||
"load_protection_enabled": False,
|
||||
"load_cpu_percent": 95,
|
||||
"auto_resume": True,
|
||||
"auto_resume_grace_seconds": 0,
|
||||
"check_interval_seconds": 30,
|
||||
}
|
||||
|
||||
_LAST_RUN: dict[int, float] = {}
|
||||
_LAST_LIMITS: dict[int, tuple[int, int]] = {}
|
||||
_HIGH_CPU_SINCE: dict[int, float] = {}
|
||||
|
||||
|
||||
def _bool(value: Any) -> bool:
|
||||
if isinstance(value, str):
|
||||
return value.lower() in {"1", "true", "yes", "on"}
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _int(value: Any, default: int = 0, lo: int = 0, hi: int = 10**9) -> int:
|
||||
try:
|
||||
return max(lo, min(hi, int(value)))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _hourly_schedule(value: Any) -> list[dict]:
|
||||
rows = value if isinstance(value, list) else []
|
||||
by_hour: dict[int, dict] = {}
|
||||
for item in rows:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
try:
|
||||
hour = int(item.get("hour"))
|
||||
except Exception:
|
||||
continue
|
||||
if hour < 0 or hour > 23:
|
||||
continue
|
||||
by_hour[hour] = {"hour": hour, "down": _int(item.get("down"), 0), "up": _int(item.get("up"), 0)}
|
||||
return [by_hour.get(hour, {"hour": hour, "down": 0, "up": 0}) for hour in range(24)]
|
||||
|
||||
def _hourly_limit_for(settings: dict, hour: int) -> tuple[int, int] | None:
|
||||
if not settings.get("hourly_schedule_enabled"):
|
||||
return None
|
||||
rows = settings.get("hourly_schedule") or []
|
||||
for item in rows:
|
||||
if int(item.get("hour", -1)) == int(hour):
|
||||
return int(item.get("down") or 0), int(item.get("up") or 0)
|
||||
return 0, 0
|
||||
|
||||
|
||||
def _time_minutes(value: str, fallback: str) -> int:
|
||||
text = str(value or fallback).strip()
|
||||
try:
|
||||
hh, mm = text.split(":", 1)
|
||||
return max(0, min(1439, int(hh) * 60 + int(mm)))
|
||||
except Exception:
|
||||
hh, mm = fallback.split(":", 1)
|
||||
return int(hh) * 60 + int(mm)
|
||||
|
||||
|
||||
def _in_window(now_min: int, start: str, end: str) -> bool:
|
||||
s = _time_minutes(start, "00:00")
|
||||
e = _time_minutes(end, "00:00")
|
||||
if s == e:
|
||||
return True
|
||||
if s < e:
|
||||
return s <= now_min < e
|
||||
return now_min >= s or now_min < e
|
||||
|
||||
|
||||
def normalize(data: dict | None) -> dict:
|
||||
raw = {**DEFAULTS, **(data or {})}
|
||||
return {
|
||||
"enabled": _bool(raw.get("enabled")),
|
||||
"name": str(raw.get("name") or DEFAULTS["name"]).strip()[:120],
|
||||
"profile_name": str(raw.get("profile_name") or raw.get("name") or DEFAULTS["profile_name"]).strip()[:80],
|
||||
"dry_run": _bool(raw.get("dry_run")),
|
||||
"manual_override_until": str(raw.get("manual_override_until") or "")[:40],
|
||||
"night_only_enabled": _bool(raw.get("night_only_enabled")),
|
||||
"night_start": str(raw.get("night_start") or DEFAULTS["night_start"])[:5],
|
||||
"night_end": str(raw.get("night_end") or DEFAULTS["night_end"])[:5],
|
||||
"quiet_hours_enabled": _bool(raw.get("quiet_hours_enabled")),
|
||||
"quiet_start": str(raw.get("quiet_start") or DEFAULTS["quiet_start"])[:5],
|
||||
"quiet_end": str(raw.get("quiet_end") or DEFAULTS["quiet_end"])[:5],
|
||||
"weekday_down": _int(raw.get("weekday_down"), 0),
|
||||
"weekday_up": _int(raw.get("weekday_up"), 0),
|
||||
"weekend_down": _int(raw.get("weekend_down"), 0),
|
||||
"weekend_up": _int(raw.get("weekend_up"), 0),
|
||||
"hourly_schedule_enabled": _bool(raw.get("hourly_schedule_enabled")),
|
||||
"hourly_schedule": _hourly_schedule(raw.get("hourly_schedule")),
|
||||
"auto_pause_cpu_enabled": _bool(raw.get("auto_pause_cpu_enabled")),
|
||||
"auto_pause_cpu_percent": _int(raw.get("auto_pause_cpu_percent"), 90, 1, 100),
|
||||
"auto_pause_disk_enabled": _bool(raw.get("auto_pause_disk_enabled")),
|
||||
"auto_pause_disk_percent": _int(raw.get("auto_pause_disk_percent"), 95, 1, 100),
|
||||
"network_protection_enabled": _bool(raw.get("network_protection_enabled")),
|
||||
"network_max_down": _int(raw.get("network_max_down"), 0),
|
||||
"network_max_up": _int(raw.get("network_max_up"), 0),
|
||||
"load_protection_enabled": _bool(raw.get("load_protection_enabled")),
|
||||
"load_cpu_percent": _int(raw.get("load_cpu_percent"), 95, 1, 100),
|
||||
"auto_resume": _bool(raw.get("auto_resume")),
|
||||
"auto_resume_grace_seconds": _int(raw.get("auto_resume_grace_seconds"), 0, 0, 86400),
|
||||
"check_interval_seconds": _int(raw.get("check_interval_seconds"), 30, 10, 3600),
|
||||
}
|
||||
|
||||
|
||||
def _row(user_id: int, profile_id: int) -> dict | None:
|
||||
with connect() as conn:
|
||||
return conn.execute(
|
||||
"SELECT * FROM download_plan_settings WHERE user_id=? AND profile_id=?",
|
||||
(user_id, profile_id),
|
||||
).fetchone()
|
||||
|
||||
|
||||
|
||||
|
||||
def _preference_row_for_disk_source(profile_id: int, user_id: int | None = None) -> dict | None:
|
||||
from . import preferences
|
||||
user_id = user_id or default_user_id()
|
||||
return preferences.get_disk_monitor_preferences(profile_id, user_id)
|
||||
|
||||
def _legacy_disk_guard_defaults(profile_id: int, user_id: int | None = None) -> dict:
|
||||
pref = _preference_row_for_disk_source(profile_id, user_id)
|
||||
if not pref or not pref.get("disk_monitor_stop_enabled"):
|
||||
return {}
|
||||
return {
|
||||
"enabled": True,
|
||||
"auto_pause_disk_enabled": True,
|
||||
"auto_pause_disk_percent": _int(pref.get("disk_monitor_stop_threshold"), 95, 1, 100),
|
||||
"auto_resume": True,
|
||||
}
|
||||
|
||||
|
||||
def _history_key(profile_id: int) -> str:
|
||||
return f"download_planner.history.{int(profile_id)}"
|
||||
|
||||
|
||||
def _override_key(profile_id: int) -> str:
|
||||
return f"download_planner.override_until.{int(profile_id)}"
|
||||
|
||||
|
||||
def _parse_iso_ts(value: str | None) -> float:
|
||||
if not value:
|
||||
return 0.0
|
||||
try:
|
||||
text = str(value).replace("Z", "+00:00")
|
||||
return datetime.fromisoformat(text).timestamp()
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _override_until(profile_id: int) -> str:
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (_override_key(profile_id),)).fetchone()
|
||||
return str(row.get("value") or "") if row else ""
|
||||
|
||||
|
||||
def set_manual_override(profile_id: int, seconds: int) -> dict:
|
||||
until = ""
|
||||
seconds = _int(seconds, 0, 0, 86400)
|
||||
if seconds:
|
||||
until = datetime.fromtimestamp(time.time() + seconds, tz=timezone.utc).isoformat()
|
||||
with connect() as conn:
|
||||
conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (_override_key(profile_id), until))
|
||||
return {"manual_override_until": until, "seconds": seconds}
|
||||
|
||||
|
||||
def _append_history(profile_id: int, event: str, payload: dict | None = None) -> None:
|
||||
payload = payload or {}
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (_history_key(profile_id),)).fetchone()
|
||||
try:
|
||||
items = json.loads(row.get("value") or "[]") if row else []
|
||||
except Exception:
|
||||
items = []
|
||||
items.append({"at": utcnow(), "event": str(event), **payload})
|
||||
items = items[-80:]
|
||||
conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (_history_key(profile_id), json.dumps(items)))
|
||||
|
||||
|
||||
def _history_items(profile_id: int) -> list[dict]:
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (_history_key(profile_id),)).fetchone()
|
||||
try:
|
||||
items = json.loads(row.get("value") or "[]") if row else []
|
||||
except Exception:
|
||||
items = []
|
||||
return items if isinstance(items, list) else []
|
||||
|
||||
|
||||
def history(profile_id: int, limit: int = 40) -> list[dict]:
|
||||
items = _history_items(profile_id)
|
||||
return list(reversed(items[-max(1, min(200, int(limit))):]))
|
||||
|
||||
|
||||
def history_count(profile_id: int) -> int:
|
||||
return len(_history_items(profile_id))
|
||||
|
||||
|
||||
def clear_history(profile_id: int) -> int:
|
||||
deleted = history_count(profile_id)
|
||||
with connect() as conn:
|
||||
# Note: Planner history is stored per profile in app_settings; clearing it does not change saved Planner rules.
|
||||
conn.execute("DELETE FROM app_settings WHERE key=?", (_history_key(profile_id),))
|
||||
return deleted
|
||||
|
||||
|
||||
def _profile_label(settings: dict) -> str:
|
||||
return str(settings.get("profile_name") or settings.get("name") or "Planner")
|
||||
|
||||
|
||||
def _next_boundary(now: datetime, settings: dict) -> str:
|
||||
candidates: list[datetime] = []
|
||||
for hour in range(24):
|
||||
if settings.get("hourly_schedule_enabled"):
|
||||
dt = now.replace(hour=hour, minute=0, second=0, microsecond=0)
|
||||
if dt <= now:
|
||||
dt = dt + __import__("datetime").timedelta(days=1)
|
||||
candidates.append(dt)
|
||||
for key in ("night_start", "night_end", "quiet_start", "quiet_end"):
|
||||
value = settings.get(key)
|
||||
if not value:
|
||||
continue
|
||||
minute = _time_minutes(str(value), "00:00")
|
||||
dt = now.replace(hour=minute // 60, minute=minute % 60, second=0, microsecond=0)
|
||||
if dt <= now:
|
||||
dt = dt.replace(day=dt.day) + __import__("datetime").timedelta(days=1)
|
||||
candidates.append(dt)
|
||||
return min(candidates).isoformat() if candidates else ""
|
||||
|
||||
def get_settings(profile_id: int, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or default_user_id()
|
||||
row = _row(user_id, profile_id)
|
||||
if not row:
|
||||
migrated = normalize({**DEFAULTS, **_legacy_disk_guard_defaults(int(profile_id), user_id)})
|
||||
return {**migrated, "profile_id": int(profile_id), "user_id": int(user_id)}
|
||||
try:
|
||||
data = json.loads(row.get("settings_json") or "{}")
|
||||
except Exception:
|
||||
data = {}
|
||||
settings = {**normalize(data), "profile_id": int(profile_id), "user_id": int(user_id), "updated_at": row.get("updated_at")}
|
||||
runtime_override = _override_until(int(profile_id))
|
||||
if runtime_override:
|
||||
settings["manual_override_until"] = runtime_override
|
||||
return settings
|
||||
|
||||
|
||||
def save_settings(profile_id: int, data: dict, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or default_user_id()
|
||||
settings = normalize(data)
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO download_plan_settings(user_id, profile_id, settings_json, updated_at)
|
||||
VALUES(?,?,?,?)
|
||||
ON CONFLICT(user_id, profile_id) DO UPDATE SET settings_json=excluded.settings_json, updated_at=excluded.updated_at
|
||||
""",
|
||||
(user_id, profile_id, json.dumps(settings), now),
|
||||
)
|
||||
return {**settings, "profile_id": int(profile_id), "user_id": int(user_id), "updated_at": now}
|
||||
|
||||
|
||||
def _active_downloading_hashes(profile: dict) -> list[str]:
|
||||
rows = rtorrent.list_torrents(profile)
|
||||
hashes: list[str] = []
|
||||
for row in rows:
|
||||
if int(row.get("complete") or 0):
|
||||
continue
|
||||
if int(row.get("state") or 0) and not row.get("paused"):
|
||||
h = str(row.get("hash") or "")
|
||||
if h:
|
||||
hashes.append(h)
|
||||
return hashes
|
||||
|
||||
|
||||
def _remember_paused(profile_id: int, hashes: list[str], reason: str) -> None:
|
||||
if not hashes:
|
||||
return
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
for h in hashes:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO download_plan_paused(profile_id,torrent_hash,reason,created_at,updated_at) VALUES(?,?,?,?,?)",
|
||||
(profile_id, h, reason, now, now),
|
||||
)
|
||||
|
||||
|
||||
def _planned_paused(profile_id: int) -> list[str]:
|
||||
with connect() as conn:
|
||||
rows = conn.execute("SELECT torrent_hash FROM download_plan_paused WHERE profile_id=?", (profile_id,)).fetchall()
|
||||
return [str(row.get("torrent_hash") or "") for row in rows if row.get("torrent_hash")]
|
||||
|
||||
|
||||
def _clear_planned(profile_id: int, hashes: list[str] | None = None) -> None:
|
||||
with connect() as conn:
|
||||
if hashes:
|
||||
conn.executemany("DELETE FROM download_plan_paused WHERE profile_id=? AND torrent_hash=?", [(profile_id, h) for h in hashes])
|
||||
else:
|
||||
conn.execute("DELETE FROM download_plan_paused WHERE profile_id=?", (profile_id,))
|
||||
|
||||
|
||||
def disk_usage(profile: dict, user_id: int | None = None) -> dict | None:
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
pref = _preference_row_for_disk_source(profile_id, user_id) or {}
|
||||
try:
|
||||
paths = json.loads(pref.get("disk_monitor_paths_json") or "[]")
|
||||
except Exception:
|
||||
paths = []
|
||||
if not isinstance(paths, list):
|
||||
paths = []
|
||||
try:
|
||||
return rtorrent.disk_usage_for_paths(
|
||||
profile,
|
||||
[str(p) for p in paths if str(p or "").strip()],
|
||||
str(pref.get("disk_monitor_mode") or "default"),
|
||||
str(pref.get("disk_monitor_selected_path") or ""),
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _disk_percent(profile: dict, user_id: int | None = None) -> float | None:
|
||||
usage = disk_usage(profile, user_id)
|
||||
if usage and usage.get("ok"):
|
||||
return float(usage.get("percent") or 0)
|
||||
return None
|
||||
|
||||
|
||||
def evaluate(profile: dict, settings: dict | None = None, now: datetime | None = None) -> dict:
|
||||
settings = normalize(settings or get_settings(int(profile.get("id") or 0)))
|
||||
now = now or datetime.now().astimezone()
|
||||
override_until = settings.get("manual_override_until") or _override_until(int(profile.get("id") or 0))
|
||||
override_active = bool(_parse_iso_ts(override_until) > time.time())
|
||||
now_min = now.hour * 60 + now.minute
|
||||
weekend = now.weekday() >= 5
|
||||
reasons: list[str] = []
|
||||
pause_downloads = False
|
||||
quiet = bool(settings["quiet_hours_enabled"] and _in_window(now_min, settings["quiet_start"], settings["quiet_end"]))
|
||||
in_night = _in_window(now_min, settings["night_start"], settings["night_end"])
|
||||
if quiet:
|
||||
pause_downloads = True
|
||||
reasons.append("quiet_hours")
|
||||
if settings["night_only_enabled"] and not in_night:
|
||||
pause_downloads = True
|
||||
reasons.append("outside_night_window")
|
||||
hourly_limits = _hourly_limit_for(settings, now.hour)
|
||||
if hourly_limits is not None:
|
||||
down, up = hourly_limits
|
||||
reasons.append("hourly_schedule")
|
||||
else:
|
||||
down = int(settings["weekend_down"] if weekend else settings["weekday_down"])
|
||||
up = int(settings["weekend_up"] if weekend else settings["weekday_up"])
|
||||
if quiet or pause_downloads:
|
||||
down = 0
|
||||
cpu = None
|
||||
if settings["load_protection_enabled"]:
|
||||
cpu_load = float(psutil.cpu_percent(interval=None))
|
||||
if cpu_load >= float(settings["load_cpu_percent"]):
|
||||
pause_downloads = True
|
||||
reasons.append("high_load")
|
||||
if settings["auto_pause_cpu_enabled"]:
|
||||
cpu = float(psutil.cpu_percent(interval=None))
|
||||
pid = int(profile.get("id") or 0)
|
||||
if cpu >= float(settings["auto_pause_cpu_percent"]):
|
||||
_HIGH_CPU_SINCE.setdefault(pid, time.monotonic())
|
||||
if time.monotonic() - _HIGH_CPU_SINCE[pid] >= 10:
|
||||
pause_downloads = True
|
||||
reasons.append("high_cpu")
|
||||
else:
|
||||
_HIGH_CPU_SINCE.pop(pid, None)
|
||||
disk = None
|
||||
if settings["auto_pause_disk_enabled"]:
|
||||
disk = _disk_percent(profile, int(settings.get("user_id") or default_user_id()))
|
||||
if disk is not None and disk >= float(settings["auto_pause_disk_percent"]):
|
||||
pause_downloads = True
|
||||
reasons.append("high_disk")
|
||||
if settings["network_protection_enabled"]:
|
||||
nd = int(settings.get("network_max_down") or 0)
|
||||
nu = int(settings.get("network_max_up") or 0)
|
||||
if nd and (not down or down > nd):
|
||||
down = nd
|
||||
reasons.append("network_limit_down")
|
||||
if nu and (not up or up > nu):
|
||||
up = nu
|
||||
reasons.append("network_limit_up")
|
||||
if override_active:
|
||||
pause_downloads = False
|
||||
reasons = ["manual_override"]
|
||||
return {
|
||||
"enabled": bool(settings["enabled"]),
|
||||
"profile_id": int(profile.get("id") or 0),
|
||||
"profile_name": _profile_label(settings),
|
||||
"dry_run": bool(settings.get("dry_run")),
|
||||
"manual_override_until": override_until if override_active else "",
|
||||
"matched_rule": reasons[0] if reasons else ("weekend" if weekend else "weekday"),
|
||||
"next_change_at": _next_boundary(now, settings),
|
||||
"pause_downloads": pause_downloads,
|
||||
"reasons": reasons,
|
||||
"down": down,
|
||||
"up": up,
|
||||
"weekend": weekend,
|
||||
"quiet": quiet,
|
||||
"in_night_window": in_night,
|
||||
"cpu": cpu,
|
||||
"disk": disk,
|
||||
}
|
||||
|
||||
|
||||
def enforce(profile: dict, force: bool = False) -> dict:
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
settings = get_settings(profile_id)
|
||||
if not settings.get("enabled"):
|
||||
return {"ok": True, "enabled": False, "profile_id": profile_id, "history": history(profile_id, 20), "history_total": history_count(profile_id), "preview": preview(profile)}
|
||||
now = time.monotonic()
|
||||
interval = int(settings.get("check_interval_seconds") or 30)
|
||||
if not force and now - _LAST_RUN.get(profile_id, 0) < interval:
|
||||
return {"ok": True, "enabled": True, "profile_id": profile_id, "skipped": True}
|
||||
_LAST_RUN[profile_id] = now
|
||||
decision = evaluate(profile, settings)
|
||||
result: dict[str, Any] = {"ok": True, "enabled": True, **decision, "limits_changed": False, "paused": 0, "resumed": 0}
|
||||
wanted_limits = (int(decision["down"]), int(decision["up"]))
|
||||
dry_run = bool(settings.get("dry_run")) or bool(force and str(profile.get("dry_run") or "").lower() == "true")
|
||||
result["dry_run"] = dry_run
|
||||
if force or _LAST_LIMITS.get(profile_id) != wanted_limits:
|
||||
if not dry_run:
|
||||
rtorrent.set_limits(profile, wanted_limits[0], wanted_limits[1])
|
||||
_LAST_LIMITS[profile_id] = wanted_limits
|
||||
result["limits_changed"] = True
|
||||
_append_history(profile_id, "speed_limit_change", {"down": wanted_limits[0], "up": wanted_limits[1], "dry_run": dry_run})
|
||||
if decision["pause_downloads"]:
|
||||
hashes = _active_downloading_hashes(profile)
|
||||
if hashes:
|
||||
action = {"dry_run": True} if dry_run else rtorrent.action(profile, hashes, "pause", {"source": "download_planner", "reasons": decision["reasons"]})
|
||||
if not dry_run:
|
||||
_remember_paused(profile_id, hashes, ",".join(decision["reasons"]))
|
||||
result["paused"] = len(hashes)
|
||||
result["pause_result"] = action
|
||||
_append_history(profile_id, "paused_torrents", {"count": len(hashes), "reasons": decision["reasons"], "dry_run": dry_run})
|
||||
if "high_cpu" in decision["reasons"] or "high_load" in decision["reasons"]:
|
||||
_append_history(profile_id, "cpu_protection_trigger", {"cpu": decision.get("cpu"), "dry_run": dry_run})
|
||||
if "high_disk" in decision["reasons"]:
|
||||
_append_history(profile_id, "disk_protection_trigger", {"disk": decision.get("disk"), "dry_run": dry_run})
|
||||
elif settings.get("auto_resume"):
|
||||
grace = int(settings.get("auto_resume_grace_seconds") or 0)
|
||||
last_trigger = 0.0
|
||||
for item in history(profile_id, 20):
|
||||
if item.get("event") in {"paused_torrents", "cpu_protection_trigger", "disk_protection_trigger"}:
|
||||
last_trigger = _parse_iso_ts(item.get("at"))
|
||||
break
|
||||
if grace and last_trigger and time.time() - last_trigger < grace:
|
||||
result["resume_wait_seconds"] = int(grace - (time.time() - last_trigger))
|
||||
else:
|
||||
hashes = _planned_paused(profile_id)
|
||||
if hashes:
|
||||
action = {"dry_run": True} if dry_run else rtorrent.action(profile, hashes, "resume", {"source": "download_planner"})
|
||||
if not dry_run:
|
||||
_clear_planned(profile_id, hashes)
|
||||
result["resumed"] = len(hashes)
|
||||
result["resume_result"] = action
|
||||
_append_history(profile_id, "resumed_torrents", {"count": len(hashes), "dry_run": dry_run})
|
||||
result["history"] = history(profile_id, 20)
|
||||
result["history_total"] = history_count(profile_id)
|
||||
result["preview"] = preview(profile)
|
||||
return result
|
||||
|
||||
|
||||
def preview(profile: dict) -> dict:
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
settings = get_settings(profile_id)
|
||||
decision = evaluate(profile, settings)
|
||||
return {
|
||||
"profile_id": profile_id,
|
||||
"profile_name": decision.get("profile_name"),
|
||||
"matched_rule": decision.get("matched_rule"),
|
||||
"next_change_at": decision.get("next_change_at"),
|
||||
"pause_downloads": decision.get("pause_downloads"),
|
||||
"down": decision.get("down"),
|
||||
"up": decision.get("up"),
|
||||
"reasons": decision.get("reasons", []),
|
||||
"manual_override_until": decision.get("manual_override_until", ""),
|
||||
"dry_run": decision.get("dry_run", False),
|
||||
}
|
||||
|
||||
|
||||
def start_scheduler(socketio=None) -> None:
|
||||
def loop():
|
||||
while True:
|
||||
try:
|
||||
from .preferences import active_profile
|
||||
from .websocket import emit_profile_event
|
||||
from . import auth
|
||||
profiles: list[dict]
|
||||
if auth.enabled():
|
||||
with connect() as conn:
|
||||
profiles = conn.execute("SELECT * FROM rtorrent_profiles ORDER BY id").fetchall()
|
||||
else:
|
||||
profile = active_profile()
|
||||
profiles = [profile] if profile else []
|
||||
for profile in profiles:
|
||||
try:
|
||||
result = enforce(profile, force=False)
|
||||
if socketio and result.get("enabled") and not result.get("skipped"):
|
||||
emit_profile_event(socketio, "download_plan_update", result, int(profile["id"]))
|
||||
except Exception as exc:
|
||||
if socketio:
|
||||
emit_profile_event(socketio, "download_plan_update", {"ok": False, "profile_id": int(profile.get("id") or 0), "error": str(exc)}, int(profile.get("id") or 0))
|
||||
except Exception:
|
||||
pass
|
||||
if socketio:
|
||||
socketio.sleep(30)
|
||||
else:
|
||||
time.sleep(30)
|
||||
if socketio:
|
||||
socketio.start_background_task(loop)
|
||||
108
pytorrent/services/frontend_assets.py
Normal file
108
pytorrent/services/frontend_assets.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ..config import BASE_DIR, USE_OFFLINE_LIBS
|
||||
|
||||
LIBS_STATIC_DIR = "libs"
|
||||
LIBS_DIR = BASE_DIR / "pytorrent" / "static" / LIBS_STATIC_DIR
|
||||
BOOTSTRAP_VERSION = "5.3.3"
|
||||
BOOTSWATCH_VERSION = "5.3.3"
|
||||
FONTAWESOME_VERSION = "6.5.2"
|
||||
FLAG_ICONS_VERSION = "7.2.3"
|
||||
SWAGGER_UI_VERSION = "5"
|
||||
SOCKET_IO_VERSION = "4.7.5"
|
||||
|
||||
BOOTSTRAP_THEMES = (
|
||||
"default",
|
||||
"flatly",
|
||||
"litera",
|
||||
"lumen",
|
||||
"minty",
|
||||
"sketchy",
|
||||
"solar",
|
||||
"spacelab",
|
||||
"united",
|
||||
"zephyr",
|
||||
)
|
||||
|
||||
STATIC_ASSETS = {
|
||||
"bootstrap_js": {
|
||||
"local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/js/bootstrap.bundle.min.js",
|
||||
"cdn": f"https://cdn.jsdelivr.net/npm/bootstrap@{BOOTSTRAP_VERSION}/dist/js/bootstrap.bundle.min.js",
|
||||
},
|
||||
"fontawesome_css": {
|
||||
"local": f"{LIBS_STATIC_DIR}/fontawesome/{FONTAWESOME_VERSION}/css/all.min.css",
|
||||
"cdn": f"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/{FONTAWESOME_VERSION}/css/all.min.css",
|
||||
},
|
||||
"flag_icons_css": {
|
||||
"local": f"{LIBS_STATIC_DIR}/flag-icons/{FLAG_ICONS_VERSION}/css/flag-icons.min.css",
|
||||
"cdn": f"https://cdn.jsdelivr.net/gh/lipis/flag-icons@{FLAG_ICONS_VERSION}/css/flag-icons.min.css",
|
||||
},
|
||||
"socket_io_js": {
|
||||
"local": f"{LIBS_STATIC_DIR}/socket.io/{SOCKET_IO_VERSION}/socket.io.min.js",
|
||||
"cdn": f"https://cdn.socket.io/{SOCKET_IO_VERSION}/socket.io.min.js",
|
||||
},
|
||||
"swagger_css": {
|
||||
"local": f"{LIBS_STATIC_DIR}/swagger-ui/{SWAGGER_UI_VERSION}/swagger-ui.css",
|
||||
"cdn": f"https://cdn.jsdelivr.net/npm/swagger-ui-dist@{SWAGGER_UI_VERSION}/swagger-ui.css",
|
||||
},
|
||||
"swagger_js": {
|
||||
"local": f"{LIBS_STATIC_DIR}/swagger-ui/{SWAGGER_UI_VERSION}/swagger-ui-bundle.js",
|
||||
"cdn": f"https://cdn.jsdelivr.net/npm/swagger-ui-dist@{SWAGGER_UI_VERSION}/swagger-ui-bundle.js",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def bootstrap_css_asset(theme: str | None = None) -> dict[str, str]:
|
||||
theme = theme if theme in BOOTSTRAP_THEMES else "default"
|
||||
if theme == "default":
|
||||
return {
|
||||
"local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/css/bootstrap.min.css",
|
||||
"cdn": f"https://cdn.jsdelivr.net/npm/bootstrap@{BOOTSTRAP_VERSION}/dist/css/bootstrap.min.css",
|
||||
}
|
||||
return {
|
||||
"local": f"{LIBS_STATIC_DIR}/bootswatch/{BOOTSWATCH_VERSION}/{theme}/bootstrap.min.css",
|
||||
"cdn": f"https://cdn.jsdelivr.net/npm/bootswatch@{BOOTSWATCH_VERSION}/dist/{theme}/bootstrap.min.css",
|
||||
}
|
||||
|
||||
|
||||
def asset_path(key: str) -> str:
|
||||
return STATIC_ASSETS[key]["local" if USE_OFFLINE_LIBS else "cdn"]
|
||||
|
||||
|
||||
def bootstrap_css_path(theme: str | None = None) -> str:
|
||||
return bootstrap_css_asset(theme)["local" if USE_OFFLINE_LIBS else "cdn"]
|
||||
|
||||
|
||||
def required_offline_paths() -> list[Path]:
|
||||
paths = [LIBS_DIR.parent / item["local"] for item in STATIC_ASSETS.values()]
|
||||
paths.extend(LIBS_DIR.parent / bootstrap_css_asset(theme)["local"] for theme in BOOTSTRAP_THEMES)
|
||||
return paths
|
||||
|
||||
|
||||
def missing_offline_paths() -> list[Path]:
|
||||
missing = [path for path in required_offline_paths() if not path.is_file() or path.stat().st_size <= 0]
|
||||
required_dirs = [
|
||||
LIBS_DIR / f"fontawesome/{FONTAWESOME_VERSION}/webfonts",
|
||||
LIBS_DIR / f"flag-icons/{FLAG_ICONS_VERSION}/flags/4x3",
|
||||
LIBS_DIR / f"flag-icons/{FLAG_ICONS_VERSION}/flags/1x1",
|
||||
]
|
||||
for directory in required_dirs:
|
||||
if not directory.is_dir() or not any(directory.iterdir()):
|
||||
missing.append(directory)
|
||||
return missing
|
||||
|
||||
|
||||
def validate_offline_assets() -> None:
|
||||
if not USE_OFFLINE_LIBS:
|
||||
return
|
||||
missing = missing_offline_paths()
|
||||
if missing:
|
||||
preview = "\n".join(f"- {path.relative_to(BASE_DIR)}" for path in missing[:20])
|
||||
extra = "" if len(missing) <= 20 else f"\n- ... and {len(missing) - 20} more"
|
||||
raise RuntimeError(
|
||||
"PYTORRENT_USE_OFFLINE_LIBS=true, but frontend libraries are missing. "
|
||||
"Run: ./scripts/download_frontend_libs.py or ./install.sh\n"
|
||||
f"Missing files:\n{preview}{extra}"
|
||||
)
|
||||
38
pytorrent/services/geoip.py
Normal file
38
pytorrent/services/geoip.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from ..config import GEOIP_DB
|
||||
|
||||
try:
|
||||
import geoip2.database
|
||||
except Exception: # pragma: no cover
|
||||
geoip2 = None
|
||||
|
||||
_reader = None
|
||||
|
||||
|
||||
def _get_reader():
|
||||
global _reader
|
||||
if _reader is not None:
|
||||
return _reader
|
||||
if not GEOIP_DB.exists() or geoip2 is None:
|
||||
return None
|
||||
_reader = geoip2.database.Reader(str(GEOIP_DB))
|
||||
return _reader
|
||||
|
||||
|
||||
@lru_cache(maxsize=50000)
|
||||
def lookup_ip(ip: str) -> dict:
|
||||
reader = _get_reader()
|
||||
if not reader:
|
||||
return {"country_iso": "", "country": "", "city": ""}
|
||||
try:
|
||||
hit = reader.city(ip)
|
||||
return {
|
||||
"country_iso": (hit.country.iso_code or "").lower(),
|
||||
"country": hit.country.name or "",
|
||||
"city": hit.city.name or "",
|
||||
}
|
||||
except Exception:
|
||||
return {"country_iso": "", "country": "", "city": ""}
|
||||
244
pytorrent/services/poller_control.py
Normal file
244
pytorrent/services/poller_control.py
Normal file
@@ -0,0 +1,244 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from ..db import connect, utcnow
|
||||
from ..config import POLL_INTERVAL, MIN_POLL_INTERVAL_SECONDS
|
||||
|
||||
DEFAULTS = {
|
||||
"adaptive_enabled": True,
|
||||
"safe_fallback_enabled": True,
|
||||
"active_interval_seconds": 5.0,
|
||||
"idle_interval_seconds": 15.0,
|
||||
"error_interval_seconds": 30.0,
|
||||
"torrent_list_interval_seconds": 5.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,
|
||||
"emit_heartbeat_on_change": True,
|
||||
"slow_response_threshold_ms": 8000.0,
|
||||
"slowdown_multiplier": 2.0,
|
||||
"recovery_after_errors": 3,
|
||||
}
|
||||
|
||||
|
||||
def _key(profile_id: int) -> str:
|
||||
return f"poller.settings.{int(profile_id)}"
|
||||
|
||||
|
||||
def _state_key(profile_id: int) -> str:
|
||||
return f"poller.runtime.{int(profile_id)}"
|
||||
|
||||
|
||||
def _coerce_float(value: Any, default: float, lo: float, hi: float) -> float:
|
||||
try:
|
||||
number = float(value)
|
||||
except Exception:
|
||||
return default
|
||||
return max(lo, min(hi, number))
|
||||
|
||||
|
||||
def normalize_settings(data: dict | None) -> dict:
|
||||
raw = {**DEFAULTS, **(data or {})}
|
||||
settings = {
|
||||
"adaptive_enabled": bool(raw.get("adaptive_enabled")),
|
||||
"safe_fallback_enabled": bool(raw.get("safe_fallback_enabled", True)),
|
||||
"active_interval_seconds": _coerce_float(raw.get("active_interval_seconds"), DEFAULTS["active_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 30.0),
|
||||
"idle_interval_seconds": _coerce_float(raw.get("idle_interval_seconds"), DEFAULTS["idle_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 120.0),
|
||||
"error_interval_seconds": _coerce_float(raw.get("error_interval_seconds"), DEFAULTS["error_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 300.0),
|
||||
"torrent_list_interval_seconds": _coerce_float(raw.get("torrent_list_interval_seconds"), DEFAULTS["torrent_list_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 120.0),
|
||||
"system_stats_interval_seconds": _coerce_float(raw.get("system_stats_interval_seconds"), DEFAULTS["system_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 120.0),
|
||||
"tracker_stats_interval_seconds": _coerce_float(raw.get("tracker_stats_interval_seconds"), DEFAULTS["tracker_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 1800.0),
|
||||
"disk_stats_interval_seconds": _coerce_float(raw.get("disk_stats_interval_seconds"), DEFAULTS["disk_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 1800.0),
|
||||
"queue_stats_interval_seconds": _coerce_float(raw.get("queue_stats_interval_seconds"), DEFAULTS["queue_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 1800.0),
|
||||
"slow_stats_interval_seconds": _coerce_float(raw.get("slow_stats_interval_seconds"), DEFAULTS["slow_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 1800.0),
|
||||
"heartbeat_interval_seconds": _coerce_float(raw.get("heartbeat_interval_seconds"), DEFAULTS["heartbeat_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 300.0),
|
||||
"emit_heartbeat_on_change": bool(raw.get("emit_heartbeat_on_change")),
|
||||
"slow_response_threshold_ms": _coerce_float(raw.get("slow_response_threshold_ms"), DEFAULTS["slow_response_threshold_ms"], 100.0, 60000.0),
|
||||
"slowdown_multiplier": _coerce_float(raw.get("slowdown_multiplier"), DEFAULTS["slowdown_multiplier"], 1.0, 10.0),
|
||||
"recovery_after_errors": int(_coerce_float(raw.get("recovery_after_errors"), 3, 1, 20)),
|
||||
}
|
||||
if settings["safe_fallback_enabled"]:
|
||||
for key in ("active_interval_seconds", "idle_interval_seconds", "error_interval_seconds", "torrent_list_interval_seconds", "system_stats_interval_seconds", "queue_stats_interval_seconds"):
|
||||
if settings[key] <= 0:
|
||||
settings[key] = DEFAULTS[key]
|
||||
return settings
|
||||
|
||||
|
||||
def get_settings(profile_id: int) -> dict:
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (_key(profile_id),)).fetchone()
|
||||
try:
|
||||
data = json.loads(row.get("value") or "{}") if row else {}
|
||||
except Exception:
|
||||
data = {}
|
||||
return normalize_settings(data)
|
||||
|
||||
|
||||
def save_settings(profile_id: int, data: dict) -> dict:
|
||||
settings = normalize_settings(data)
|
||||
with connect() as conn:
|
||||
conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (_key(profile_id), json.dumps(settings)))
|
||||
return settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProfilePollState:
|
||||
profile_id: int
|
||||
last_fast_at: float = 0.0
|
||||
last_system_at: float = 0.0
|
||||
last_slow_at: float = 0.0
|
||||
last_tracker_at: float = 0.0
|
||||
last_disk_at: float = 0.0
|
||||
last_queue_at: float = 0.0
|
||||
last_heartbeat_at: float = 0.0
|
||||
last_ok: bool = True
|
||||
last_active: bool = False
|
||||
last_error: str = ""
|
||||
last_tick_ms: float = 0.0
|
||||
last_tick_started_at: float = 0.0
|
||||
last_tick_gap_ms: float = 0.0
|
||||
effective_interval_seconds: float = 0.0
|
||||
tick_count: int = 0
|
||||
sleep_hint: float = 1.0
|
||||
error_count: int = 0
|
||||
slow_count: int = 0
|
||||
skipped_emissions: int = 0
|
||||
emitted_payload_size: int = 0
|
||||
rtorrent_call_count: int = 0
|
||||
adaptive_mode: str = "normal"
|
||||
slow_task_running: bool = False
|
||||
system_task_running: bool = False
|
||||
stats: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
_STATES: dict[int, ProfilePollState] = {}
|
||||
|
||||
|
||||
def state_for(profile_id: int) -> ProfilePollState:
|
||||
profile_id = int(profile_id)
|
||||
state = _STATES.get(profile_id)
|
||||
if state is None:
|
||||
state = ProfilePollState(profile_id=profile_id)
|
||||
_STATES[profile_id] = state
|
||||
return state
|
||||
|
||||
|
||||
def interval_for(settings: dict, state: ProfilePollState) -> float:
|
||||
if not settings.get("adaptive_enabled"):
|
||||
return float(settings["active_interval_seconds"])
|
||||
if not state.last_ok:
|
||||
return float(settings["error_interval_seconds"])
|
||||
base = float(settings["active_interval_seconds"] if state.last_active else settings["idle_interval_seconds"])
|
||||
if state.adaptive_mode == "slowdown":
|
||||
return min(float(settings["error_interval_seconds"]), base * float(settings.get("slowdown_multiplier") or 2.0))
|
||||
return base
|
||||
|
||||
|
||||
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"]))
|
||||
|
||||
|
||||
def should_fast_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
|
||||
return (now - state.last_fast_at) >= effective_fast_interval(settings, state)
|
||||
|
||||
|
||||
def should_system_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
|
||||
return (now - state.last_system_at) >= float(settings["system_stats_interval_seconds"])
|
||||
|
||||
|
||||
def should_slow_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
|
||||
return (now - state.last_slow_at) >= float(settings["slow_stats_interval_seconds"])
|
||||
|
||||
|
||||
def should_tracker_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
|
||||
return (now - state.last_tracker_at) >= float(settings["tracker_stats_interval_seconds"])
|
||||
|
||||
|
||||
def should_disk_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
|
||||
return (now - state.last_disk_at) >= float(settings["disk_stats_interval_seconds"])
|
||||
|
||||
|
||||
def should_queue_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
|
||||
return (now - state.last_queue_at) >= float(settings["queue_stats_interval_seconds"])
|
||||
|
||||
|
||||
def should_heartbeat(now: float, settings: dict, state: ProfilePollState, changed: bool) -> bool:
|
||||
if changed and settings.get("emit_heartbeat_on_change"):
|
||||
return True
|
||||
return (now - state.last_heartbeat_at) >= float(settings["heartbeat_interval_seconds"])
|
||||
|
||||
|
||||
def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool, error: str = "", emitted_payload_size: int = 0, rtorrent_call_count: int = 0, skipped_emissions: int = 0, settings: dict | None = None) -> dict:
|
||||
now = time.monotonic()
|
||||
effective_settings = normalize_settings(settings) if settings is not None else DEFAULTS
|
||||
previous_started_at = state.last_tick_started_at
|
||||
state.tick_count += 1
|
||||
state.last_tick_ms = round((now - started_at) * 1000.0, 2)
|
||||
state.last_tick_gap_ms = round((started_at - previous_started_at) * 1000.0, 2) if previous_started_at else 0.0
|
||||
state.last_tick_started_at = started_at
|
||||
state.last_active = bool(active)
|
||||
state.effective_interval_seconds = effective_fast_interval(effective_settings, state)
|
||||
state.last_ok = bool(ok)
|
||||
state.last_error = str(error or "")
|
||||
state.emitted_payload_size = int(emitted_payload_size or 0)
|
||||
state.rtorrent_call_count = int(rtorrent_call_count or 0)
|
||||
state.skipped_emissions += int(skipped_emissions or 0)
|
||||
adaptive_enabled = bool(effective_settings.get("adaptive_enabled", DEFAULTS["adaptive_enabled"]))
|
||||
|
||||
if not adaptive_enabled:
|
||||
# Adaptive mode is explicitly disabled for this rTorrent profile. Keep metrics,
|
||||
# but do not enter slowdown/recovery or preserve a stale adaptive state from
|
||||
# earlier ticks; otherwise refreshes remain slow even with the toggle off.
|
||||
state.error_count = 0 if ok else state.error_count + 1
|
||||
state.slow_count = 0
|
||||
state.adaptive_mode = "fixed"
|
||||
else:
|
||||
if ok:
|
||||
state.error_count = 0
|
||||
else:
|
||||
state.error_count += 1
|
||||
threshold = float(effective_settings.get("slow_response_threshold_ms") or DEFAULTS["slow_response_threshold_ms"])
|
||||
recovery_after = int(effective_settings.get("recovery_after_errors") or DEFAULTS["recovery_after_errors"])
|
||||
if state.last_tick_ms >= threshold:
|
||||
state.slow_count += 1
|
||||
state.adaptive_mode = "slowdown"
|
||||
elif ok and state.error_count == 0 and state.slow_count:
|
||||
state.slow_count = max(0, state.slow_count - 1)
|
||||
if not ok and state.error_count >= recovery_after:
|
||||
state.adaptive_mode = "recovery"
|
||||
elif ok and state.slow_count == 0:
|
||||
state.adaptive_mode = "normal" if state.last_active else "idle"
|
||||
state.sleep_hint = max(MIN_POLL_INTERVAL_SECONDS, min(10.0, state.sleep_hint))
|
||||
state.stats = {
|
||||
"profile_id": state.profile_id,
|
||||
"tick_count": state.tick_count,
|
||||
"last_tick_ms": state.last_tick_ms,
|
||||
"last_active": state.last_active,
|
||||
"last_ok": state.last_ok,
|
||||
"last_tick_gap_ms": state.last_tick_gap_ms,
|
||||
"effective_interval_seconds": state.effective_interval_seconds,
|
||||
"configured_min_interval_seconds": MIN_POLL_INTERVAL_SECONDS,
|
||||
"last_error": state.last_error,
|
||||
"duration_ms": state.last_tick_ms,
|
||||
"emitted_payload_size": state.emitted_payload_size,
|
||||
"rtorrent_call_count": state.rtorrent_call_count,
|
||||
"skipped_emissions": state.skipped_emissions,
|
||||
"adaptive_enabled": adaptive_enabled,
|
||||
"adaptive_mode": state.adaptive_mode,
|
||||
"error_count": state.error_count,
|
||||
"slow_count": state.slow_count,
|
||||
"updated_at": utcnow(),
|
||||
}
|
||||
return dict(state.stats)
|
||||
|
||||
|
||||
def snapshot(profile_id: int) -> dict:
|
||||
state = state_for(profile_id)
|
||||
return dict(state.stats or {"profile_id": int(profile_id), "tick_count": state.tick_count})
|
||||
428
pytorrent/services/preferences.py
Normal file
428
pytorrent/services/preferences.py
Normal file
@@ -0,0 +1,428 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from ..db import connect, utcnow, default_user_id
|
||||
from . import auth
|
||||
|
||||
BOOTSTRAP_THEMES = {
|
||||
"default": "Default Bootstrap",
|
||||
"flatly": "Flatly",
|
||||
"litera": "Litera",
|
||||
"lumen": "Lumen",
|
||||
"minty": "Minty",
|
||||
"sketchy": "Sketchy",
|
||||
"solar": "Solar",
|
||||
"spacelab": "Spacelab",
|
||||
"united": "United",
|
||||
"zephyr": "Zephyr",
|
||||
}
|
||||
|
||||
FONT_FAMILIES = {
|
||||
"default": "Theme default",
|
||||
"adwaita-mono": "Adwaita Mono",
|
||||
"inter": "Inter",
|
||||
"system-ui": "System UI",
|
||||
"source-sans-3": "Source Sans 3",
|
||||
"jetbrains-mono": "JetBrains Mono",
|
||||
}
|
||||
|
||||
# Note: Backend owns the recommended torrent table layout so frontend builds do not duplicate presets.
|
||||
RECOMMENDED_TABLE_COLUMNS = {
|
||||
"hidden": ["hash", "priority", "hashing", "active", "message", "complete", "state", "ratio_group"],
|
||||
"shown": ["down_total", "to_download", "up_total", "created"],
|
||||
"mobile": {
|
||||
"status": True, "size": True, "progress": True, "down_rate": True, "up_rate": True,
|
||||
"eta": True, "seeds": True, "peers": True, "ratio": True, "path": True, "label": True,
|
||||
"ratio_group": False, "down_total": True, "to_download": True, "up_total": True,
|
||||
"created": False, "priority": False, "state": False, "active": False, "complete": False,
|
||||
"hashing": False, "message": False, "hash": False,
|
||||
},
|
||||
"mobileSmartFiltersEnabled": False,
|
||||
"widths": {
|
||||
"select": 44, "name": 389, "status": 83, "size": 75, "progress": 177,
|
||||
"down_rate": 60, "up_rate": 55, "eta": 53, "seeds": 44, "peers": 49,
|
||||
"ratio": 47, "path": 135, "label": 67, "ratio_group": 87,
|
||||
"down_total": 82, "to_download": 89, "up_total": 44, "created": 150,
|
||||
"priority": 80, "state": 70, "active": 70, "complete": 82, "hashing": 82,
|
||||
"message": 220, "hash": 280,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def recommended_table_columns_json() -> str:
|
||||
return json.dumps(RECOMMENDED_TABLE_COLUMNS, separators=(",", ":"))
|
||||
|
||||
|
||||
def apply_recommended_table_columns(user_id: int | None = None):
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
get_preferences(user_id)
|
||||
now = utcnow()
|
||||
value = recommended_table_columns_json()
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE user_preferences SET table_columns_json=?, updated_at=? WHERE user_id=?",
|
||||
(value, now, user_id),
|
||||
)
|
||||
return get_preferences(user_id)
|
||||
|
||||
def bootstrap_css_url(theme: str | None) -> str:
|
||||
from .frontend_assets import bootstrap_css_path
|
||||
|
||||
return bootstrap_css_path(theme)
|
||||
|
||||
|
||||
def _int_setting(data: dict, key: str, default: int, minimum: int, maximum: int) -> int:
|
||||
try:
|
||||
value = int(data.get(key) if data.get(key) is not None else default)
|
||||
except (TypeError, ValueError):
|
||||
value = default
|
||||
return max(minimum, min(maximum, value))
|
||||
|
||||
|
||||
def list_profiles(user_id: int | None = None):
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
visible = auth.visible_profile_ids(user_id)
|
||||
with connect() as conn:
|
||||
if visible is None:
|
||||
return conn.execute(
|
||||
"SELECT * FROM rtorrent_profiles ORDER BY is_default DESC, name COLLATE NOCASE"
|
||||
).fetchall()
|
||||
if not visible:
|
||||
return []
|
||||
placeholders = ",".join("?" for _ in visible)
|
||||
return conn.execute(
|
||||
f"SELECT * FROM rtorrent_profiles WHERE id IN ({placeholders}) ORDER BY is_default DESC, name COLLATE NOCASE",
|
||||
tuple(visible),
|
||||
).fetchall()
|
||||
|
||||
|
||||
def get_profile(profile_id: int, user_id: int | None = None):
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
if not auth.can_access_profile(profile_id, user_id):
|
||||
return None
|
||||
with connect() as conn:
|
||||
return conn.execute("SELECT * FROM rtorrent_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
|
||||
|
||||
def active_profile(user_id: int | None = None):
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
with connect() as conn:
|
||||
pref = conn.execute("SELECT active_rtorrent_id FROM user_preferences WHERE user_id=?", (user_id,)).fetchone()
|
||||
if pref and pref.get("active_rtorrent_id") and auth.can_access_profile(int(pref["active_rtorrent_id"]), user_id):
|
||||
row = conn.execute("SELECT * FROM rtorrent_profiles WHERE id=?", (pref["active_rtorrent_id"],)).fetchone()
|
||||
if row:
|
||||
return row
|
||||
profiles = list_profiles(user_id)
|
||||
return profiles[0] if profiles else None
|
||||
|
||||
|
||||
def save_profile(data: dict, user_id: int | None = None):
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
now = utcnow()
|
||||
name = str(data.get("name") or "rTorrent").strip()
|
||||
scgi_url = str(data.get("scgi_url") or "").strip()
|
||||
timeout = _int_setting(data, "timeout_seconds", 5, 1, 300)
|
||||
max_parallel = _int_setting(data, "max_parallel_jobs", 5, 1, 64)
|
||||
light_parallel = _int_setting(data, "light_parallel_jobs", 4, 1, 64)
|
||||
light_timeout = _int_setting(data, "light_job_timeout_seconds", 300, 30, 86400)
|
||||
heavy_timeout = _int_setting(data, "heavy_job_timeout_seconds", 7200, 300, 172800)
|
||||
pending_timeout = _int_setting(data, "pending_job_timeout_seconds", 900, 60, 86400)
|
||||
is_remote = 1 if data.get("is_remote") else 0
|
||||
is_default = 1 if data.get("is_default") else 0
|
||||
if not scgi_url.startswith("scgi://"):
|
||||
raise ValueError("SCGI URL must start with scgi://")
|
||||
with connect() as conn:
|
||||
if is_default:
|
||||
conn.execute("UPDATE rtorrent_profiles SET is_default=0 WHERE user_id=?", (user_id,))
|
||||
cur = conn.execute(
|
||||
"INSERT INTO rtorrent_profiles(user_id,name,scgi_url,is_default,timeout_seconds,max_parallel_jobs,light_parallel_jobs,light_job_timeout_seconds,heavy_job_timeout_seconds,pending_job_timeout_seconds,is_remote,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(user_id, name, scgi_url, is_default, timeout, max_parallel, light_parallel, light_timeout, heavy_timeout, pending_timeout, is_remote, now, now),
|
||||
)
|
||||
profile_id = cur.lastrowid
|
||||
pref = conn.execute("SELECT active_rtorrent_id FROM user_preferences WHERE user_id=?", (user_id,)).fetchone()
|
||||
if not pref or not pref.get("active_rtorrent_id") or is_default:
|
||||
conn.execute(
|
||||
"UPDATE user_preferences SET active_rtorrent_id=?, updated_at=? WHERE user_id=?",
|
||||
(profile_id, now, user_id),
|
||||
)
|
||||
return conn.execute("SELECT * FROM rtorrent_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
|
||||
|
||||
def update_profile(profile_id: int, data: dict, user_id: int | None = None):
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
now = utcnow()
|
||||
name = str(data.get("name") or "rTorrent").strip()
|
||||
scgi_url = str(data.get("scgi_url") or "").strip()
|
||||
timeout = _int_setting(data, "timeout_seconds", 5, 1, 300)
|
||||
max_parallel = _int_setting(data, "max_parallel_jobs", 5, 1, 64)
|
||||
light_parallel = _int_setting(data, "light_parallel_jobs", 4, 1, 64)
|
||||
light_timeout = _int_setting(data, "light_job_timeout_seconds", 300, 30, 86400)
|
||||
heavy_timeout = _int_setting(data, "heavy_job_timeout_seconds", 7200, 300, 172800)
|
||||
pending_timeout = _int_setting(data, "pending_job_timeout_seconds", 900, 60, 86400)
|
||||
is_remote = 1 if data.get("is_remote") else 0
|
||||
is_default = 1 if data.get("is_default") else 0
|
||||
if not scgi_url.startswith("scgi://"):
|
||||
raise ValueError("SCGI URL must start with scgi://")
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT id FROM rtorrent_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
if not row or not auth.can_write_profile(profile_id, user_id):
|
||||
raise ValueError("Profil nie istnieje")
|
||||
if is_default:
|
||||
conn.execute("UPDATE rtorrent_profiles SET is_default=0 WHERE user_id=?", (user_id,))
|
||||
conn.execute(
|
||||
"UPDATE rtorrent_profiles SET name=?, scgi_url=?, is_default=?, timeout_seconds=?, max_parallel_jobs=?, light_parallel_jobs=?, light_job_timeout_seconds=?, heavy_job_timeout_seconds=?, pending_job_timeout_seconds=?, is_remote=?, updated_at=? WHERE id=?",
|
||||
(name, scgi_url, is_default, timeout, max_parallel, light_parallel, light_timeout, heavy_timeout, pending_timeout, is_remote, now, profile_id),
|
||||
)
|
||||
return conn.execute("SELECT * FROM rtorrent_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
|
||||
|
||||
def delete_profile(profile_id: int, user_id: int | None = None):
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
auth.require_profile_write(profile_id)
|
||||
with connect() as conn:
|
||||
conn.execute("DELETE FROM rtorrent_profiles WHERE id=?", (profile_id,))
|
||||
active = active_profile(user_id)
|
||||
conn.execute(
|
||||
"UPDATE user_preferences SET active_rtorrent_id=?, updated_at=? WHERE user_id=?",
|
||||
(active["id"] if active else None, utcnow(), user_id),
|
||||
)
|
||||
|
||||
|
||||
def activate_profile(profile_id: int, user_id: int | None = None):
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT id FROM rtorrent_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
if not row or not auth.can_access_profile(profile_id, user_id):
|
||||
raise ValueError("Profil nie istnieje")
|
||||
conn.execute(
|
||||
"UPDATE user_preferences SET active_rtorrent_id=?, updated_at=? WHERE user_id=?",
|
||||
(profile_id, utcnow(), user_id),
|
||||
)
|
||||
return get_profile(profile_id, user_id)
|
||||
|
||||
|
||||
|
||||
def export_profiles(user_id: int | None = None) -> dict:
|
||||
profiles = [dict(row) for row in list_profiles(user_id)]
|
||||
for p in profiles:
|
||||
p.pop("id", None)
|
||||
p.pop("user_id", None)
|
||||
p.pop("created_at", None)
|
||||
p.pop("updated_at", None)
|
||||
return {"version": 1, "profiles": profiles}
|
||||
|
||||
|
||||
def import_profiles(payload: dict, user_id: int | None = None) -> list[dict]:
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
rows = payload.get("profiles") if isinstance(payload, dict) else None
|
||||
if not isinstance(rows, list):
|
||||
raise ValueError("Invalid profiles export")
|
||||
imported = []
|
||||
for item in rows:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
imported.append(dict(save_profile(item, user_id)))
|
||||
return imported
|
||||
|
||||
|
||||
def _active_profile_id_for_user(user_id: int) -> int | None:
|
||||
profile = active_profile(user_id)
|
||||
try:
|
||||
return int(profile["id"]) if profile else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _clean_disk_paths(value) -> list[str]:
|
||||
try:
|
||||
parsed = json.loads(value if isinstance(value, str) else json.dumps(value or []))
|
||||
except Exception:
|
||||
parsed = []
|
||||
if not isinstance(parsed, list):
|
||||
parsed = []
|
||||
clean: list[str] = []
|
||||
for item in parsed:
|
||||
path = str(item or "").strip()
|
||||
if path and path not in clean:
|
||||
clean.append(path)
|
||||
return clean
|
||||
|
||||
|
||||
def _normalize_disk_monitor(data: dict | None) -> dict:
|
||||
data = data or {}
|
||||
mode = str(data.get("mode") or data.get("disk_monitor_mode") or "default")
|
||||
if mode not in {"default", "selected", "aggregate"}:
|
||||
mode = "default"
|
||||
try:
|
||||
threshold = int(data.get("stop_threshold") if data.get("stop_threshold") is not None else data.get("disk_monitor_stop_threshold") or 98)
|
||||
except (TypeError, ValueError):
|
||||
threshold = 98
|
||||
threshold = max(1, min(100, threshold))
|
||||
return {
|
||||
"disk_monitor_paths_json": json.dumps(_clean_disk_paths(data.get("paths_json") if data.get("paths_json") is not None else data.get("disk_monitor_paths_json"))),
|
||||
"disk_monitor_mode": mode,
|
||||
"disk_monitor_selected_path": str(data.get("selected_path") if data.get("selected_path") is not None else data.get("disk_monitor_selected_path") or "").strip(),
|
||||
"disk_monitor_stop_enabled": 1 if (data.get("stop_enabled") if data.get("stop_enabled") is not None else data.get("disk_monitor_stop_enabled")) else 0,
|
||||
"disk_monitor_stop_threshold": threshold,
|
||||
}
|
||||
|
||||
|
||||
def legacy_disk_monitor_preferences(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 * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone() or {}
|
||||
return _normalize_disk_monitor(row)
|
||||
|
||||
|
||||
def get_disk_monitor_preferences(profile_id: int | None = None, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
profile_id = int(profile_id or _active_profile_id_for_user(user_id) or 0)
|
||||
if not profile_id:
|
||||
return legacy_disk_monitor_preferences(user_id)
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT * FROM disk_monitor_preferences WHERE user_id=? AND profile_id=?", (user_id, profile_id)).fetchone()
|
||||
if row:
|
||||
return _normalize_disk_monitor(row)
|
||||
# Backward-compatible seed: existing global disk monitor values become defaults for first use of a profile.
|
||||
return legacy_disk_monitor_preferences(user_id)
|
||||
|
||||
|
||||
def save_disk_monitor_preferences(profile_id: int | None, data: dict, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
profile_id = int(profile_id or _active_profile_id_for_user(user_id) or 0)
|
||||
if not profile_id:
|
||||
return legacy_disk_monitor_preferences(user_id)
|
||||
current = get_disk_monitor_preferences(profile_id, user_id)
|
||||
merged = dict(current)
|
||||
for key in ("disk_monitor_paths_json", "disk_monitor_mode", "disk_monitor_selected_path", "disk_monitor_stop_enabled", "disk_monitor_stop_threshold"):
|
||||
if key in data:
|
||||
merged[key] = data.get(key)
|
||||
clean = _normalize_disk_monitor(merged)
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO disk_monitor_preferences(user_id,profile_id,paths_json,mode,selected_path,stop_enabled,stop_threshold,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?) "
|
||||
"ON CONFLICT(user_id,profile_id) DO UPDATE SET paths_json=excluded.paths_json, mode=excluded.mode, selected_path=excluded.selected_path, stop_enabled=excluded.stop_enabled, stop_threshold=excluded.stop_threshold, updated_at=excluded.updated_at",
|
||||
(user_id, profile_id, clean["disk_monitor_paths_json"], clean["disk_monitor_mode"], clean["disk_monitor_selected_path"], clean["disk_monitor_stop_enabled"], clean["disk_monitor_stop_threshold"], now, now),
|
||||
)
|
||||
return clean
|
||||
|
||||
|
||||
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()
|
||||
with connect() as conn:
|
||||
pref = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone()
|
||||
if not pref:
|
||||
now = utcnow()
|
||||
conn.execute("INSERT INTO user_preferences(user_id, theme, created_at, updated_at) VALUES(?, 'dark', ?, ?)", (user_id, now, now))
|
||||
pref = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone()
|
||||
merged = dict(pref or {})
|
||||
merged.update(get_disk_monitor_preferences(profile_id, user_id))
|
||||
return merged
|
||||
|
||||
|
||||
def save_preferences(data: dict, user_id: int | None = None):
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
allowed_theme = data.get("theme") if data.get("theme") in {"light", "dark"} else None
|
||||
bootstrap_theme = data.get("bootstrap_theme") if data.get("bootstrap_theme") in BOOTSTRAP_THEMES else None
|
||||
font_family = data.get("font_family") if data.get("font_family") in FONT_FAMILIES else None
|
||||
table_columns_json = data.get("table_columns_json")
|
||||
peers_refresh_seconds = data.get("peers_refresh_seconds")
|
||||
port_check_enabled = data.get("port_check_enabled")
|
||||
footer_items_json = data.get("footer_items_json")
|
||||
title_speed_enabled = data.get("title_speed_enabled")
|
||||
tracker_favicons_enabled = data.get("tracker_favicons_enabled")
|
||||
automation_toasts_enabled = data.get("automation_toasts_enabled")
|
||||
smart_queue_toasts_enabled = data.get("smart_queue_toasts_enabled")
|
||||
disk_monitor_paths_json = data.get("disk_monitor_paths_json")
|
||||
disk_monitor_mode = data.get("disk_monitor_mode")
|
||||
disk_monitor_selected_path = data.get("disk_monitor_selected_path")
|
||||
disk_monitor_stop_enabled = data.get("disk_monitor_stop_enabled")
|
||||
disk_monitor_stop_threshold = data.get("disk_monitor_stop_threshold")
|
||||
interface_scale = data.get("interface_scale")
|
||||
detail_panel_height = data.get("detail_panel_height")
|
||||
torrent_sort_json = data.get("torrent_sort_json")
|
||||
active_filter = data.get("active_filter")
|
||||
disk_payload = None
|
||||
if any(value is not None for value in (disk_monitor_paths_json, disk_monitor_mode, disk_monitor_selected_path, disk_monitor_stop_enabled, disk_monitor_stop_threshold)):
|
||||
disk_payload = {
|
||||
"disk_monitor_paths_json": disk_monitor_paths_json,
|
||||
"disk_monitor_mode": disk_monitor_mode,
|
||||
"disk_monitor_selected_path": disk_monitor_selected_path,
|
||||
"disk_monitor_stop_enabled": disk_monitor_stop_enabled,
|
||||
"disk_monitor_stop_threshold": disk_monitor_stop_threshold,
|
||||
}
|
||||
with connect() as conn:
|
||||
now = utcnow()
|
||||
if allowed_theme:
|
||||
conn.execute("UPDATE user_preferences SET theme=?, updated_at=? WHERE user_id=?", (allowed_theme, now, user_id))
|
||||
if bootstrap_theme:
|
||||
conn.execute("UPDATE user_preferences SET bootstrap_theme=?, updated_at=? WHERE user_id=?", (bootstrap_theme, now, user_id))
|
||||
if font_family:
|
||||
conn.execute("UPDATE user_preferences SET font_family=?, updated_at=? WHERE user_id=?", (font_family, now, user_id))
|
||||
if table_columns_json is not None:
|
||||
conn.execute("UPDATE user_preferences SET table_columns_json=?, updated_at=? WHERE user_id=?", (str(table_columns_json), now, user_id))
|
||||
if peers_refresh_seconds is not None:
|
||||
sec = int(peers_refresh_seconds or 0)
|
||||
if sec not in {0, 10, 15, 30, 60}: sec = 0
|
||||
conn.execute("UPDATE user_preferences SET peers_refresh_seconds=?, updated_at=? WHERE user_id=?", (sec, now, user_id))
|
||||
if port_check_enabled is not None:
|
||||
conn.execute("UPDATE user_preferences SET port_check_enabled=?, updated_at=? WHERE user_id=?", (1 if port_check_enabled else 0, now, user_id))
|
||||
if title_speed_enabled is not None:
|
||||
conn.execute("UPDATE user_preferences SET title_speed_enabled=?, updated_at=? WHERE user_id=?", (1 if title_speed_enabled else 0, now, user_id))
|
||||
if tracker_favicons_enabled is not None:
|
||||
conn.execute("UPDATE user_preferences SET tracker_favicons_enabled=?, updated_at=? WHERE user_id=?", (1 if tracker_favicons_enabled else 0, now, user_id))
|
||||
if automation_toasts_enabled is not None:
|
||||
# Note: Lets users silence automation-created toast noise without hiding job/history data.
|
||||
conn.execute("UPDATE user_preferences SET automation_toasts_enabled=?, updated_at=? WHERE user_id=?", (1 if automation_toasts_enabled else 0, now, user_id))
|
||||
if smart_queue_toasts_enabled is not None:
|
||||
# Note: Smart Queue toast noise can be disabled independently from automation notifications.
|
||||
conn.execute("UPDATE user_preferences SET smart_queue_toasts_enabled=?, updated_at=? WHERE user_id=?", (1 if smart_queue_toasts_enabled else 0, now, user_id))
|
||||
if interface_scale is not None:
|
||||
scale = int(interface_scale or 100)
|
||||
if scale < 80: scale = 80
|
||||
if scale > 140: scale = 140
|
||||
conn.execute("UPDATE user_preferences SET interface_scale=?, updated_at=? WHERE user_id=?", (scale, now, user_id))
|
||||
if footer_items_json is not None:
|
||||
# Note: Store only JSON objects so footer visibility can be extended without schema churn.
|
||||
value = footer_items_json if isinstance(footer_items_json, str) else json.dumps(footer_items_json)
|
||||
parsed = json.loads(value or "{}")
|
||||
if not isinstance(parsed, dict):
|
||||
parsed = {}
|
||||
conn.execute("UPDATE user_preferences SET footer_items_json=?, updated_at=? WHERE user_id=?", (json.dumps(parsed), now, user_id))
|
||||
if detail_panel_height is not None:
|
||||
try:
|
||||
height = int(detail_panel_height or 255)
|
||||
except (TypeError, ValueError):
|
||||
height = 255
|
||||
if height < 160: height = 160
|
||||
if height > 720: height = 720
|
||||
conn.execute("UPDATE user_preferences SET detail_panel_height=?, updated_at=? WHERE user_id=?", (height, now, user_id))
|
||||
if torrent_sort_json is not None:
|
||||
# Note: Persist only a compact sort object; unknown keys are ignored on the client.
|
||||
value = torrent_sort_json if isinstance(torrent_sort_json, str) else json.dumps(torrent_sort_json)
|
||||
parsed = json.loads(value or "{}")
|
||||
if not isinstance(parsed, dict):
|
||||
parsed = {}
|
||||
try:
|
||||
direction = int(parsed.get("dir") or 1)
|
||||
except (TypeError, ValueError):
|
||||
direction = 1
|
||||
allowed_sort_keys = {"name", "status", "size", "progress", "down_rate", "up_rate", "eta", "seeds", "peers", "ratio", "path", "label", "ratio_group", "down_total", "to_download", "up_total", "created", "priority", "state", "active", "complete", "hashing", "message", "hash"}
|
||||
sort_key = str(parsed.get("key") or "name")
|
||||
if sort_key not in allowed_sort_keys:
|
||||
sort_key = "name"
|
||||
clean = {"key": sort_key, "dir": 1 if direction >= 0 else -1}
|
||||
conn.execute("UPDATE user_preferences SET torrent_sort_json=?, updated_at=? WHERE user_id=?", (json.dumps(clean), now, user_id))
|
||||
if active_filter is not None:
|
||||
value = str(active_filter or "all").strip()
|
||||
if not value or len(value) > 180:
|
||||
value = "all"
|
||||
allowed_static_filters = {"all", "downloading", "seeding", "paused", "checking", "error", "stopped", "moving"}
|
||||
if value not in allowed_static_filters and not value.startswith("label:") and not value.startswith("tracker:"):
|
||||
value = "all"
|
||||
conn.execute("UPDATE user_preferences SET active_filter=?, updated_at=? WHERE user_id=?", (value, now, user_id))
|
||||
if disk_payload is not None:
|
||||
save_disk_monitor_preferences(_active_profile_id_for_user(user_id), disk_payload, user_id)
|
||||
return get_preferences(user_id)
|
||||
146
pytorrent/services/ratio_rules.py
Normal file
146
pytorrent/services/ratio_rules.py
Normal file
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..db import connect, utcnow, default_user_id
|
||||
from . import rtorrent
|
||||
from .workers import enqueue
|
||||
|
||||
|
||||
def _age_minutes_from_epoch(value) -> int:
|
||||
try:
|
||||
created = datetime.fromtimestamp(int(value or 0), timezone.utc)
|
||||
return max(0, int((datetime.now(timezone.utc) - created).total_seconds() // 60))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _is_private(profile: dict, torrent_hash: str) -> bool:
|
||||
try:
|
||||
value = rtorrent.client_for(profile).call("d.is_private", torrent_hash)
|
||||
return bool(int(value or 0))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _group_for_torrent(groups_by_name: dict[str, dict], torrent: dict) -> dict | None:
|
||||
name = str(torrent.get("ratio_group") or "").strip()
|
||||
return groups_by_name.get(name) if name else None
|
||||
|
||||
|
||||
def _record(user_id: int, profile_id: int, group: dict, torrent: dict, action: str, status: str, reason: str, details: dict | None = None) -> None:
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO ratio_history(user_id,profile_id,group_id,group_name,torrent_hash,torrent_name,action,status,reason,details_json,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(user_id, profile_id, group.get("id"), group.get("name"), torrent.get("hash"), torrent.get("name"), action, status, reason, json.dumps(details or {}), now),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO ratio_assignments(profile_id,torrent_hash,group_id,group_name,applied_at,last_status,updated_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(profile_id,torrent_hash) DO UPDATE SET group_id=excluded.group_id,group_name=excluded.group_name,applied_at=excluded.applied_at,last_status=excluded.last_status,updated_at=excluded.updated_at",
|
||||
(profile_id, torrent.get("hash"), group.get("id"), group.get("name"), now if status == "applied" else None, status, now),
|
||||
)
|
||||
|
||||
|
||||
def _should_apply(profile: dict, group: dict, torrent: dict) -> tuple[bool, str]:
|
||||
if not int(group.get("enabled") or 0):
|
||||
return False, "group disabled"
|
||||
if not torrent.get("complete"):
|
||||
return False, "torrent is not complete"
|
||||
if int(group.get("ignore_private") or 0) and _is_private(profile, torrent["hash"]):
|
||||
return False, "private torrent is excluded"
|
||||
min_ratio = float(group.get("min_ratio") or 0)
|
||||
max_ratio = float(group.get("max_ratio") or 0)
|
||||
wanted_ratio = max(min_ratio, max_ratio)
|
||||
seed_time = max(int(group.get("seed_time_minutes") or 0), int(group.get("min_seed_time_minutes") or 0))
|
||||
ratio_ok = float(torrent.get("ratio") or 0) >= wanted_ratio if wanted_ratio else True
|
||||
seed_ok = _age_minutes_from_epoch(torrent.get("created")) >= seed_time if seed_time else True
|
||||
if not ratio_ok:
|
||||
return False, "ratio threshold not reached"
|
||||
if not seed_ok:
|
||||
return False, "minimum seed time not reached"
|
||||
min_upload = int(group.get("active_upload_min_bytes") or 1024)
|
||||
if int(group.get("ignore_active_upload") or 0) and int(torrent.get("up_rate") or 0) >= min_upload:
|
||||
return False, "active upload is above exception threshold"
|
||||
return True, "ratio rule applied"
|
||||
|
||||
|
||||
def check(profile: dict, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or default_user_id()
|
||||
profile_id = int(profile["id"])
|
||||
with connect() as conn:
|
||||
groups = conn.execute("SELECT * FROM ratio_groups WHERE user_id=? AND profile_id=? AND enabled=1", (user_id, profile_id)).fetchall()
|
||||
already = {row["torrent_hash"] for row in conn.execute("SELECT torrent_hash FROM ratio_assignments WHERE profile_id=? AND last_status='applied'", (profile_id,)).fetchall()}
|
||||
groups_by_name = {str(g.get("name") or ""): g for g in groups}
|
||||
applied = 0
|
||||
skipped = 0
|
||||
queued_jobs = []
|
||||
for torrent in rtorrent.list_torrents(profile):
|
||||
group = _group_for_torrent(groups_by_name, torrent)
|
||||
if not group:
|
||||
continue
|
||||
if torrent.get("hash") in already:
|
||||
skipped += 1
|
||||
continue
|
||||
ok, reason = _should_apply(profile, group, torrent)
|
||||
if not ok:
|
||||
skipped += 1
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO ratio_assignments(profile_id,torrent_hash,group_id,group_name,last_status,updated_at) VALUES(?,?,?,?,?,?) ON CONFLICT(profile_id,torrent_hash) DO UPDATE SET group_id=excluded.group_id,group_name=excluded.group_name,last_status=excluded.last_status,updated_at=excluded.updated_at",
|
||||
(profile_id, torrent.get("hash"), group.get("id"), group.get("name"), reason, utcnow()),
|
||||
)
|
||||
continue
|
||||
action = str(group.get("action") or "stop")
|
||||
payload = {"hashes": [torrent["hash"]], "source": "ratio", "job_context": {"source": "ratio", "rule_name": group.get("name"), "hash_count": 1}}
|
||||
if action == "remove_data":
|
||||
api_action = "remove"
|
||||
payload["remove_data"] = True
|
||||
elif action == "move":
|
||||
api_action = "move"
|
||||
payload.update({"path": group.get("move_path") or torrent.get("path") or "", "move_data": True, "recheck": False, "keep_seeding": False})
|
||||
elif action == "set_label":
|
||||
api_action = "set_label"
|
||||
payload["label"] = group.get("set_label") or group.get("name") or ""
|
||||
else:
|
||||
api_action = action if action in {"stop", "remove", "pause"} else "stop"
|
||||
job_id = enqueue(api_action, profile_id, payload, user_id=user_id)
|
||||
queued_jobs.append(job_id)
|
||||
applied += 1
|
||||
_record(user_id, profile_id, group, torrent, action, "applied", reason, {"job_id": job_id, "api_action": api_action})
|
||||
return {"applied": applied, "skipped": skipped, "job_ids": queued_jobs}
|
||||
|
||||
|
||||
_scheduler_started = False
|
||||
|
||||
|
||||
def start_scheduler(socketio=None) -> None:
|
||||
global _scheduler_started
|
||||
if _scheduler_started:
|
||||
return
|
||||
_scheduler_started = True
|
||||
|
||||
def loop() -> None:
|
||||
# Note: Ratio rules are evaluated periodically and actions are executed through the existing safe job queue.
|
||||
while True:
|
||||
try:
|
||||
from .preferences import get_profile
|
||||
with connect() as conn:
|
||||
profiles = conn.execute("SELECT DISTINCT user_id, profile_id FROM ratio_groups WHERE enabled=1 AND profile_id IS NOT NULL").fetchall()
|
||||
for row in profiles:
|
||||
profile = get_profile(int(row["profile_id"]), int(row["user_id"]))
|
||||
if not profile:
|
||||
continue
|
||||
result = check(profile, int(row["user_id"]))
|
||||
if socketio and result.get("applied"):
|
||||
socketio.emit("ratio_rules_checked", {"profile_id": profile["id"], **result}, to=f"profile:{profile['id']}")
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(300)
|
||||
|
||||
if socketio:
|
||||
socketio.start_background_task(loop)
|
||||
else:
|
||||
import threading
|
||||
threading.Thread(target=loop, daemon=True, name="pytorrent-ratio-scheduler").start()
|
||||
49
pytorrent/services/retention.py
Normal file
49
pytorrent/services/retention.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from ..config import JOBS_RETENTION_DAYS, LOG_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, TRAFFIC_HISTORY_RETENTION_DAYS
|
||||
from ..db import connect
|
||||
|
||||
_LAST_CLEANUP = 0.0
|
||||
CLEANUP_EVERY_SECONDS = 3600
|
||||
|
||||
|
||||
def _cutoff(days: int) -> str:
|
||||
return (datetime.now(timezone.utc) - timedelta(days=max(1, int(days or 1)))).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _table_exists(conn, table: str) -> bool:
|
||||
row = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def cleanup(force: bool = False) -> dict[str, int]:
|
||||
global _LAST_CLEANUP
|
||||
now_ts = datetime.now(timezone.utc).timestamp()
|
||||
if not force and now_ts - _LAST_CLEANUP < CLEANUP_EVERY_SECONDS:
|
||||
return {}
|
||||
_LAST_CLEANUP = now_ts
|
||||
|
||||
deleted: dict[str, int] = {}
|
||||
with connect() as conn:
|
||||
targets = {
|
||||
"traffic_history": ("created_at", TRAFFIC_HISTORY_RETENTION_DAYS),
|
||||
"smart_queue_history": ("created_at", SMART_QUEUE_HISTORY_RETENTION_DAYS),
|
||||
# Note: Automation history follows Smart Queue retention; rules and rule state are never deleted here.
|
||||
"automation_history": ("created_at", SMART_QUEUE_HISTORY_RETENTION_DAYS),
|
||||
"jobs": ("updated_at", JOBS_RETENTION_DAYS),
|
||||
"logs": ("created_at", LOG_RETENTION_DAYS),
|
||||
}
|
||||
for table, (column, days) in targets.items():
|
||||
if not _table_exists(conn, table):
|
||||
continue
|
||||
if table == "jobs":
|
||||
cur = conn.execute(
|
||||
f"DELETE FROM {table} WHERE {column} < ? AND status IN ('done','failed','cancelled')",
|
||||
(_cutoff(days),),
|
||||
)
|
||||
else:
|
||||
cur = conn.execute(f"DELETE FROM {table} WHERE {column} < ?", (_cutoff(days),))
|
||||
deleted[table] = int(cur.rowcount or 0)
|
||||
return deleted
|
||||
218
pytorrent/services/rss.py
Normal file
218
pytorrent/services/rss.py
Normal file
@@ -0,0 +1,218 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from email.utils import parsedate_to_datetime
|
||||
from typing import Iterable
|
||||
|
||||
from ..db import connect, utcnow, default_user_id
|
||||
from . import rtorrent
|
||||
from .workers import enqueue
|
||||
|
||||
RSS_FETCH_LIMIT = 2_000_000
|
||||
|
||||
|
||||
def _parse_dt(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return parsedate_to_datetime(value).astimezone(timezone.utc)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _item_size(item: ET.Element) -> int:
|
||||
enc = item.find("enclosure")
|
||||
if enc is not None:
|
||||
try:
|
||||
return int(enc.get("length") or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
for tag in ("size", "length"):
|
||||
try:
|
||||
return int(item.findtext(tag) or 0)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
def _item_category(item: ET.Element) -> str:
|
||||
values = [x.text or "" for x in item.findall("category")]
|
||||
return " ".join(values).strip()
|
||||
|
||||
|
||||
def parse_feed(raw: bytes) -> list[dict]:
|
||||
root = ET.fromstring(raw)
|
||||
items = root.findall(".//item")
|
||||
if not items and root.tag.lower().endswith("feed"):
|
||||
items = root.findall("{http://www.w3.org/2005/Atom}entry")
|
||||
parsed: list[dict] = []
|
||||
for item in items[:200]:
|
||||
title = item.findtext("title") or item.findtext("{http://www.w3.org/2005/Atom}title") or ""
|
||||
link = item.findtext("link") or ""
|
||||
atom_link = item.find("{http://www.w3.org/2005/Atom}link")
|
||||
if atom_link is not None and atom_link.get("href"):
|
||||
link = atom_link.get("href") or link
|
||||
enc = item.find("enclosure")
|
||||
if enc is not None and enc.get("url"):
|
||||
link = enc.get("url") or link
|
||||
pub_date = item.findtext("pubDate") or item.findtext("updated") or item.findtext("{http://www.w3.org/2005/Atom}updated")
|
||||
parsed.append({
|
||||
"title": title.strip(),
|
||||
"link": str(link or "").strip(),
|
||||
"size": _item_size(item),
|
||||
"category": _item_category(item),
|
||||
"published_at": _parse_dt(pub_date).isoformat(timespec="seconds") if _parse_dt(pub_date) else None,
|
||||
})
|
||||
return parsed
|
||||
|
||||
|
||||
def fetch_feed(url: str) -> list[dict]:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "pyTorrent RSS"})
|
||||
with urllib.request.urlopen(req, timeout=12) as res:
|
||||
raw = res.read(RSS_FETCH_LIMIT)
|
||||
return parse_feed(raw)
|
||||
|
||||
|
||||
def _season_episode(title: str) -> tuple[int | None, int | None]:
|
||||
match = re.search(r"S(\d{1,2})E(\d{1,3})", title or "", re.I)
|
||||
if match:
|
||||
return int(match.group(1)), int(match.group(2))
|
||||
match = re.search(r"\b(\d{1,2})x(\d{1,3})\b", title or "", re.I)
|
||||
if match:
|
||||
return int(match.group(1)), int(match.group(2))
|
||||
return None, None
|
||||
|
||||
|
||||
def matches_rule(rule: dict, item: dict) -> tuple[bool, str]:
|
||||
title = str(item.get("title") or "")
|
||||
haystack = " ".join([title, str(item.get("category") or "")])
|
||||
pattern = str(rule.get("pattern") or ".*")
|
||||
exclude = str(rule.get("exclude_pattern") or "").strip()
|
||||
try:
|
||||
if pattern and not re.search(pattern, haystack, re.I):
|
||||
return False, "include pattern did not match"
|
||||
if exclude and re.search(exclude, haystack, re.I):
|
||||
return False, "exclude pattern matched"
|
||||
except re.error as exc:
|
||||
return False, f"invalid regex: {exc}"
|
||||
size_mb = (int(item.get("size") or 0) / 1024 / 1024) if item.get("size") else 0
|
||||
min_size = int(rule.get("min_size_mb") or 0)
|
||||
max_size = int(rule.get("max_size_mb") or 0)
|
||||
if min_size and size_mb and size_mb < min_size:
|
||||
return False, "item is below minimum size"
|
||||
if max_size and size_mb and size_mb > max_size:
|
||||
return False, "item is above maximum size"
|
||||
category = str(rule.get("category") or "").strip().lower()
|
||||
if category and category not in str(item.get("category") or "").lower() and category not in title.lower():
|
||||
return False, "category did not match"
|
||||
quality = str(rule.get("quality") or "").strip().lower()
|
||||
if quality and quality not in title.lower():
|
||||
return False, "quality did not match"
|
||||
wanted_season = rule.get("season")
|
||||
wanted_episode = rule.get("episode")
|
||||
found_season, found_episode = _season_episode(title)
|
||||
if wanted_season not in (None, "", 0) and int(wanted_season) != int(found_season or -1):
|
||||
return False, "season did not match"
|
||||
if wanted_episode not in (None, "", 0) and int(wanted_episode) != int(found_episode or -1):
|
||||
return False, "episode did not match"
|
||||
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:
|
||||
with connect() as conn:
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO rss_history(user_id,profile_id,feed_id,rule_id,title,link,status,message,created_at) VALUES(?,?,?,?,?,?,?,?,?)",
|
||||
(user_id, profile_id, feed_id, rule_id, item.get("title"), item.get("link"), status, message, utcnow()),
|
||||
)
|
||||
except Exception:
|
||||
# Note: Duplicate successful RSS matches are ignored to prevent recurring duplicate downloads.
|
||||
pass
|
||||
|
||||
|
||||
def check(profile: dict, user_id: int | None = None, only_due: bool = False) -> dict:
|
||||
user_id = user_id or default_user_id()
|
||||
profile_id = int(profile["id"])
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
if only_due:
|
||||
feeds = conn.execute("SELECT * FROM rss_feeds WHERE user_id=? AND profile_id=? AND enabled=1 AND (next_check_at IS NULL OR next_check_at<=?)", (user_id, profile_id, now)).fetchall()
|
||||
else:
|
||||
feeds = conn.execute("SELECT * FROM rss_feeds WHERE user_id=? AND profile_id=? AND enabled=1", (user_id, profile_id)).fetchall()
|
||||
rules = conn.execute("SELECT * FROM rss_rules WHERE user_id=? AND profile_id=? AND enabled=1", (user_id, profile_id)).fetchall()
|
||||
queued = 0
|
||||
tested = 0
|
||||
errors: list[dict] = []
|
||||
for feed in feeds:
|
||||
interval = max(5, int(feed.get("interval_minutes") or 30))
|
||||
next_check = (datetime.now(timezone.utc) + timedelta(minutes=interval)).isoformat(timespec="seconds")
|
||||
try:
|
||||
items = fetch_feed(feed["url"])
|
||||
for item in items:
|
||||
for rule in rules:
|
||||
matched, reason = matches_rule(rule, item)
|
||||
tested += 1
|
||||
if not matched:
|
||||
continue
|
||||
link = item.get("link") or ""
|
||||
if not link:
|
||||
_log(user_id, profile_id, feed["id"], rule["id"], item, "skipped", "missing link")
|
||||
continue
|
||||
enqueue("add_magnet", profile_id, {"uri": link, "start": bool(rule["start"]), "directory": rule.get("save_path") or rtorrent.default_download_path(profile), "label": rule.get("label") or "", "source": "rss"}, user_id=user_id)
|
||||
queued += 1
|
||||
_log(user_id, profile_id, feed["id"], rule["id"], item, "queued", reason)
|
||||
with connect() as conn:
|
||||
conn.execute("UPDATE rss_feeds SET last_error=NULL,last_checked_at=?,next_check_at=?,updated_at=? WHERE id=?", (now, next_check, now, feed["id"]))
|
||||
except Exception as exc:
|
||||
errors.append({"feed_id": feed.get("id"), "error": str(exc)})
|
||||
with connect() as conn:
|
||||
conn.execute("UPDATE rss_feeds SET last_error=?,last_checked_at=?,next_check_at=?,updated_at=? WHERE id=?", (str(exc), now, next_check, now, feed["id"]))
|
||||
return {"queued": queued, "tested": tested, "feeds_checked": len(feeds), "errors": errors}
|
||||
|
||||
|
||||
def test_rule(feed_url: str, rule: dict) -> dict:
|
||||
items = fetch_feed(feed_url)
|
||||
matches = []
|
||||
rejected = []
|
||||
for item in items[:100]:
|
||||
matched, reason = matches_rule(rule, item)
|
||||
target = matches if matched else rejected
|
||||
target.append({**item, "reason": reason})
|
||||
return {"matches": matches[:50], "rejected": rejected[:50], "total": len(items)}
|
||||
|
||||
|
||||
_scheduler_started = False
|
||||
|
||||
|
||||
def start_scheduler(socketio=None) -> None:
|
||||
global _scheduler_started
|
||||
if _scheduler_started:
|
||||
return
|
||||
_scheduler_started = True
|
||||
|
||||
def loop() -> None:
|
||||
# Note: The lightweight RSS scheduler uses persisted next_check_at values, so restarts do not reset cadence.
|
||||
while True:
|
||||
try:
|
||||
from .preferences import get_profile
|
||||
with connect() as conn:
|
||||
profiles = conn.execute("SELECT DISTINCT user_id, profile_id FROM rss_feeds WHERE enabled=1 AND profile_id IS NOT NULL").fetchall()
|
||||
for row in profiles:
|
||||
profile = get_profile(int(row["profile_id"]), int(row["user_id"]))
|
||||
if profile:
|
||||
result = check(profile, int(row["user_id"]), only_due=True)
|
||||
if socketio and result.get("queued"):
|
||||
socketio.emit("rss_checked", {"profile_id": profile["id"], **result}, to=f"profile:{profile['id']}")
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(60)
|
||||
|
||||
if socketio:
|
||||
socketio.start_background_task(loop)
|
||||
else:
|
||||
import threading
|
||||
threading.Thread(target=loop, daemon=True, name="pytorrent-rss-scheduler").start()
|
||||
10
pytorrent/services/rtorrent/README.md
Normal file
10
pytorrent/services/rtorrent/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# rTorrent service modules
|
||||
|
||||
The old `pytorrent/services/rtorrent.py` monolith is end-of-life.
|
||||
Do not recreate it and do not add new rTorrent logic outside this directory.
|
||||
|
||||
Use focused modules in `pytorrent/services/rtorrent/` instead:
|
||||
- `client.py` for SCGI/XMLRPC transport and shared caches.
|
||||
- `system.py` for status, footer metrics, disk and remote host usage.
|
||||
- `torrents.py` for torrent list and torrent operations.
|
||||
- `files.py`, `config.py`, `diagnostics.py` for their dedicated areas.
|
||||
14
pytorrent/services/rtorrent/__init__.py
Normal file
14
pytorrent/services/rtorrent/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# EOL note: do not recreate or edit the old pytorrent/services/rtorrent.py monolith.
|
||||
# All rTorrent code belongs in this package directory.
|
||||
|
||||
# Note: Public functions are re-exported here so existing imports from services.rtorrent remain transparent.
|
||||
# Compatibility note: module __all__ definitions include selected private helpers used by existing routes.
|
||||
from .client import *
|
||||
from .system import *
|
||||
from .diagnostics import *
|
||||
from .files import *
|
||||
from .config import *
|
||||
from .torrents import *
|
||||
from .chunks import *
|
||||
207
pytorrent/services/rtorrent/chunks.py
Normal file
207
pytorrent/services/rtorrent/chunks.py
Normal file
@@ -0,0 +1,207 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from .client import *
|
||||
from .files import set_file_priorities
|
||||
|
||||
|
||||
_HEX_RE = re.compile(r"[0-9a-fA-F]")
|
||||
|
||||
|
||||
def _clean_hex_bitfield(value) -> str:
|
||||
"""Return only hexadecimal bitfield characters from rTorrent output."""
|
||||
# Note: rTorrent may return spacing or non-hex separators; keep only the actual bitfield payload.
|
||||
return "".join(_HEX_RE.findall(str(value or ""))).lower()
|
||||
|
||||
|
||||
def _hex_to_bits(value: str, limit: int | None = None) -> list[int]:
|
||||
"""Decode an rTorrent hex bitfield into one bit per torrent piece."""
|
||||
# Note: d.bitfield is a packed bitset, not a per-nibble completion percentage; decoding fixes false partial cells near 100% torrents.
|
||||
bits: list[int] = []
|
||||
for char in _clean_hex_bitfield(value):
|
||||
nibble = int(char, 16)
|
||||
bits.extend([
|
||||
1 if nibble & 0b1000 else 0,
|
||||
1 if nibble & 0b0100 else 0,
|
||||
1 if nibble & 0b0010 else 0,
|
||||
1 if nibble & 0b0001 else 0,
|
||||
])
|
||||
if limit is not None and limit >= 0:
|
||||
if len(bits) < limit:
|
||||
bits.extend([0] * (limit - len(bits)))
|
||||
return bits[:limit]
|
||||
return bits
|
||||
|
||||
|
||||
def _chunk_status(completed: int, total: int, seen: bool = False) -> str:
|
||||
"""Classify a visual chunk cell for CSS and filtering."""
|
||||
if total <= 0:
|
||||
return "missing"
|
||||
if completed >= total:
|
||||
return "complete"
|
||||
if completed <= 0:
|
||||
return "seen" if seen else "missing"
|
||||
return "partial"
|
||||
|
||||
|
||||
def _group_cells(cells: list[dict], max_cells: int) -> list[dict]:
|
||||
"""Reduce very large torrents to a browser-friendly number of visual cells."""
|
||||
# Note: Grouping now happens on real piece states, so the aggregated percentage matches the actual torrent progress.
|
||||
if max_cells <= 0 or len(cells) <= max_cells:
|
||||
return cells
|
||||
grouped: list[dict] = []
|
||||
scale = len(cells) / float(max_cells)
|
||||
for out_idx in range(max_cells):
|
||||
start = int(math.floor(out_idx * scale))
|
||||
end = int(math.floor((out_idx + 1) * scale))
|
||||
part = cells[start:max(end, start + 1)]
|
||||
if not part:
|
||||
continue
|
||||
completed = sum(int(c.get("completed") or 0) for c in part)
|
||||
total = sum(int(c.get("total") or 0) for c in part)
|
||||
seen = any(bool(c.get("seen")) for c in part)
|
||||
percent = round((completed / total) * 100.0, 2) if total > 0 else 0.0
|
||||
grouped.append({
|
||||
"index": out_idx,
|
||||
"first_chunk": int(part[0].get("first_chunk", 0)),
|
||||
"last_chunk": int(part[-1].get("last_chunk", 0)),
|
||||
"completed": completed,
|
||||
"total": total,
|
||||
"percent": percent,
|
||||
"seen": seen,
|
||||
"status": _chunk_status(completed, total, seen),
|
||||
"grouped": True,
|
||||
"unit_count": len(part),
|
||||
})
|
||||
return grouped
|
||||
|
||||
|
||||
def _build_piece_cells(total_chunks: int, have_bits: list[int], seen_bits: list[int]) -> list[dict]:
|
||||
"""Create one raw cell per real torrent piece."""
|
||||
# Note: The UI still groups these cells later when needed, but the source data remains exact per piece.
|
||||
cells: list[dict] = []
|
||||
for idx in range(max(0, int(total_chunks or 0))):
|
||||
completed = 1 if idx < len(have_bits) and have_bits[idx] else 0
|
||||
seen = idx < len(seen_bits) and bool(seen_bits[idx])
|
||||
cells.append({
|
||||
"index": idx,
|
||||
"first_chunk": idx,
|
||||
"last_chunk": idx,
|
||||
"completed": completed,
|
||||
"total": 1,
|
||||
"percent": 100.0 if completed else 0.0,
|
||||
"seen": seen,
|
||||
"status": _chunk_status(completed, 1, seen),
|
||||
"grouped": False,
|
||||
"unit_count": 1,
|
||||
})
|
||||
return cells
|
||||
|
||||
|
||||
def torrent_chunks(profile: dict, torrent_hash: str, max_cells: int = 2048) -> dict:
|
||||
"""Return ruTorrent-like visual chunk data for one torrent."""
|
||||
# Note: Uses documented rTorrent XML-RPC fields: d.bitfield, d.chunks_seen, d.chunk_size and d.size_chunks.
|
||||
c = client_for(profile)
|
||||
values = {
|
||||
"bitfield": _clean_hex_bitfield(c.call("d.bitfield", torrent_hash)),
|
||||
"seen": "",
|
||||
"chunk_size": 0,
|
||||
"size_chunks": 0,
|
||||
"completed_chunks": 0,
|
||||
"chunks_hashed": 0,
|
||||
}
|
||||
optional_calls = {
|
||||
"seen": "d.chunks_seen",
|
||||
"chunk_size": "d.chunk_size",
|
||||
"size_chunks": "d.size_chunks",
|
||||
"completed_chunks": "d.completed_chunks",
|
||||
"chunks_hashed": "d.chunks_hashed",
|
||||
}
|
||||
for key, method in optional_calls.items():
|
||||
try:
|
||||
raw = c.call(method, torrent_hash)
|
||||
values[key] = _clean_hex_bitfield(raw) if key == "seen" else int(raw or 0)
|
||||
except Exception:
|
||||
values[key] = "" if key == "seen" else 0
|
||||
|
||||
total_chunks = int(values["size_chunks"] or 0)
|
||||
completed = int(values["completed_chunks"] or 0)
|
||||
if total_chunks <= 0:
|
||||
total_chunks = max(completed, len(values["bitfield"]) * 4)
|
||||
|
||||
have_bits = _hex_to_bits(values["bitfield"], total_chunks)
|
||||
seen_bits = _hex_to_bits(values["seen"], total_chunks)
|
||||
cells = _build_piece_cells(total_chunks, have_bits, seen_bits)
|
||||
|
||||
visual_cells = _group_cells(cells, max(64, min(10000, int(max_cells or 2048))))
|
||||
return {
|
||||
"hash": torrent_hash,
|
||||
"chunk_size": int(values["chunk_size"] or 0),
|
||||
"chunk_size_h": human_size(values["chunk_size"] or 0),
|
||||
"size_chunks": total_chunks,
|
||||
"completed_chunks": completed,
|
||||
"chunks_hashed": int(values["chunks_hashed"] or 0),
|
||||
"bitfield_units": len(have_bits),
|
||||
"visual_cells": len(visual_cells),
|
||||
"grouped": len(visual_cells) != len(cells),
|
||||
"cells": visual_cells,
|
||||
"summary": {
|
||||
"complete": sum(1 for c in visual_cells if c.get("status") == "complete"),
|
||||
"partial": sum(1 for c in visual_cells if c.get("status") == "partial"),
|
||||
"missing": sum(1 for c in visual_cells if c.get("status") == "missing"),
|
||||
"seen": sum(1 for c in visual_cells if c.get("status") == "seen"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _files_touching_chunks(c: ScgiRtorrentClient, torrent_hash: str, first_chunk: int, last_chunk: int) -> list[dict]:
|
||||
"""Find files whose rTorrent chunk range overlaps the selected visual cells."""
|
||||
# Note: rTorrent exposes file chunk coverage through f.range_first and f.range_second; the second value is exclusive.
|
||||
rows = c.f.multicall(torrent_hash, "", "f.path=", "f.range_first=", "f.range_second=", "f.priority=")
|
||||
matches = []
|
||||
for idx, row in enumerate(rows):
|
||||
start = int(row[1] or 0)
|
||||
end_exclusive = int(row[2] or 0)
|
||||
end = max(start, end_exclusive - 1)
|
||||
if start <= last_chunk and end >= first_chunk:
|
||||
matches.append({
|
||||
"index": idx,
|
||||
"path": str(row[0] or ""),
|
||||
"range_first": start,
|
||||
"range_second": end_exclusive,
|
||||
"priority": int(row[3] or 0),
|
||||
})
|
||||
return matches
|
||||
|
||||
|
||||
def torrent_chunk_action(profile: dict, torrent_hash: str, action: str, payload: dict | None = None) -> dict:
|
||||
"""Run safe actions related to visual chunk selection."""
|
||||
# Note: rTorrent does not expose a supported XML-RPC method to redownload one arbitrary chunk; recheck is torrent-wide.
|
||||
payload = payload or {}
|
||||
action = str(action or "").strip().lower()
|
||||
c = client_for(profile)
|
||||
if action == "recheck":
|
||||
c.call("d.check_hash", torrent_hash)
|
||||
return {"action": action, "message": "Torrent hash check queued", "scope": "torrent"}
|
||||
if action == "prioritize_files":
|
||||
first_chunk = max(0, int(payload.get("first_chunk") or 0))
|
||||
last_chunk = max(first_chunk, int(payload.get("last_chunk") if payload.get("last_chunk") is not None else first_chunk))
|
||||
priority = max(0, min(3, int(payload.get("priority") or 2)))
|
||||
matches = _files_touching_chunks(c, torrent_hash, first_chunk, last_chunk)
|
||||
if not matches:
|
||||
return {"action": action, "updated": [], "errors": [{"error": "No files overlap selected chunk range"}]}
|
||||
result = set_file_priorities(profile, torrent_hash, [{"index": m["index"], "priority": priority} for m in matches])
|
||||
try:
|
||||
c.call("d.update_priorities", torrent_hash)
|
||||
except Exception:
|
||||
pass
|
||||
result.update({"action": action, "files": matches, "priority": priority, "first_chunk": first_chunk, "last_chunk": last_chunk})
|
||||
return result
|
||||
raise ValueError("Unknown chunk action")
|
||||
|
||||
|
||||
__all__ = [
|
||||
name for name in globals()
|
||||
if not name.startswith("__") and name not in {"annotations"}
|
||||
]
|
||||
364
pytorrent/services/rtorrent/client.py
Normal file
364
pytorrent/services/rtorrent/client.py
Normal file
@@ -0,0 +1,364 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import os
|
||||
import posixpath
|
||||
import socket
|
||||
import time
|
||||
import uuid
|
||||
from urllib.parse import urlparse
|
||||
from xmlrpc.client import Binary, dumps, loads
|
||||
from pathlib import Path as LocalPath
|
||||
from ...utils import human_rate, human_size
|
||||
from ...db import connect, default_user_id, utcnow
|
||||
from ...config import PYTORRENT_TMP_DIR, REMOTE_READ_CHUNK_BYTES
|
||||
|
||||
|
||||
class ScgiMethod:
|
||||
def __init__(self, client: "ScgiRtorrentClient", name: str):
|
||||
self.client = client
|
||||
self.name = name
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
return ScgiMethod(self.client, f"{self.name}.{name}")
|
||||
|
||||
def __call__(self, *args):
|
||||
return self.client.call(self.name, *args)
|
||||
|
||||
|
||||
class ScgiRtorrentClient:
|
||||
"""XML-RPC over SCGI client for rTorrent network.scgi.open_port."""
|
||||
|
||||
def __init__(self, url: str, timeout: int = 5):
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme != "scgi":
|
||||
raise ValueError("SCGI URL must start with scgi://")
|
||||
if not parsed.hostname or not parsed.port:
|
||||
raise ValueError("SCGI URL must include host and port, e.g. scgi://127.0.0.1:5000/RPC2")
|
||||
self.host = parsed.hostname
|
||||
self.port = parsed.port
|
||||
self.timeout = timeout
|
||||
self.path = parsed.path or "/RPC2"
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
return ScgiMethod(self, name)
|
||||
|
||||
def call(self, method_name: str, *args):
|
||||
body = dumps(args, methodname=method_name, allow_none=True).encode("utf-8")
|
||||
headers = {
|
||||
"CONTENT_LENGTH": str(len(body)),
|
||||
"SCGI": "1",
|
||||
"REQUEST_METHOD": "POST",
|
||||
"REQUEST_URI": self.path,
|
||||
"SCRIPT_NAME": self.path,
|
||||
"SERVER_PROTOCOL": "HTTP/1.1",
|
||||
"CONTENT_TYPE": "text/xml",
|
||||
}
|
||||
header_blob = b"".join(k.encode() + b"\0" + v.encode() + b"\0" for k, v in headers.items())
|
||||
payload = str(len(header_blob)).encode("ascii") + b":" + header_blob + b"," + body
|
||||
attempts = _scgi_retry_attempts()
|
||||
last_exc = None
|
||||
for attempt in range(1, attempts + 1):
|
||||
try:
|
||||
with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock:
|
||||
sock.settimeout(self.timeout)
|
||||
sock.sendall(payload)
|
||||
chunks: list[bytes] = []
|
||||
while True:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
response = b"".join(chunks)
|
||||
if not response:
|
||||
raise ConnectionError("Empty response from rTorrent SCGI")
|
||||
if b"\r\n\r\n" in response:
|
||||
response = response.split(b"\r\n\r\n", 1)[1]
|
||||
elif b"\n\n" in response:
|
||||
response = response.split(b"\n\n", 1)[1]
|
||||
result, _ = loads(response)
|
||||
return result[0] if len(result) == 1 else result
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
if attempt >= attempts or not _is_transient_scgi_error(exc):
|
||||
raise
|
||||
time.sleep(_scgi_retry_delay(attempt))
|
||||
raise last_exc or ConnectionError("rTorrent SCGI call failed")
|
||||
|
||||
|
||||
|
||||
|
||||
# Note: Shared runtime caches and post-check state live in the client module so split service modules keep the same process-wide behavior as the old monolith.
|
||||
_DISK_USAGE_CACHE: dict[str, tuple[float, dict]] = {}
|
||||
_DISK_USAGE_TTL_SECONDS = 30.0
|
||||
_REMOTE_USAGE_CACHE: dict[int, tuple[float, dict]] = {}
|
||||
_REMOTE_USAGE_TTL_SECONDS = 60.0
|
||||
_REMOTE_PUBLIC_IP_CACHE: dict[int, tuple[float, str]] = {}
|
||||
_REMOTE_PUBLIC_IP_TTL_SECONDS = 6 * 60 * 60.0
|
||||
POST_CHECK_DOWNLOAD_LABEL = "To download after check"
|
||||
_POST_CHECK_WATCH_TTL_SECONDS = 48 * 60 * 60
|
||||
_POST_CHECK_WATCH_MIN_SECONDS = 2.0
|
||||
_POST_CHECK_WATCH: dict[int, dict[str, float]] = {}
|
||||
|
||||
def _scgi_retry_attempts() -> int:
|
||||
# Note: Short retry/backoff protects bulk operations from temporary Errno 111 during high rTorrent load.
|
||||
try:
|
||||
return max(1, min(10, int(os.environ.get("PYTORRENT_SCGI_RETRIES", "5"))))
|
||||
except Exception:
|
||||
return 5
|
||||
|
||||
|
||||
def _scgi_retry_delay(attempt: int) -> float:
|
||||
return min(5.0, 0.35 * (2 ** max(0, attempt - 1)))
|
||||
|
||||
|
||||
def _is_transient_scgi_error(exc: Exception) -> bool:
|
||||
# Note: Retry covers common temporary SCGI/socket errors but does not hide semantic XML-RPC errors.
|
||||
if isinstance(exc, (ConnectionRefusedError, ConnectionResetError, TimeoutError, socket.timeout)):
|
||||
return True
|
||||
err_no = getattr(exc, "errno", None)
|
||||
if err_no in {errno.ECONNREFUSED, errno.ECONNRESET, errno.ETIMEDOUT, errno.EHOSTUNREACH, errno.ENETUNREACH}:
|
||||
return True
|
||||
msg = str(exc).lower()
|
||||
return any(text in msg for text in ("connection refused", "connection reset", "timed out", "timeout", "empty response", "pipe creation failed", "resource temporarily unavailable", "try again", "temporarily unavailable"))
|
||||
|
||||
|
||||
def client_for(profile: dict) -> ScgiRtorrentClient:
|
||||
return ScgiRtorrentClient(profile["scgi_url"], int(profile.get("timeout_seconds") or 5))
|
||||
|
||||
|
||||
_UNSUPPORTED_EXEC_METHODS: set[str] = set()
|
||||
_EXEC_TARGET_STYLE: dict[str, int] = {}
|
||||
|
||||
def _rt_execute_preview(method_name: str, call_args: tuple) -> str:
|
||||
# Note: The compact RPC summary removes long scripts from error messages while keeping the method and first arguments for diagnostics.
|
||||
preview = ", ".join(repr(x) for x in call_args[:3])
|
||||
if len(call_args) > 3:
|
||||
preview += ", ..."
|
||||
return f"{method_name}({preview})"
|
||||
|
||||
|
||||
def _rt_execute_target_variants(method: str, args: tuple) -> list[tuple]:
|
||||
# Note: Depending on version, rTorrent XML-RPC either requires or rejects an empty target; cache the working variant per method.
|
||||
variants = [("", *args), args]
|
||||
preferred = _EXEC_TARGET_STYLE.get(method)
|
||||
if preferred is not None and 0 <= preferred < len(variants):
|
||||
return [variants[preferred]] + [v for i, v in enumerate(variants) if i != preferred]
|
||||
return variants
|
||||
|
||||
|
||||
def _is_rt_method_missing(exc: Exception) -> bool:
|
||||
msg = str(exc).lower()
|
||||
return "not defined" in msg or "no such method" in msg or "unknown method" in msg
|
||||
|
||||
|
||||
def _rt_execute_methods(method: str) -> list[str]:
|
||||
# Note: execute2.* is tried only when the base execute.* method does not exist to avoid false retry errors.
|
||||
methods = [method]
|
||||
if method.startswith("execute."):
|
||||
fallback = method.replace("execute.", "execute2.", 1)
|
||||
if fallback not in _UNSUPPORTED_EXEC_METHODS:
|
||||
methods.append(fallback)
|
||||
return methods
|
||||
|
||||
|
||||
def _rt_execute(c: ScgiRtorrentClient, method: str, *args):
|
||||
"""Run rTorrent execute.* as the rTorrent user across XML-RPC variants."""
|
||||
errors: list[str] = []
|
||||
attempts = _scgi_retry_attempts()
|
||||
for attempt in range(1, attempts + 1):
|
||||
errors.clear()
|
||||
transient_seen = False
|
||||
primary_missing = False
|
||||
for method_index, method_name in enumerate(_rt_execute_methods(method)):
|
||||
if method_name in _UNSUPPORTED_EXEC_METHODS:
|
||||
continue
|
||||
if method_index > 0 and not primary_missing:
|
||||
continue
|
||||
for call_args in _rt_execute_target_variants(method_name, args):
|
||||
try:
|
||||
result = c.call(method_name, *call_args)
|
||||
if method_name == method:
|
||||
_EXEC_TARGET_STYLE[method_name] = 0 if call_args and call_args[0] == "" else 1
|
||||
return result
|
||||
except Exception as exc:
|
||||
if _is_rt_method_missing(exc):
|
||||
_UNSUPPORTED_EXEC_METHODS.add(method_name)
|
||||
if method_name == method:
|
||||
primary_missing = True
|
||||
errors.append(f"{method_name}: method not defined")
|
||||
break
|
||||
transient_seen = transient_seen or _is_transient_scgi_error(exc)
|
||||
errors.append(f"{_rt_execute_preview(method_name, call_args)}: {exc}")
|
||||
if transient_seen and attempt < attempts:
|
||||
time.sleep(_scgi_retry_delay(attempt))
|
||||
continue
|
||||
break
|
||||
raise RuntimeError("rTorrent execute failed: " + "; ".join(errors))
|
||||
|
||||
|
||||
def _is_rt_timeout_error(exc: Exception) -> bool:
|
||||
msg = str(exc).lower()
|
||||
return isinstance(exc, (TimeoutError, socket.timeout)) or "timed out" in msg or "timeout" in msg
|
||||
|
||||
|
||||
def _rt_execute_allow_timeout(c: ScgiRtorrentClient, method: str, *args):
|
||||
try:
|
||||
return _rt_execute(c, method, *args)
|
||||
except Exception as exc:
|
||||
if _is_rt_timeout_error(exc):
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
def _remote_clean_path(path: str) -> str:
|
||||
path = str(path or "").strip()
|
||||
return posixpath.normpath(path) if path else path
|
||||
|
||||
|
||||
def _remote_join(*parts: str) -> str:
|
||||
cleaned = [str(p).strip().rstrip("/") for p in parts if str(p).strip()]
|
||||
return posixpath.normpath(posixpath.join(*cleaned)) if cleaned else ""
|
||||
|
||||
|
||||
def _run_remote_move(c: ScgiRtorrentClient, src: str, dst: str, poll_interval: float = 2.0) -> None:
|
||||
"""Run a remote mv without binding the transfer time to the SCGI timeout."""
|
||||
token = uuid.uuid4().hex
|
||||
status_path = f"/tmp/pytorrent-move-{token}.status"
|
||||
start_script = (
|
||||
'src=$1; dst=$2; status=$3; tmp=${status}.tmp; '
|
||||
'rm -f "$status" "$tmp"; '
|
||||
'( '
|
||||
'rc=0; '
|
||||
'parent=${dst%/*}; '
|
||||
'if [ -z "$dst" ] || [ "$dst" = "/" ]; then echo "unsafe destination: $dst" >&2; rc=5; fi; '
|
||||
'if [ $rc -eq 0 ] && [ -n "$parent" ] && [ "$parent" != "$dst" ]; then mkdir -p "$parent" || rc=$?; fi; '
|
||||
'if [ $rc -eq 0 ] && [ "$src" = "$dst" ]; then :; '
|
||||
'elif [ $rc -eq 0 ] && { [ -e "$dst" ] || [ -L "$dst" ]; } && [ ! -e "$src" ] && [ ! -L "$src" ]; then :; '
|
||||
'elif [ $rc -eq 0 ] && [ ! -e "$src" ] && [ ! -L "$src" ]; then echo "source missing: $src" >&2; rc=3; '
|
||||
'elif [ $rc -eq 0 ] && { [ -e "$dst" ] || [ -L "$dst" ]; }; then rm -rf -- "$dst" && mv -f -- "$src" "$dst" || rc=$?; '
|
||||
'elif [ $rc -eq 0 ]; then mv -f -- "$src" "$dst" || rc=$?; '
|
||||
'fi; '
|
||||
'if [ $rc -eq 0 ]; then printf "OK\n" > "$status"; '
|
||||
'else printf "ERR %s\n" "$rc" > "$status"; fi; '
|
||||
'if [ -s "$tmp" ]; then cat "$tmp" >> "$status"; fi; '
|
||||
'rm -f "$tmp" '
|
||||
') > "$tmp" 2>&1 &'
|
||||
)
|
||||
poll_script = 'status=$1; [ -f "$status" ] && cat "$status" || true'
|
||||
cleanup_script = 'rm -f "$1"'
|
||||
|
||||
_rt_execute_allow_timeout(c, "execute.throw", "sh", "-c", start_script, "pytorrent-move-start", src, dst, status_path)
|
||||
|
||||
while True:
|
||||
time.sleep(max(0.25, poll_interval))
|
||||
try:
|
||||
output = str(_rt_execute(c, "execute.capture", "sh", "-c", poll_script, "pytorrent-move-poll", status_path) or "").strip()
|
||||
except Exception as exc:
|
||||
# Note: During bulk moves, rTorrent may briefly not create the execute.capture pipe; polling waits and retries.
|
||||
if _is_rt_timeout_error(exc) or _is_transient_scgi_error(exc):
|
||||
continue
|
||||
raise
|
||||
if not output:
|
||||
continue
|
||||
try:
|
||||
_rt_execute(c, "execute.throw", "sh", "-c", cleanup_script, "pytorrent-move-clean", status_path)
|
||||
except Exception:
|
||||
pass
|
||||
first_line = output.splitlines()[0].strip()
|
||||
if first_line == "OK":
|
||||
return
|
||||
if first_line.startswith("ERR"):
|
||||
details = "\n".join(output.splitlines()[1:]).strip()
|
||||
raise RuntimeError(details or first_line)
|
||||
raise RuntimeError(output)
|
||||
|
||||
|
||||
def _torrent_data_path(c: ScgiRtorrentClient, torrent_hash: str) -> str:
|
||||
"""Return data path as rTorrent sees it; do not touch pyTorrent local FS."""
|
||||
try:
|
||||
src = str(c.call("d.base_path", torrent_hash) or "").strip()
|
||||
if src:
|
||||
return src
|
||||
except Exception:
|
||||
pass
|
||||
directory = str(c.call("d.directory", torrent_hash) or "").strip()
|
||||
name = str(c.call("d.name", torrent_hash) or "").strip()
|
||||
try:
|
||||
is_multi = int(c.call("d.is_multi_file", torrent_hash) or 0)
|
||||
except Exception:
|
||||
is_multi = 0
|
||||
if is_multi:
|
||||
return directory
|
||||
if directory and name:
|
||||
return _remote_join(directory, name)
|
||||
return directory
|
||||
|
||||
|
||||
def _safe_rm_rf_path(path: str) -> str:
|
||||
path = _remote_clean_path(path)
|
||||
if not path or path in {"/", "."}:
|
||||
raise ValueError("Refusing to remove an unsafe data path")
|
||||
if path.rstrip("/").count("/") < 1:
|
||||
raise ValueError(f"Refusing to remove an unsafe data path: {path}")
|
||||
return path
|
||||
|
||||
|
||||
def _run_remote_rm(c: ScgiRtorrentClient, path: str, poll_interval: float = 2.0) -> None:
|
||||
# Note: rm -rf runs in the background on the rTorrent side, so long deletes do not hold a single SCGI connection.
|
||||
token = uuid.uuid4().hex
|
||||
status_path = f"/tmp/pytorrent-rm-{token}.status"
|
||||
script = (
|
||||
'target=$1; status=$2; tmp=${status}.tmp; '
|
||||
'rm -f "$status" "$tmp"; '
|
||||
'( rc=0; '
|
||||
'if [ -z "$target" ] || [ "$target" = "/" ] || [ "$target" = "." ]; then echo "unsafe remove target: $target" >&2; rc=5; '
|
||||
'else rm -rf -- "$target" || rc=$?; fi; '
|
||||
'if [ $rc -eq 0 ]; then printf "OK\n" > "$status"; else printf "ERR %s\n" "$rc" > "$status"; fi; '
|
||||
'if [ -s "$tmp" ]; then cat "$tmp" >> "$status"; fi; '
|
||||
'rm -f "$tmp" ) > "$tmp" 2>&1 &'
|
||||
)
|
||||
poll_script = 'status=$1; [ -f "$status" ] && cat "$status" || true'
|
||||
cleanup_script = 'rm -f "$1"'
|
||||
_rt_execute_allow_timeout(c, "execute.throw", "sh", "-c", script, "pytorrent-rm-start", path, status_path)
|
||||
while True:
|
||||
time.sleep(max(0.25, poll_interval))
|
||||
try:
|
||||
output = str(_rt_execute(c, "execute.capture", "sh", "-c", poll_script, "pytorrent-rm-poll", status_path) or "").strip()
|
||||
except Exception as exc:
|
||||
# Note: Remove uses the same safe polling as move, so a temporary missing pipe does not fail the whole queue.
|
||||
if _is_rt_timeout_error(exc) or _is_transient_scgi_error(exc):
|
||||
continue
|
||||
raise
|
||||
if not output:
|
||||
continue
|
||||
try:
|
||||
_rt_execute(c, "execute.throw", "sh", "-c", cleanup_script, "pytorrent-rm-clean", status_path)
|
||||
except Exception:
|
||||
pass
|
||||
first_line = output.splitlines()[0].strip()
|
||||
if first_line == "OK":
|
||||
return
|
||||
if first_line.startswith("ERR"):
|
||||
details = "\n".join(output.splitlines()[1:]).strip()
|
||||
raise RuntimeError(details or first_line)
|
||||
raise RuntimeError(output)
|
||||
|
||||
|
||||
def _remove_torrent_data(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
||||
data_path = _safe_rm_rf_path(_torrent_data_path(c, torrent_hash))
|
||||
try:
|
||||
c.call("d.stop", torrent_hash)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
c.call("d.close", torrent_hash)
|
||||
except Exception:
|
||||
pass
|
||||
_run_remote_rm(c, data_path)
|
||||
return {"hash": torrent_hash, "removed_path": data_path}
|
||||
|
||||
|
||||
|
||||
# Note: Focused rTorrent modules share low-level helpers with wildcard imports; keep private helper names available internally.
|
||||
__all__ = [name for name in globals() if not name.startswith('__')]
|
||||
255
pytorrent/services/rtorrent/config.py
Normal file
255
pytorrent/services/rtorrent/config.py
Normal file
@@ -0,0 +1,255 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .client import *
|
||||
|
||||
RTORRENT_CONFIG_FIELDS = [
|
||||
{"group": "Directories", "key": "directory.default", "label": "Default download directory", "type": "text"},
|
||||
{"group": "Directories", "key": "session.path", "label": "Session path", "type": "text"},
|
||||
{"group": "Directories", "key": "system.cwd", "label": "Working directory", "type": "text", "readonly": True},
|
||||
{"group": "Network", "key": "network.port_range", "label": "Incoming port range", "type": "text", "placeholder": "49164-49164"},
|
||||
{"group": "Network", "key": "network.port_random", "label": "Random incoming port", "type": "bool"},
|
||||
{"group": "Network", "key": "network.bind_address", "label": "Bind address", "type": "text", "placeholder": "0.0.0.0"},
|
||||
{"group": "Network", "key": "network.local_address", "label": "Local address", "type": "text"},
|
||||
{"group": "Network", "key": "network.max_open_files", "label": "Max open files", "type": "number"},
|
||||
{"group": "Network", "key": "network.max_open_sockets", "label": "Max open sockets", "type": "number"},
|
||||
{"group": "Network", "key": "network.http.max_open", "label": "Max HTTP connections", "type": "number"},
|
||||
{"group": "Network", "key": "network.http.ssl_verify_peer", "label": "Verify SSL peers", "type": "bool"},
|
||||
{"group": "Network", "key": "network.xmlrpc.size_limit", "label": "XML-RPC upload size limit", "type": "text", "placeholder": "16M"},
|
||||
{"group": "Peers", "key": "throttle.min_peers.normal", "label": "Min peers downloading", "type": "number"},
|
||||
{"group": "Peers", "key": "throttle.max_peers.normal", "label": "Max peers downloading", "type": "number"},
|
||||
{"group": "Peers", "key": "throttle.min_peers.seed", "label": "Min peers seeding", "type": "number"},
|
||||
{"group": "Peers", "key": "throttle.max_peers.seed", "label": "Max peers seeding", "type": "number"},
|
||||
{"group": "Peers", "key": "trackers.numwant", "label": "Tracker numwant", "type": "number"},
|
||||
{"group": "Throttle", "key": "throttle.global_down.max_rate", "label": "Global download limit B/s", "type": "number"},
|
||||
{"group": "Throttle", "key": "throttle.global_up.max_rate", "label": "Global upload limit B/s", "type": "number"},
|
||||
{"group": "Throttle", "key": "throttle.max_downloads.global", "label": "Max active downloads", "type": "number"},
|
||||
{"group": "Throttle", "key": "throttle.max_uploads.global", "label": "Max active uploads", "type": "number"},
|
||||
{"group": "Throttle", "key": "throttle.max_downloads.div", "label": "Max downloads per throttle", "type": "number"},
|
||||
{"group": "Throttle", "key": "throttle.max_uploads.div", "label": "Max uploads per throttle", "type": "number"},
|
||||
{"group": "DHT / PEX", "key": "dht.mode", "label": "DHT mode", "type": "text", "placeholder": "disable/off/auto/on"},
|
||||
{"group": "DHT / PEX", "key": "dht.port", "label": "DHT port", "type": "number"},
|
||||
{"group": "DHT / PEX", "key": "protocol.pex", "label": "Peer exchange", "type": "bool"},
|
||||
{"group": "Protocol", "key": "protocol.encryption.set", "label": "Encryption flags", "type": "text", "placeholder": "allow_incoming,try_outgoing,enable_retry"},
|
||||
{"group": "Protocol", "key": "protocol.connection.leech", "label": "Leech connection type", "type": "text", "placeholder": "leech"},
|
||||
{"group": "Protocol", "key": "protocol.connection.seed", "label": "Seed connection type", "type": "text", "placeholder": "seed"},
|
||||
{"group": "Files", "key": "pieces.hash.on_completion", "label": "Hash check on completion", "type": "bool"},
|
||||
{"group": "Files", "key": "pieces.preload.type", "label": "Pieces preload type", "type": "number"},
|
||||
{"group": "Files", "key": "pieces.preload.min_size", "label": "Pieces preload min size", "type": "number"},
|
||||
{"group": "Files", "key": "pieces.preload.min_rate", "label": "Pieces preload min rate", "type": "number"},
|
||||
{"group": "Files", "key": "system.file.allocate", "label": "File allocation", "type": "number"},
|
||||
{"group": "Files", "key": "system.file.max_size", "label": "Max file size", "type": "number"},
|
||||
{"group": "System", "key": "system.umask", "label": "File umask", "type": "text", "placeholder": "0002"},
|
||||
{"group": "System", "key": "system.hostname", "label": "Hostname", "type": "text", "readonly": True},
|
||||
{"group": "System", "key": "system.client_version", "label": "Client version", "type": "text", "readonly": True},
|
||||
{"group": "System", "key": "system.library_version", "label": "Library version", "type": "text", "readonly": True},
|
||||
]
|
||||
|
||||
|
||||
def _normalize_config_value(meta: dict, value):
|
||||
if meta.get("type") == "bool":
|
||||
return "1" if str(value).lower() in {"1", "true", "yes", "on"} or value is True else "0"
|
||||
if meta.get("type") == "number":
|
||||
return str(int(value or 0))
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def saved_config_overrides(profile_id: int, user_id: int | None = None) -> dict[str, dict]:
|
||||
user_id = user_id or default_user_id()
|
||||
with connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT key,value,baseline_value,apply_on_start,updated_at FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=?",
|
||||
(user_id, int(profile_id)),
|
||||
).fetchall()
|
||||
return {r["key"]: r for r in rows}
|
||||
|
||||
|
||||
def get_config(profile: dict) -> dict:
|
||||
c = client_for(profile)
|
||||
saved = saved_config_overrides(int(profile["id"]))
|
||||
fields = []
|
||||
for meta in RTORRENT_CONFIG_FIELDS:
|
||||
item = dict(meta)
|
||||
saved_item = saved.get(meta["key"])
|
||||
try:
|
||||
item["value"] = _normalize_config_value(meta, c.call(meta["key"]))
|
||||
item["current_value"] = item["value"]
|
||||
item["ok"] = True
|
||||
except Exception as exc:
|
||||
item["value"] = ""
|
||||
item["current_value"] = ""
|
||||
item["ok"] = False
|
||||
item["error"] = str(exc)
|
||||
if saved_item:
|
||||
saved_value = _normalize_config_value(meta, saved_item.get("value"))
|
||||
baseline_raw = saved_item.get("baseline_value")
|
||||
if baseline_raw not in (None, ""):
|
||||
baseline_value = _normalize_config_value(meta, baseline_raw)
|
||||
else:
|
||||
baseline_value = _normalize_config_value(meta, item.get("current_value"))
|
||||
item["saved"] = True
|
||||
item["saved_value"] = saved_value
|
||||
item["baseline_value"] = baseline_value
|
||||
item["apply_on_start"] = bool(saved_item.get("apply_on_start"))
|
||||
item["changed"] = saved_value != baseline_value
|
||||
fields.append(item)
|
||||
return {"fields": fields, "apply_on_start": any(bool(v.get("apply_on_start")) for v in saved.values())}
|
||||
|
||||
|
||||
|
||||
def default_download_path(profile: dict) -> str:
|
||||
"""Return rTorrent default download directory for the active profile."""
|
||||
c = client_for(profile)
|
||||
errors = []
|
||||
for method in ("directory.default", "system.cwd"):
|
||||
try:
|
||||
value = str(c.call(method) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
except Exception as exc:
|
||||
errors.append(f"{method}: {exc}")
|
||||
raise RuntimeError("Cannot read rTorrent default download directory: " + "; ".join(errors))
|
||||
|
||||
def generate_config_text(values: dict) -> str:
|
||||
known = {f["key"]: f for f in RTORRENT_CONFIG_FIELDS}
|
||||
lines = []
|
||||
for key, value in (values or {}).items():
|
||||
meta = known.get(key)
|
||||
if not meta or meta.get("readonly"):
|
||||
continue
|
||||
normalized = _normalize_config_value(meta, value)
|
||||
if meta.get("type") == "text" and any(ch.isspace() for ch in normalized):
|
||||
normalized = '"' + normalized.replace('\\', '\\\\').replace('"', '\\"') + '"'
|
||||
lines.append(f"{key}.set = {normalized}")
|
||||
return "\n".join(lines) + ("\n" if lines else "")
|
||||
|
||||
|
||||
def _read_rtorrent_config_value(client, key: str, meta: dict) -> str:
|
||||
return _normalize_config_value(meta, client.call(key))
|
||||
|
||||
|
||||
def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, baseline_values: dict | None = None, clear_keys: list[str] | None = None) -> list[str]:
|
||||
known = {f["key"]: f for f in RTORRENT_CONFIG_FIELDS}
|
||||
user_id = default_user_id()
|
||||
now = utcnow()
|
||||
profile_id = int(profile["id"])
|
||||
baseline_values = baseline_values or {}
|
||||
clear_set = set(clear_keys or [])
|
||||
stored = []
|
||||
with connect() as conn:
|
||||
for key in clear_set:
|
||||
if key in known:
|
||||
conn.execute(
|
||||
"DELETE FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=? AND key=?",
|
||||
(user_id, profile_id, key),
|
||||
)
|
||||
for key, value in (values or {}).items():
|
||||
if key in clear_set:
|
||||
continue
|
||||
meta = known.get(key)
|
||||
if not meta or meta.get("readonly"):
|
||||
continue
|
||||
normalized = _normalize_config_value(meta, value)
|
||||
existing = conn.execute(
|
||||
"SELECT baseline_value FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=? AND key=?",
|
||||
(user_id, profile_id, key),
|
||||
).fetchone()
|
||||
existing_baseline = existing.get("baseline_value") if existing else None
|
||||
|
||||
# Keep the first reference value forever until the override is cleared.
|
||||
# Without this, a second save could treat already-overridden rTorrent
|
||||
# values as the new baseline and the UI would stop marking them as changed.
|
||||
if existing_baseline not in (None, ""):
|
||||
baseline = _normalize_config_value(meta, existing_baseline)
|
||||
else:
|
||||
baseline = _normalize_config_value(meta, baseline_values.get(key)) if key in baseline_values else None
|
||||
|
||||
if baseline not in (None, "") and normalized == baseline:
|
||||
conn.execute(
|
||||
"DELETE FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=? AND key=?",
|
||||
(user_id, profile_id, key),
|
||||
)
|
||||
continue
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO rtorrent_config_overrides(user_id,profile_id,key,value,baseline_value,apply_on_start,updated_at) VALUES(?,?,?,?,?,?,?)",
|
||||
(user_id, profile_id, key, normalized, baseline, 1 if apply_on_start else 0, now),
|
||||
)
|
||||
stored.append(key)
|
||||
conn.execute(
|
||||
"UPDATE rtorrent_config_overrides SET apply_on_start=?, updated_at=? WHERE user_id=? AND profile_id=?",
|
||||
(1 if apply_on_start else 0, now, user_id, profile_id),
|
||||
)
|
||||
return stored
|
||||
|
||||
|
||||
def set_config(profile: dict, values: dict, apply_now: bool = True, apply_on_start: bool = False, clear_keys: list[str] | None = None) -> dict:
|
||||
updated, errors = [], []
|
||||
known = {f["key"]: f for f in RTORRENT_CONFIG_FIELDS}
|
||||
c = client_for(profile)
|
||||
baseline_values = {}
|
||||
for key, raw_value in (values or {}).items():
|
||||
meta = known.get(key)
|
||||
if not meta or meta.get("readonly"):
|
||||
continue
|
||||
try:
|
||||
baseline_values[key] = _read_rtorrent_config_value(c, key, meta)
|
||||
except Exception:
|
||||
pass
|
||||
stored = store_config_overrides(profile, values, apply_on_start, baseline_values, clear_keys)
|
||||
if not apply_now:
|
||||
return {"ok": True, "updated": [], "stored": stored, "errors": []}
|
||||
for key, raw_value in (values or {}).items():
|
||||
if key not in known:
|
||||
continue
|
||||
meta = known[key]
|
||||
if meta.get("readonly"):
|
||||
continue
|
||||
value = _normalize_config_value(meta, raw_value)
|
||||
rpc_value = int(value) if meta.get("type") in {"bool", "number"} else value
|
||||
try:
|
||||
try:
|
||||
c.call(key + ".set", "", rpc_value)
|
||||
except Exception:
|
||||
c.call(key + ".set", rpc_value)
|
||||
updated.append(key)
|
||||
except Exception as exc:
|
||||
errors.append({"key": key, "error": str(exc)})
|
||||
return {"ok": not errors, "updated": updated, "stored": stored, "errors": errors}
|
||||
|
||||
|
||||
|
||||
def reset_config_overrides(profile: dict, user_id: int | None = None) -> dict:
|
||||
"""Remove saved UI overrides and return the freshly read rTorrent config."""
|
||||
# Note: Reset means "forget pyTorrent UI overrides"; it does not write defaults back to rTorrent.
|
||||
user_id = user_id or default_user_id()
|
||||
profile_id = int(profile["id"])
|
||||
with connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS count FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=?",
|
||||
(user_id, profile_id),
|
||||
).fetchone()
|
||||
removed = int((row or {}).get("count") or 0)
|
||||
conn.execute(
|
||||
"DELETE FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=?",
|
||||
(user_id, profile_id),
|
||||
)
|
||||
config = get_config(profile)
|
||||
config["reset_removed"] = removed
|
||||
return config
|
||||
|
||||
|
||||
def apply_startup_overrides(profile: dict) -> dict:
|
||||
rows = saved_config_overrides(int(profile["id"]))
|
||||
values = {k: v.get("value") for k, v in rows.items() if v.get("apply_on_start")}
|
||||
if not values:
|
||||
return {"ok": True, "updated": [], "errors": [], "skipped": True}
|
||||
return set_config(profile, values, apply_now=True, apply_on_start=True)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# Note: Keep split module exports compatible with the previous single rtorrent.py module.
|
||||
__all__ = [
|
||||
name for name in globals()
|
||||
if not name.startswith("__") and name not in {"annotations"}
|
||||
]
|
||||
118
pytorrent/services/rtorrent/diagnostics.py
Normal file
118
pytorrent/services/rtorrent/diagnostics.py
Normal file
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .client import *
|
||||
import shlex
|
||||
|
||||
def scgi_diagnostics(profile: dict) -> dict:
|
||||
c = client_for(profile)
|
||||
started = time.perf_counter()
|
||||
body = dumps((), methodname="system.client_version", allow_none=True).encode("utf-8")
|
||||
headers = {
|
||||
"CONTENT_LENGTH": str(len(body)),
|
||||
"SCGI": "1",
|
||||
"REQUEST_METHOD": "POST",
|
||||
"REQUEST_URI": c.path,
|
||||
"SCRIPT_NAME": c.path,
|
||||
"SERVER_PROTOCOL": "HTTP/1.1",
|
||||
"CONTENT_TYPE": "text/xml",
|
||||
}
|
||||
header_blob = b"".join(k.encode() + b"\0" + v.encode() + b"\0" for k, v in headers.items())
|
||||
payload = str(len(header_blob)).encode("ascii") + b":" + header_blob + b"," + body
|
||||
metrics = {
|
||||
"url": profile.get("scgi_url"),
|
||||
"host": c.host,
|
||||
"port": c.port,
|
||||
"path": c.path,
|
||||
"timeout_seconds": c.timeout,
|
||||
"request_bytes": len(payload),
|
||||
}
|
||||
connect_started = time.perf_counter()
|
||||
with socket.create_connection((c.host, c.port), timeout=c.timeout) as sock:
|
||||
sock.settimeout(c.timeout)
|
||||
metrics["connect_ms"] = round((time.perf_counter() - connect_started) * 1000, 2)
|
||||
send_started = time.perf_counter()
|
||||
sock.sendall(payload)
|
||||
metrics["send_ms"] = round((time.perf_counter() - send_started) * 1000, 2)
|
||||
chunks: list[bytes] = []
|
||||
first_byte_at = None
|
||||
while True:
|
||||
chunk = sock.recv(65536)
|
||||
if chunk and first_byte_at is None:
|
||||
first_byte_at = time.perf_counter()
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
response = b"".join(chunks)
|
||||
metrics["response_bytes"] = len(response)
|
||||
metrics["first_byte_ms"] = round(((first_byte_at or time.perf_counter()) - started) * 1000, 2)
|
||||
metrics["total_ms"] = round((time.perf_counter() - started) * 1000, 2)
|
||||
if not response:
|
||||
raise ConnectionError("Empty response from rTorrent SCGI")
|
||||
xml_response = response
|
||||
if b"\r\n\r\n" in xml_response:
|
||||
xml_response = xml_response.split(b"\r\n\r\n", 1)[1]
|
||||
elif b"\n\n" in xml_response:
|
||||
xml_response = xml_response.split(b"\n\n", 1)[1]
|
||||
result, _ = loads(xml_response)
|
||||
metrics["xml_bytes"] = len(xml_response)
|
||||
metrics["client_version"] = str(result[0]) if result else ""
|
||||
metrics["ok"] = True
|
||||
return metrics
|
||||
|
||||
|
||||
|
||||
def profile_diagnostics(profile: dict) -> dict:
|
||||
"""Lightweight per-profile diagnostics for save/test UI."""
|
||||
started = time.perf_counter()
|
||||
result = {"profile_id": profile.get("id"), "ok": False, "checks": {}}
|
||||
try:
|
||||
c = client_for(profile)
|
||||
version = str(c.call("system.client_version") or "")
|
||||
library = ""
|
||||
try:
|
||||
library = str(c.call("system.library_version") or "")
|
||||
except Exception:
|
||||
library = ""
|
||||
paths = {}
|
||||
for key, method in (("default_directory", "directory.default"), ("cwd", "system.cwd")):
|
||||
try:
|
||||
paths[key] = str(c.call(method) or "")
|
||||
except Exception as exc:
|
||||
paths[key] = {"error": str(exc)}
|
||||
write_permissions = {}
|
||||
free_disk = {}
|
||||
base = paths.get("default_directory") if isinstance(paths.get("default_directory"), str) else ""
|
||||
if base:
|
||||
try:
|
||||
out = _rt_execute(c, "execute.capture", "sh", "-lc", f"test -w {shlex.quote(base)} && printf writable || printf readonly")
|
||||
write_permissions[base] = str(out or "").strip() or "unknown"
|
||||
except Exception as exc:
|
||||
write_permissions[base] = f"error: {exc}"
|
||||
try:
|
||||
out = _rt_execute(c, "execute.capture", "sh", "-lc", f"df -Pk {shlex.quote(base)} | tail -1 | awk '{{print $4}}'")
|
||||
kb = int(str(out or "0").strip() or 0)
|
||||
free_disk[base] = {"free_bytes": kb * 1024, "free_h": human_size(kb * 1024)}
|
||||
except Exception as exc:
|
||||
free_disk[base] = {"error": str(exc)}
|
||||
result.update({
|
||||
"ok": True,
|
||||
"status": "online",
|
||||
"version": version,
|
||||
"library_version": library,
|
||||
"base_paths": paths,
|
||||
"write_permissions": write_permissions,
|
||||
"free_disk": free_disk,
|
||||
"response_time_ms": round((time.perf_counter() - started) * 1000, 2),
|
||||
})
|
||||
except Exception as exc:
|
||||
result.update({"ok": False, "status": "error", "error": str(exc), "response_time_ms": round((time.perf_counter() - started) * 1000, 2)})
|
||||
if result.get("ok") and result.get("response_time_ms", 0) > 1500:
|
||||
result["status"] = "slow"
|
||||
return result
|
||||
|
||||
|
||||
# Note: Keep split module exports compatible with the previous single rtorrent.py module.
|
||||
__all__ = [
|
||||
name for name in globals()
|
||||
if not name.startswith("__") and name not in {"annotations"}
|
||||
]
|
||||
353
pytorrent/services/rtorrent/files.py
Normal file
353
pytorrent/services/rtorrent/files.py
Normal file
@@ -0,0 +1,353 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .client import *
|
||||
|
||||
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=")
|
||||
files = []
|
||||
for idx, r in enumerate(rows):
|
||||
size = int(r[1] or 0)
|
||||
completed_chunks = int(r[2] or 0)
|
||||
size_chunks = int(r[3] or 0)
|
||||
progress = 100.0 if size <= 0 else round((completed_chunks / size_chunks) * 100, 2) if size_chunks else 0.0
|
||||
files.append({
|
||||
"index": idx,
|
||||
"path": r[0],
|
||||
"size": size,
|
||||
"size_h": human_size(size),
|
||||
"completed_chunks": completed_chunks,
|
||||
"size_chunks": size_chunks,
|
||||
"progress": min(100.0, max(0.0, progress)),
|
||||
"priority": int(r[4] or 0),
|
||||
})
|
||||
return files
|
||||
|
||||
|
||||
def torrent_file_tree(profile: dict, torrent_hash: str) -> dict:
|
||||
# Note: The tree is built from rTorrent file paths without changing the existing flat file API.
|
||||
root = {"name": "", "path": "", "type": "directory", "size": 0, "children": {}}
|
||||
for item in torrent_files(profile, torrent_hash):
|
||||
parts = [part for part in str(item.get("path") or "").split("/") if part]
|
||||
node = root
|
||||
prefix: list[str] = []
|
||||
for part in parts[:-1]:
|
||||
prefix.append(part)
|
||||
children = node.setdefault("children", {})
|
||||
node = children.setdefault(part, {"name": part, "path": "/".join(prefix), "type": "directory", "size": 0, "children": {}})
|
||||
name = parts[-1] if parts else str(item.get("path") or f"file-{item.get('index')}")
|
||||
child = dict(item)
|
||||
child.update({"name": name, "type": "file"})
|
||||
node.setdefault("children", {})[name] = child
|
||||
def finalize(node: dict) -> dict:
|
||||
if node.get("type") == "file":
|
||||
return node
|
||||
children = [finalize(v) for v in node.get("children", {}).values()]
|
||||
children.sort(key=lambda x: (x.get("type") != "directory", str(x.get("name") or "").lower()))
|
||||
node["children"] = children
|
||||
node["size"] = sum(int(c.get("size") or 0) for c in children)
|
||||
node["size_h"] = human_size(node["size"])
|
||||
return node
|
||||
return finalize(root)
|
||||
|
||||
|
||||
|
||||
def _torrent_file_remote_path(profile: dict, torrent_hash: str, index: int) -> tuple[dict, str]:
|
||||
c = client_for(profile)
|
||||
files = torrent_files(profile, torrent_hash)
|
||||
selected = next((f for f in files if int(f.get("index", -1)) == int(index)), None)
|
||||
if selected is None:
|
||||
available = ", ".join(str(f.get("index")) for f in files[:20]) or "none"
|
||||
raise ValueError(f"File index {index} not found. Available indexes: {available}")
|
||||
base = _remote_clean_path(_torrent_data_path(c, torrent_hash))
|
||||
rel = str(selected.get("path") or "").lstrip("/")
|
||||
if len(files) == 1 and base and not base.endswith("/"):
|
||||
path = base
|
||||
else:
|
||||
path = _remote_join(base, rel)
|
||||
return selected, path
|
||||
|
||||
|
||||
def download_tmp_dir() -> str:
|
||||
PYTORRENT_TMP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return str(PYTORRENT_TMP_DIR)
|
||||
|
||||
|
||||
def _remote_readability_error(c: ScgiRtorrentClient, source_path: str) -> str | None:
|
||||
script = (
|
||||
'p=$1; '
|
||||
'command -v base64 >/dev/null 2>&1 || { echo "base64 command not found on rTorrent host"; exit 0; }; '
|
||||
'[ -e "$p" ] || { echo "source file does not exist"; exit 0; }; '
|
||||
'[ -f "$p" ] || { echo "source path is not a regular file"; exit 0; }; '
|
||||
'[ -r "$p" ] || { echo "source file is not readable by rTorrent"; exit 0; }; '
|
||||
'echo OK'
|
||||
)
|
||||
output = str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-download-check", source_path) or "").strip()
|
||||
return None if output == "OK" else (output or "source file cannot be read by rTorrent")
|
||||
|
||||
|
||||
def remote_file_readability_error(profile: dict, source_path: str) -> str | None:
|
||||
return _remote_readability_error(client_for(profile), source_path)
|
||||
|
||||
|
||||
def iter_remote_file_chunks(profile: dict, source_path: str, size: int | None = None, chunk_size: int | None = None):
|
||||
c = client_for(profile)
|
||||
clean = _remote_clean_path(source_path)
|
||||
err = _remote_readability_error(c, clean)
|
||||
if err:
|
||||
raise RuntimeError(err)
|
||||
block_size = max(65536, int(chunk_size or REMOTE_READ_CHUNK_BYTES or 1048576))
|
||||
offset = 0
|
||||
emitted = 0
|
||||
script = (
|
||||
'p=$1; bs=$2; skip=$3; '
|
||||
'command -v base64 >/dev/null 2>&1 || { printf "ERR\tbase64 command not found on rTorrent host"; exit 0; }; '
|
||||
'[ -r "$p" ] || { printf "ERR\tsource file is not readable by rTorrent"; exit 0; }; '
|
||||
'dd if="$p" bs="$bs" skip="$skip" count=1 2>/dev/null | base64 | tr -d "\n"'
|
||||
)
|
||||
while size is None or emitted < int(size):
|
||||
output = str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-download-read", clean, str(block_size), str(offset)) or "")
|
||||
if output.startswith("ERR\t"):
|
||||
raise RuntimeError(output.split("\t", 1)[1] or "remote read failed")
|
||||
if not output:
|
||||
break
|
||||
try:
|
||||
chunk = __import__("base64").b64decode(output, validate=False)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"remote read returned invalid base64: {exc}") from exc
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
emitted += len(chunk)
|
||||
offset += 1
|
||||
if size is not None and emitted >= int(size):
|
||||
break
|
||||
|
||||
|
||||
def torrent_download_file_info(profile: dict, torrent_hash: str, index: int) -> dict:
|
||||
selected, remote_path = _torrent_file_remote_path(profile, torrent_hash, index)
|
||||
err = remote_file_readability_error(profile, remote_path)
|
||||
if err:
|
||||
raise RuntimeError(err)
|
||||
return {**selected, "remote_path": remote_path, "download_name": LocalPath(str(selected.get("path") or remote_path)).name}
|
||||
|
||||
|
||||
def torrent_download_zip_items(profile: dict, torrent_hash: str, indexes: list[int] | None = None) -> list[dict]:
|
||||
files = torrent_files(profile, torrent_hash)
|
||||
wanted = {int(x) for x in indexes} if indexes else {int(f["index"]) for f in files}
|
||||
items = []
|
||||
for item in files:
|
||||
if int(item.get("index", -1)) not in wanted:
|
||||
continue
|
||||
_, remote_path = _torrent_file_remote_path(profile, torrent_hash, int(item["index"]))
|
||||
err = remote_file_readability_error(profile, remote_path)
|
||||
if err:
|
||||
raise RuntimeError(f"{item.get('path') or item.get('index')}: {err}")
|
||||
items.append({**item, "remote_path": remote_path})
|
||||
if not items:
|
||||
raise ValueError("No files selected")
|
||||
return items
|
||||
|
||||
|
||||
def _remote_stage_path(c: ScgiRtorrentClient, source_path: str, suffix: str = "") -> str:
|
||||
token = uuid.uuid4().hex
|
||||
safe_suffix = ''.join(ch if ch.isalnum() or ch in '.-_' else '_' for ch in str(suffix or ''))[:80]
|
||||
target = f"{download_tmp_dir().rstrip('/')}/pytorrent-download-{token}{safe_suffix}"
|
||||
script = (
|
||||
'src=$1; dst=$2; '
|
||||
'if [ ! -f "$src" ]; then echo "ERR\tmissing source"; exit 0; fi; '
|
||||
'cp -- "$src" "$dst" 2>/tmp/pytorrent-cp-err-$$ || { rc=$?; err=$(cat /tmp/pytorrent-cp-err-$$ 2>/dev/null); rm -f /tmp/pytorrent-cp-err-$$; printf "ERR\t%s\t%s\n" "$rc" "$err"; exit 0; }; '
|
||||
'rm -f /tmp/pytorrent-cp-err-$$; chmod 0644 "$dst" 2>/dev/null || true; printf "OK\t%s\n" "$dst"'
|
||||
)
|
||||
output = str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-stage-file", source_path, target) or "").strip()
|
||||
parts = (output.splitlines()[0] if output else "").split("\t", 2)
|
||||
if len(parts) >= 2 and parts[0] == "OK":
|
||||
return parts[1]
|
||||
detail = parts[2] if len(parts) > 2 else (parts[1] if len(parts) > 1 else output)
|
||||
raise RuntimeError(detail or "Cannot stage file through rTorrent")
|
||||
|
||||
|
||||
def _remote_stage_zip(c: ScgiRtorrentClient, files: list[dict], suffix: str = ".zip") -> str:
|
||||
if not files:
|
||||
raise ValueError("No files selected")
|
||||
token = uuid.uuid4().hex
|
||||
tmp_base = download_tmp_dir().rstrip("/")
|
||||
list_path = f"{tmp_base}/pytorrent-zip-list-{token}.txt"
|
||||
zip_path = f"{tmp_base}/pytorrent-download-{token}{suffix}"
|
||||
lines = []
|
||||
for item in files:
|
||||
src = str(item.get("remote_path") or "")
|
||||
arc = str(item.get("path") or LocalPath(src).name).lstrip("/") or LocalPath(src).name
|
||||
lines.append(src.replace("\t", " ") + "\t" + arc.replace("\t", " "))
|
||||
list_data = "\n".join(lines)
|
||||
script = (
|
||||
'list=$1; zip=$2; data=$3; umask 022; printf "%s\n" "$data" > "$list"; '
|
||||
'rm -f "$zip"; tmpdir=$(mktemp -d /tmp/pytorrent-zip-XXXXXX) || exit 3; '
|
||||
'rc=0; while IFS=$(printf "\\t") read -r src arc; do '
|
||||
'[ -n "$src" ] || continue; '
|
||||
'if [ ! -f "$src" ]; then echo "missing source: $src" >&2; rc=4; break; fi; '
|
||||
'case "$arc" in /*|../*|*/../*) echo "unsafe zip path: $arc" >&2; rc=5; break;; esac; '
|
||||
'dir=${arc%/*}; if [ "$dir" != "$arc" ]; then mkdir -p "$tmpdir/$dir" || { rc=$?; break; }; fi; cp -- "$src" "$tmpdir/$arc" || { rc=$?; break; }; '
|
||||
'done; if [ $rc -eq 0 ]; then (cd "$tmpdir" && zip -qr "$zip" .) || rc=$?; fi; '
|
||||
'rm -rf "$tmpdir" "$list"; '
|
||||
'if [ $rc -eq 0 ] && [ -f "$zip" ]; then chmod 0644 "$zip" 2>/dev/null || true; printf "OK\t%s\n" "$zip"; else printf "ERR\t%s\n" "$rc"; fi'
|
||||
)
|
||||
output = str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-stage-zip", list_path, zip_path, list_data) or "").strip()
|
||||
parts = (output.splitlines()[0] if output else "").split("\t", 1)
|
||||
if len(parts) == 2 and parts[0] == "OK":
|
||||
return parts[1]
|
||||
raise RuntimeError(output or "Cannot create ZIP through rTorrent")
|
||||
|
||||
|
||||
def _remote_remove_staged(profile: dict, path: str) -> None:
|
||||
clean = str(path or "")
|
||||
tmp_prefix = download_tmp_dir().rstrip("/") + "/pytorrent-download-"
|
||||
if not clean.startswith(tmp_prefix):
|
||||
return
|
||||
try:
|
||||
_rt_execute(client_for(profile), "execute.throw", "rm", "-f", clean)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def torrent_staged_file_path(profile: dict, torrent_hash: str, index: int) -> dict:
|
||||
c = client_for(profile)
|
||||
selected, remote_path = _torrent_file_remote_path(profile, torrent_hash, index)
|
||||
suffix = LocalPath(str(selected.get("path") or "file")).suffix
|
||||
staged = _remote_stage_path(c, remote_path, suffix)
|
||||
return {**selected, "remote_path": remote_path, "staged_path": staged, "download_name": LocalPath(str(selected.get("path") or staged)).name}
|
||||
|
||||
|
||||
def torrent_staged_zip_path(profile: dict, torrent_hash: str, indexes: list[int] | None = None) -> dict:
|
||||
c = client_for(profile)
|
||||
files = torrent_files(profile, torrent_hash)
|
||||
wanted = {int(x) for x in indexes} if indexes else {int(f["index"]) for f in files}
|
||||
items = []
|
||||
for item in files:
|
||||
if int(item.get("index", -1)) not in wanted:
|
||||
continue
|
||||
_, remote_path = _torrent_file_remote_path(profile, torrent_hash, int(item["index"]))
|
||||
items.append({**item, "remote_path": remote_path})
|
||||
staged = _remote_stage_zip(c, items)
|
||||
return {"staged_path": staged, "count": len(items)}
|
||||
|
||||
|
||||
def _torrent_raw_from_method(c: ScgiRtorrentClient, torrent_hash: str) -> bytes | None:
|
||||
for method in ("d.get_metafile", "d.metafile"):
|
||||
try:
|
||||
value = c.call(method, torrent_hash)
|
||||
except Exception:
|
||||
continue
|
||||
if hasattr(value, "data"):
|
||||
data = value.data
|
||||
elif isinstance(value, bytes):
|
||||
data = value
|
||||
elif isinstance(value, str):
|
||||
data = value.encode("latin-1", "ignore")
|
||||
else:
|
||||
data = None
|
||||
if data:
|
||||
return bytes(data)
|
||||
return None
|
||||
|
||||
|
||||
def _torrent_source_file(c: ScgiRtorrentClient, torrent_hash: str) -> str:
|
||||
for method in ("d.tied_to_file", "d.get_tied_to_file", "d.loaded_file", "d.get_loaded_file", "d.session_file", "d.get_session_file"):
|
||||
try:
|
||||
value = str(c.call(method, torrent_hash) or "").strip()
|
||||
except Exception:
|
||||
continue
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def export_torrent_file(profile: dict, torrent_hash: str) -> dict:
|
||||
c = client_for(profile)
|
||||
name = str(c.call("d.name", torrent_hash) or torrent_hash).strip() or torrent_hash
|
||||
filename = f"{name}.torrent" if not name.lower().endswith(".torrent") else name
|
||||
raw = _torrent_raw_from_method(c, torrent_hash)
|
||||
if raw:
|
||||
target = LocalPath(download_tmp_dir()) / f"pytorrent-download-{uuid.uuid4().hex}.torrent"
|
||||
target.write_bytes(raw)
|
||||
return {"path": str(target), "download_name": filename, "local": True}
|
||||
source = _torrent_source_file(c, torrent_hash)
|
||||
if not source:
|
||||
raise RuntimeError("Cannot find torrent source file in rTorrent")
|
||||
staged = _remote_stage_path(c, source, ".torrent")
|
||||
return {"path": staged, "download_name": filename, "local": False}
|
||||
|
||||
|
||||
def set_file_priorities(profile: dict, torrent_hash: str, files: list[dict]) -> dict:
|
||||
"""Set rTorrent file priorities for one torrent.
|
||||
|
||||
Note: Keeps the existing /files/priority API behavior and returns per-file errors
|
||||
instead of failing the whole batch on one invalid item.
|
||||
"""
|
||||
c = client_for(profile)
|
||||
updated = []
|
||||
errors = []
|
||||
for item in files or []:
|
||||
try:
|
||||
index = int(item.get("index"))
|
||||
priority = int(item.get("priority"))
|
||||
if priority < 0 or priority > 3:
|
||||
raise ValueError("Priority must be between 0 and 3")
|
||||
target = f"{torrent_hash}:f{index}"
|
||||
c.call("f.priority.set", target, priority)
|
||||
updated.append({"index": index, "priority": priority})
|
||||
except Exception as exc:
|
||||
errors.append({"item": item, "error": str(exc)})
|
||||
return {"updated": updated, "errors": errors}
|
||||
|
||||
def set_folder_priority(profile: dict, torrent_hash: str, folder_path: str, priority: int) -> dict:
|
||||
# Note: Folder priority applies the same rTorrent file priority to every descendant path.
|
||||
folder = str(folder_path or "").strip().strip("/")
|
||||
updates = []
|
||||
for item in torrent_files(profile, torrent_hash):
|
||||
path = str(item.get("path") or "").strip("/")
|
||||
if not folder or path == folder or path.startswith(folder + "/"):
|
||||
updates.append({"index": item["index"], "priority": int(priority)})
|
||||
if not updates:
|
||||
return {"updated": [], "errors": [{"folder": folder_path, "error": "No files matched folder"}]}
|
||||
return set_file_priorities(profile, torrent_hash, updates)
|
||||
|
||||
|
||||
def torrent_local_file_path(profile: dict, torrent_hash: str, index: int) -> str:
|
||||
c = client_for(profile)
|
||||
files = torrent_files(profile, torrent_hash)
|
||||
selected = next((f for f in files if int(f.get("index", -1)) == int(index)), None)
|
||||
if not selected:
|
||||
raise ValueError("File index not found")
|
||||
base = _remote_clean_path(_torrent_data_path(c, torrent_hash))
|
||||
rel = str(selected.get("path") or "").lstrip("/")
|
||||
if len(files) == 1 and base and not base.endswith("/"):
|
||||
path = base
|
||||
else:
|
||||
path = _remote_join(base, rel)
|
||||
# Note: HTTP file serving is enabled only for local profiles to avoid pretending remote files exist locally.
|
||||
if int(profile.get("is_remote") or 0):
|
||||
raise ValueError("HTTP file download is available only for local rTorrent profiles")
|
||||
local = LocalPath(path).resolve()
|
||||
if not local.exists() or not local.is_file():
|
||||
raise FileNotFoundError(f"Local file is not available: {local}")
|
||||
return str(local)
|
||||
|
||||
|
||||
def torrent_local_file_paths(profile: dict, torrent_hash: str, indexes: list[int] | None = None) -> list[dict]:
|
||||
files = torrent_files(profile, torrent_hash)
|
||||
wanted = {int(x) for x in indexes} if indexes else {int(f["index"]) for f in files}
|
||||
out = []
|
||||
for item in files:
|
||||
if int(item.get("index", -1)) not in wanted:
|
||||
continue
|
||||
out.append({**item, "local_path": torrent_local_file_path(profile, torrent_hash, int(item["index"]))})
|
||||
return out
|
||||
|
||||
|
||||
|
||||
|
||||
# Note: Keep split module exports compatible with the previous single rtorrent.py module.
|
||||
__all__ = [
|
||||
name for name in globals()
|
||||
if not name.startswith("__") and name not in {"annotations"}
|
||||
]
|
||||
4
pytorrent/services/rtorrent/shared.py
Normal file
4
pytorrent/services/rtorrent/shared.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# Note: Backward-compatible internal alias for modules created during refactor.
|
||||
from .client import *
|
||||
488
pytorrent/services/rtorrent/system.py
Normal file
488
pytorrent/services/rtorrent/system.py
Normal file
@@ -0,0 +1,488 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from threading import RLock
|
||||
|
||||
from .client import *
|
||||
from .config import default_download_path
|
||||
from ...utils import human_size
|
||||
|
||||
|
||||
def browse_path(profile: dict, path: str | None = None) -> dict:
|
||||
"""List directories through rTorrent execute.capture to avoid pyTorrent FS permissions."""
|
||||
# Note: Directory browsing stays remote-side, matching the original monolithic service behavior.
|
||||
c = client_for(profile)
|
||||
base = _remote_clean_path(path or default_download_path(profile))
|
||||
script = (
|
||||
'base=$1; '
|
||||
'[ -d "$base" ] || exit 2; '
|
||||
'dfline=$(df -Pk "$base" 2>/dev/null | awk "NR==2{print \\$2,\\$3,\\$4,\\$5}"); '
|
||||
'dir_count=0; file_count=0; '
|
||||
'for p in "$base"/* "$base"/.[!.]* "$base"/..?*; do '
|
||||
'[ -e "$p" ] || continue; '
|
||||
'if [ -d "$p" ]; then dir_count=$((dir_count+1)); name=${p##*/}; printf "D\\t%s\\t%s\\n" "$name" "$p"; '
|
||||
'elif [ -f "$p" ]; then file_count=$((file_count+1)); fi; '
|
||||
'done; '
|
||||
'printf "M\\t%s\\t%s\\n" "$dir_count" "$file_count"; '
|
||||
'[ -n "$dfline" ] && printf "F\\t%s\\n" "$dfline"'
|
||||
)
|
||||
output = _rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-browse", base)
|
||||
dirs = []
|
||||
dir_count = 0
|
||||
file_count = 0
|
||||
disk_total = disk_used = disk_free = 0
|
||||
disk_percent = 0
|
||||
for line in str(output or "").splitlines():
|
||||
if "\t" not in line:
|
||||
continue
|
||||
marker, rest = line.split("\t", 1)
|
||||
if marker == "D" and "\t" in rest:
|
||||
name, full_path = rest.split("\t", 1)
|
||||
if name not in {".", ".."}:
|
||||
dirs.append({"name": name, "path": full_path})
|
||||
elif marker == "M" and "\t" in rest:
|
||||
first, second = rest.split("\t", 1)
|
||||
try:
|
||||
dir_count = int(first or 0)
|
||||
file_count = int(second or 0)
|
||||
except Exception:
|
||||
dir_count = file_count = 0
|
||||
elif marker == "F":
|
||||
parts = rest.split()
|
||||
if len(parts) >= 4:
|
||||
try:
|
||||
disk_total = int(parts[0]) * 1024
|
||||
disk_used = int(parts[1]) * 1024
|
||||
disk_free = int(parts[2]) * 1024
|
||||
disk_percent = int(str(parts[3]).rstrip("%") or 0)
|
||||
except Exception:
|
||||
disk_total = disk_used = disk_free = disk_percent = 0
|
||||
dirs.sort(key=lambda x: x["name"].lower())
|
||||
parent = posixpath.dirname(base.rstrip("/")) or "/"
|
||||
if parent == base:
|
||||
parent = base
|
||||
# Note: Path picker metadata is best-effort and remote-side, so it works for move targets on remote rTorrent hosts.
|
||||
return {
|
||||
"path": base,
|
||||
"parent": parent,
|
||||
"dirs": dirs[:300],
|
||||
"source": "rtorrent",
|
||||
"dir_count": dir_count,
|
||||
"file_count": file_count,
|
||||
"total": disk_total,
|
||||
"used": disk_used,
|
||||
"free": disk_free,
|
||||
"total_h": human_size(disk_total),
|
||||
"used_h": human_size(disk_used),
|
||||
"free_h": human_size(disk_free),
|
||||
"used_percent": disk_percent,
|
||||
}
|
||||
|
||||
def remote_public_ip(profile: dict, force: bool = False) -> str:
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
now = time.monotonic()
|
||||
cached = _REMOTE_PUBLIC_IP_CACHE.get(profile_id)
|
||||
if cached and not force and now - cached[0] < _REMOTE_PUBLIC_IP_TTL_SECONDS:
|
||||
return cached[1]
|
||||
script = (
|
||||
'for url in https://ifconfig.co https://ifconfig.me https://ipapi.linuxiarz.pl http://ifconfig.co http://ifconfig.me; do '
|
||||
'ip=$(curl -fsS --max-time 8 "$url" 2>/dev/null | tr -d "\r" | head -n 1 | sed "s/[^0-9a-fA-F:.]//g"); '
|
||||
'if [ -n "$ip" ]; then printf "%s" "$ip"; exit 0; fi; '
|
||||
'done; exit 1'
|
||||
)
|
||||
value = str(_rt_execute(client_for(profile), "execute.capture", "sh", "-c", script) or "").strip()
|
||||
if not value:
|
||||
raise RuntimeError("Cannot read remote public IP")
|
||||
_REMOTE_PUBLIC_IP_CACHE[profile_id] = (now, value)
|
||||
return value
|
||||
|
||||
|
||||
def remote_system_usage(profile: dict, force: bool = False) -> dict:
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
now = time.monotonic()
|
||||
cached = _REMOTE_USAGE_CACHE.get(profile_id)
|
||||
if cached and not force and now - cached[0] < _REMOTE_USAGE_TTL_SECONDS:
|
||||
usage = dict(cached[1])
|
||||
usage["cached"] = True
|
||||
return usage
|
||||
script = (
|
||||
'read cpu user nice system idle iowait irq softirq steal guest guest_nice < /proc/stat; '
|
||||
'total1=$((user+nice+system+idle+iowait+irq+softirq+steal)); idle1=$((idle+iowait)); '
|
||||
'sleep 1; '
|
||||
'read cpu user nice system idle iowait irq softirq steal guest guest_nice < /proc/stat; '
|
||||
'total2=$((user+nice+system+idle+iowait+irq+softirq+steal)); idle2=$((idle+iowait)); '
|
||||
'dt=$((total2-total1)); di=$((idle2-idle1)); '
|
||||
'cpu_pct=$(awk -v dt="$dt" -v di="$di" "BEGIN { if (dt > 0) printf \"%.1f\", (dt-di)*100/dt; else printf \"0.0\" }"); '
|
||||
"mem_total=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo); "
|
||||
"mem_avail=$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo); "
|
||||
'ram_pct=$(awk -v t="$mem_total" -v a="$mem_avail" "BEGIN { if (t > 0) printf \"%.1f\", (t-a)*100/t; else printf \"0.0\" }"); '
|
||||
'printf "%s %s" "$cpu_pct" "$ram_pct"'
|
||||
)
|
||||
output = str(_rt_execute(client_for(profile), "execute.capture", "sh", "-c", script) or "").strip()
|
||||
parts = output.split()
|
||||
if len(parts) < 2:
|
||||
raise RuntimeError(f"Cannot read remote CPU/RAM usage: {output}")
|
||||
usage = {"cpu": float(parts[0]), "ram": float(parts[1]), "source": "rtorrent-remote", "usage_source": "rtorrent-remote", "cached": False}
|
||||
_REMOTE_USAGE_CACHE[profile_id] = (now, usage)
|
||||
return dict(usage)
|
||||
|
||||
|
||||
def _usage_dict(total: int, used: int, free: int) -> dict:
|
||||
total = max(0, int(total or 0))
|
||||
used = max(0, int(used or 0))
|
||||
free = max(0, int(free or 0))
|
||||
pct = round((used / total) * 100, 1) if total else 0.0
|
||||
return {
|
||||
"ok": True,
|
||||
"total": total,
|
||||
"used": used,
|
||||
"free": free,
|
||||
"total_h": human_size(total),
|
||||
"used_h": human_size(used),
|
||||
"free_h": human_size(free),
|
||||
"percent": pct,
|
||||
}
|
||||
|
||||
|
||||
def _statvfs_usage(path: str) -> dict:
|
||||
stat = os.statvfs(path)
|
||||
total = int(stat.f_blocks * stat.f_frsize)
|
||||
free = int(stat.f_bavail * stat.f_frsize)
|
||||
used = max(0, total - free)
|
||||
return _usage_dict(total, used, free)
|
||||
|
||||
|
||||
def _remote_df_usage(profile: dict, path: str) -> dict:
|
||||
# Note: Disk paths belong to the rTorrent host. Query df through rTorrent so NFS/Btrfs mounts are measured correctly.
|
||||
clean_path = _remote_clean_path(path or os.sep)
|
||||
cache_key = f"remote-df:{profile.get('id')}:{clean_path}"
|
||||
now = time.monotonic()
|
||||
cached = _DISK_USAGE_CACHE.get(cache_key)
|
||||
if cached and now - cached[0] < _DISK_USAGE_TTL_SECONDS:
|
||||
return dict(cached[1])
|
||||
script = (
|
||||
'path=$1; '
|
||||
'if [ ! -e "$path" ]; then echo "ERR\tmissing path"; exit 0; fi; '
|
||||
'line=$(df -Pk "$path" 2>/dev/null | tail -n 1); '
|
||||
'if [ -z "$line" ]; then echo "ERR\tdf failed"; exit 0; fi; '
|
||||
'set -- $line; pct=${5%\\%}; '
|
||||
'if [ -z "$2" ] || [ -z "$3" ] || [ -z "$4" ]; then echo "ERR\tdf parse failed"; exit 0; fi; '
|
||||
'printf "OK\t%s\t%s\t%s\t%s\t%s\n" "$2" "$3" "$4" "$pct" "$6"'
|
||||
)
|
||||
output = str(_rt_execute(client_for(profile), "execute.capture", "sh", "-c", script, "pytorrent-df", clean_path) or "").strip()
|
||||
first_line = output.splitlines()[0] if output else ""
|
||||
parts = first_line.split("\t")
|
||||
if len(parts) >= 6 and parts[0] == "OK":
|
||||
total = int(parts[1]) * 1024
|
||||
used = int(parts[2]) * 1024
|
||||
free = int(parts[3]) * 1024
|
||||
usage = _usage_dict(total, used, free)
|
||||
usage.update({"path": clean_path, "source_path": parts[5] or clean_path, "fallback": False, "measure_source": "rtorrent-df"})
|
||||
else:
|
||||
error = parts[1] if len(parts) > 1 else (output or "df returned no data")
|
||||
usage = {"ok": False, "path": clean_path, "source_path": clean_path, "error": error, "percent": 0, "measure_source": "rtorrent-df"}
|
||||
_DISK_USAGE_CACHE[cache_key] = (now, dict(usage))
|
||||
return usage
|
||||
|
||||
|
||||
def _disk_usage_for_path(profile: dict, path: str, allow_parent_fallback: bool = False) -> dict:
|
||||
clean_path = _remote_clean_path(path or os.sep)
|
||||
try:
|
||||
return _remote_df_usage(profile, clean_path)
|
||||
except Exception as remote_exc:
|
||||
try:
|
||||
usage = _statvfs_usage(clean_path)
|
||||
usage.update({"path": clean_path, "source_path": clean_path, "fallback": False, "measure_source": "local-statvfs", "warning": str(remote_exc)})
|
||||
return usage
|
||||
except Exception as first_exc:
|
||||
usage = {"ok": False, "path": clean_path, "source_path": clean_path, "error": str(first_exc), "warning": str(remote_exc), "percent": 0}
|
||||
if not allow_parent_fallback:
|
||||
return usage
|
||||
probe = os.path.abspath(clean_path or os.sep)
|
||||
seen = set()
|
||||
while probe and probe not in seen:
|
||||
seen.add(probe)
|
||||
parent = os.path.dirname(probe)
|
||||
if parent == probe:
|
||||
break
|
||||
probe = parent
|
||||
try:
|
||||
usage = _statvfs_usage(probe)
|
||||
usage.update({"path": clean_path, "source_path": probe, "fallback": True, "measure_source": "local-statvfs", "warning": str(first_exc)})
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
return usage
|
||||
|
||||
|
||||
def disk_usage_for_default_path(profile: dict) -> dict:
|
||||
"""Filesystem usage for the rTorrent default download directory."""
|
||||
path = default_download_path(profile)
|
||||
cache_key = f"default-disk:{profile.get('id')}:{path}"
|
||||
now = time.monotonic()
|
||||
cached = _DISK_USAGE_CACHE.get(cache_key)
|
||||
if cached and now - cached[0] < _DISK_USAGE_TTL_SECONDS:
|
||||
return dict(cached[1])
|
||||
usage = _disk_usage_for_path(profile, path, allow_parent_fallback=True)
|
||||
_DISK_USAGE_CACHE[cache_key] = (now, dict(usage))
|
||||
return usage
|
||||
|
||||
|
||||
def disk_usage_for_paths(profile: dict, paths: list[str] | None = None, mode: str = 'default', selected_path: str = '') -> dict:
|
||||
# Note: Aggregate/selected modes measure exact user paths on the rTorrent host; they do not fall back to parent/root partitions.
|
||||
default_path = default_download_path(profile)
|
||||
mode = mode if mode in {'default', 'selected', 'aggregate'} else 'default'
|
||||
user_paths: list[str] = []
|
||||
for item in paths or []:
|
||||
path = _remote_clean_path(str(item or '').strip())
|
||||
if path and path not in user_paths:
|
||||
user_paths.append(path)
|
||||
selected_path = _remote_clean_path(str(selected_path or '').strip())
|
||||
if mode == 'selected':
|
||||
source_paths = [selected_path] if selected_path else list(user_paths)
|
||||
elif mode == 'aggregate':
|
||||
source_paths = list(user_paths)
|
||||
else:
|
||||
source_paths = [default_path]
|
||||
if mode in {'selected', 'aggregate'} and not source_paths:
|
||||
source_paths = [default_path]
|
||||
clean_paths: list[str] = []
|
||||
for item in source_paths:
|
||||
path = _remote_clean_path(str(item or '').strip())
|
||||
if path and path not in clean_paths:
|
||||
clean_paths.append(path)
|
||||
entries = [_disk_usage_for_path(profile, path, allow_parent_fallback=(mode == 'default')) for path in clean_paths]
|
||||
chosen = entries[0] if entries else _disk_usage_for_path(profile, default_path, allow_parent_fallback=True)
|
||||
if mode == 'selected' and selected_path:
|
||||
chosen = next((x for x in entries if x.get('path') == selected_path), chosen)
|
||||
elif mode == 'aggregate':
|
||||
ok_entries = [x for x in entries if x.get('ok')]
|
||||
total = sum(int(x.get('total') or 0) for x in ok_entries)
|
||||
used = sum(int(x.get('used') or 0) for x in ok_entries)
|
||||
free = sum(int(x.get('free') or 0) for x in ok_entries)
|
||||
chosen = _usage_dict(total, used, free) if ok_entries else {"ok": False, "total": 0, "used": 0, "free": 0, "total_h": "0 B", "used_h": "0 B", "free_h": "0 B", "percent": 0}
|
||||
chosen.update({'path': 'aggregate', 'source_path': 'aggregate', 'fallback': False, 'measure_source': 'rtorrent-df'})
|
||||
chosen = dict(chosen)
|
||||
chosen['mode'] = mode
|
||||
chosen['paths'] = entries
|
||||
return chosen
|
||||
|
||||
|
||||
|
||||
_STATUS_META_CACHE: dict[int, dict[str, Any]] = {}
|
||||
_STATUS_META_LOCK = RLock()
|
||||
|
||||
|
||||
def _profile_cache_key(profile: dict) -> int:
|
||||
return int(profile.get("id") or 0)
|
||||
|
||||
|
||||
def _adaptive_meta_ttl(duration_ms: float) -> float:
|
||||
# Note: Slow rTorrent metadata calls get a longer TTL, while fast servers keep the footer fresh.
|
||||
if duration_ms >= 5000:
|
||||
return 30.0
|
||||
if duration_ms >= 2000:
|
||||
return 15.0
|
||||
if duration_ms >= 800:
|
||||
return 8.0
|
||||
return 3.0
|
||||
|
||||
|
||||
def _cached_rtorrent_meta(profile: dict, c: Any) -> dict[str, Any]:
|
||||
profile_id = _profile_cache_key(profile)
|
||||
now = time.monotonic()
|
||||
with _STATUS_META_LOCK:
|
||||
cached = _STATUS_META_CACHE.get(profile_id)
|
||||
if cached and now < float(cached.get("expires_at") or 0):
|
||||
meta = dict(cached.get("value") or {})
|
||||
meta["status_meta_cache"] = {"hit": True, "ttl_seconds": cached.get("ttl_seconds"), "duration_ms": cached.get("duration_ms")}
|
||||
return meta
|
||||
started = time.monotonic()
|
||||
version = str(c.system.client_version())
|
||||
try:
|
||||
down_limit = int(c.throttle.global_down.max_rate())
|
||||
except Exception:
|
||||
down_limit = 0
|
||||
try:
|
||||
up_limit = int(c.throttle.global_up.max_rate())
|
||||
except Exception:
|
||||
up_limit = 0
|
||||
meta = {
|
||||
"version": version,
|
||||
"down_limit": down_limit,
|
||||
"up_limit": up_limit,
|
||||
"down_limit_h": human_rate(down_limit) if down_limit else "∞",
|
||||
"up_limit_h": human_rate(up_limit) if up_limit else "∞",
|
||||
"open_sockets": _safe_rtorrent_first_int(c, ("network.open_sockets",)),
|
||||
"max_open_sockets": _safe_rtorrent_first_int(c, ("network.max_open_sockets",)),
|
||||
"open_files": _safe_rtorrent_first_int(c, ("network.open_files", "network.current_open_files", "network.open_file_count")),
|
||||
"max_open_files": _safe_rtorrent_first_int(c, ("network.max_open_files",)),
|
||||
"open_http": _safe_rtorrent_first_int(c, ("network.http.open", "network.http.current_open", "network.http.current_opened", "network.http.open_sockets")),
|
||||
"max_open_http": _safe_rtorrent_first_int(c, ("network.http.max_open",)),
|
||||
"max_downloads_global": _safe_rtorrent_first_int(c, ("throttle.max_downloads.global",)),
|
||||
"max_uploads_global": _safe_rtorrent_first_int(c, ("throttle.max_uploads.global",)),
|
||||
"listen_port": _rtorrent_listen_port(c),
|
||||
"rtorrent_time": _safe_rtorrent_time(c),
|
||||
}
|
||||
duration_ms = round((time.monotonic() - started) * 1000.0, 2)
|
||||
ttl = _adaptive_meta_ttl(duration_ms)
|
||||
with _STATUS_META_LOCK:
|
||||
_STATUS_META_CACHE[profile_id] = {"value": dict(meta), "expires_at": now + ttl, "ttl_seconds": ttl, "duration_ms": duration_ms}
|
||||
meta["status_meta_cache"] = {"hit": False, "ttl_seconds": ttl, "duration_ms": duration_ms}
|
||||
return meta
|
||||
|
||||
|
||||
def clear_profile_runtime_caches(profile_id: int) -> dict[str, int]:
|
||||
"""Clear rTorrent runtime caches that are scoped to a single profile."""
|
||||
# Note: This is used by Cleanup to force fresh disk/status/remote readings without restarting pyTorrent.
|
||||
profile_id = int(profile_id or 0)
|
||||
removed = {"disk_usage": 0, "remote_usage": 0, "remote_public_ip": 0, "status_meta": 0}
|
||||
prefix_candidates = (f"default-disk:{profile_id}:", f"remote-df:{profile_id}:")
|
||||
for key in list(_DISK_USAGE_CACHE.keys()):
|
||||
if any(str(key).startswith(prefix) for prefix in prefix_candidates):
|
||||
_DISK_USAGE_CACHE.pop(key, None)
|
||||
removed["disk_usage"] += 1
|
||||
if _REMOTE_USAGE_CACHE.pop(profile_id, None) is not None:
|
||||
removed["remote_usage"] += 1
|
||||
if _REMOTE_PUBLIC_IP_CACHE.pop(profile_id, None) is not None:
|
||||
removed["remote_public_ip"] += 1
|
||||
with _STATUS_META_LOCK:
|
||||
if _STATUS_META_CACHE.pop(profile_id, None) is not None:
|
||||
removed["status_meta"] += 1
|
||||
return removed
|
||||
|
||||
def _safe_rtorrent_int(callable_obj, default=None):
|
||||
"""Return an integer rTorrent metric without failing the whole status poll."""
|
||||
try:
|
||||
value = callable_obj()
|
||||
return int(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _safe_rtorrent_value(callable_obj, default=None):
|
||||
"""Return any rTorrent metric without failing the whole status poll."""
|
||||
try:
|
||||
value = callable_obj()
|
||||
return default if value is None else value
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
|
||||
def _rtorrent_read_candidates(method_name: str) -> tuple[str, ...]:
|
||||
"""Return getter variants used by different rTorrent XMLRPC builds."""
|
||||
name = str(method_name or "").strip()
|
||||
if not name:
|
||||
return tuple()
|
||||
candidates = [name]
|
||||
if not name.endswith("="):
|
||||
candidates.append(f"{name}=")
|
||||
else:
|
||||
candidates.append(name.rstrip("="))
|
||||
return tuple(dict.fromkeys(candidates))
|
||||
|
||||
|
||||
def _safe_rtorrent_first_int(c, method_names, default=None):
|
||||
"""Try several rTorrent XMLRPC getter names and return the first integer value."""
|
||||
for method_name in method_names:
|
||||
for candidate in _rtorrent_read_candidates(method_name):
|
||||
value = _safe_rtorrent_int(lambda name=candidate: c.call(name), None)
|
||||
if value is not None:
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
def _safe_rtorrent_first_value(c, method_names, default=None):
|
||||
"""Try several rTorrent XMLRPC getter names and return the first non-empty value."""
|
||||
for method_name in method_names:
|
||||
for candidate in _rtorrent_read_candidates(method_name):
|
||||
value = _safe_rtorrent_value(lambda name=candidate: c.call(name), None)
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
def _rtorrent_listen_port(c):
|
||||
"""Return the configured incoming port, preferring network.port_range over port-open state."""
|
||||
port_range = _safe_rtorrent_first_value(c, ("network.port_range",))
|
||||
if port_range:
|
||||
first = str(port_range).split("-", 1)[0].strip()
|
||||
if first:
|
||||
return first
|
||||
value = _safe_rtorrent_first_value(c, ("network.port_open", "network.open_port"))
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
return None
|
||||
|
||||
def _safe_rtorrent_time(c):
|
||||
"""Read rTorrent server time when supported; otherwise let the browser clock remain authoritative."""
|
||||
candidates = (
|
||||
lambda: c.system.time_seconds(),
|
||||
lambda: c.system.time(),
|
||||
)
|
||||
for candidate in candidates:
|
||||
value = _safe_rtorrent_int(candidate)
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
def system_status(profile: dict, rows: list[dict] | None = None) -> dict:
|
||||
c = client_for(profile)
|
||||
meta = _cached_rtorrent_meta(profile, c)
|
||||
if rows is None:
|
||||
from .torrents import list_torrents
|
||||
rows = list_torrents(profile)
|
||||
else:
|
||||
rows = list(rows)
|
||||
# Note: ruTorrent-style footer metadata is cached adaptively; live speeds still come from fresh torrent rows.
|
||||
checking_count = sum(1 for t in rows if t.get("status") == "Checking" or int(t.get("hashing") or 0) > 0)
|
||||
active_downloads = sum(1 for t in rows if not t["complete"] and t["state"] and not t.get("paused") and t.get("status") != "Checking")
|
||||
active_uploads = sum(1 for t in rows if t["complete"] and t["state"] and not t.get("paused"))
|
||||
return {
|
||||
"ok": True,
|
||||
"version": meta.get("version"),
|
||||
"total": len(rows),
|
||||
"active": sum(1 for t in rows if t["state"]),
|
||||
"seeding": sum(1 for t in rows if t["complete"] and t["state"] and not t.get("paused")),
|
||||
"leeching": sum(1 for t in rows if not t["complete"] and t["state"] and not t.get("paused") and t.get("status") != "Checking"),
|
||||
"checking": checking_count,
|
||||
"paused": sum(1 for t in rows if t.get("paused")),
|
||||
"stopped": sum(1 for t in rows if not t["state"]),
|
||||
"down_rate": sum(t["down_rate"] for t in rows),
|
||||
"down_rate_h": human_rate(sum(t["down_rate"] for t in rows)),
|
||||
"up_rate": sum(t["up_rate"] for t in rows),
|
||||
"up_rate_h": human_rate(sum(t["up_rate"] for t in rows)),
|
||||
"down_limit": meta.get("down_limit", 0),
|
||||
"up_limit": meta.get("up_limit", 0),
|
||||
"down_limit_h": meta.get("down_limit_h", "∞"),
|
||||
"up_limit_h": meta.get("up_limit_h", "∞"),
|
||||
"total_down": sum(t["down_total"] for t in rows),
|
||||
"total_up": sum(t["up_total"] for t in rows),
|
||||
"total_down_h": human_size(sum(t["down_total"] for t in rows)),
|
||||
"total_up_h": human_size(sum(t["up_total"] for t in rows)),
|
||||
"open_sockets": meta.get("open_sockets"),
|
||||
"max_open_sockets": meta.get("max_open_sockets"),
|
||||
"open_files": meta.get("open_files"),
|
||||
"max_open_files": meta.get("max_open_files"),
|
||||
"open_http": meta.get("open_http"),
|
||||
"max_open_http": meta.get("max_open_http"),
|
||||
"active_downloads": active_downloads,
|
||||
"max_downloads_global": meta.get("max_downloads_global"),
|
||||
"active_uploads": active_uploads,
|
||||
"max_uploads_global": meta.get("max_uploads_global"),
|
||||
"listen_port": meta.get("listen_port"),
|
||||
"rtorrent_time": meta.get("rtorrent_time"),
|
||||
"status_meta_cache": meta.get("status_meta_cache", {}),
|
||||
"disk": disk_usage_for_default_path(profile),
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# Note: Export private cache-backed helpers where the old monolith exposed them through services.rtorrent.
|
||||
__all__ = [
|
||||
name for name in globals()
|
||||
if not name.startswith("__") and name not in {"annotations"}
|
||||
]
|
||||
879
pytorrent/services/rtorrent/torrents.py
Normal file
879
pytorrent/services/rtorrent/torrents.py
Normal file
@@ -0,0 +1,879 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .client import *
|
||||
from .files import set_file_priorities
|
||||
from .system import disk_usage_for_default_path
|
||||
|
||||
|
||||
XMLRPC_DEFAULT_SIZE_LIMIT_BYTES = 512 * 1024
|
||||
|
||||
|
||||
def _parse_xmlrpc_size_limit(value) -> int:
|
||||
"""Parse rTorrent XML-RPC size values such as 524288, 16M or 8K."""
|
||||
# Note: rTorrent accepts human suffixes in config files; UI validation normalizes them to bytes.
|
||||
text = str(value or '').strip().lower()
|
||||
if not text:
|
||||
return XMLRPC_DEFAULT_SIZE_LIMIT_BYTES
|
||||
multiplier = 1
|
||||
if text[-1:] in {'k', 'm', 'g'}:
|
||||
suffix = text[-1]
|
||||
text = text[:-1]
|
||||
multiplier = {'k': 1024, 'm': 1024 * 1024, 'g': 1024 * 1024 * 1024}[suffix]
|
||||
try:
|
||||
return max(1, int(float(text) * multiplier))
|
||||
except Exception:
|
||||
return XMLRPC_DEFAULT_SIZE_LIMIT_BYTES
|
||||
|
||||
|
||||
def xmlrpc_size_limit(profile: dict) -> dict:
|
||||
"""Return the current rTorrent XML-RPC request size limit."""
|
||||
# Note: This value controls .torrent uploads because load.raw sends the torrent through XML-RPC.
|
||||
try:
|
||||
raw = client_for(profile).call('network.xmlrpc.size_limit')
|
||||
limit = _parse_xmlrpc_size_limit(raw)
|
||||
return {'ok': True, 'raw': str(raw), 'bytes': limit, 'human': human_size(limit)}
|
||||
except Exception as exc:
|
||||
return {'ok': False, 'raw': '', 'bytes': XMLRPC_DEFAULT_SIZE_LIMIT_BYTES, 'human': human_size(XMLRPC_DEFAULT_SIZE_LIMIT_BYTES), 'error': str(exc)}
|
||||
|
||||
|
||||
def estimate_torrent_upload_request_size(data: bytes, start: bool = True, directory: str = '', label: str = '', file_priorities: list[dict] | None = None) -> int:
|
||||
"""Estimate the XML-RPC body size produced by rTorrent load.raw* for a .torrent file."""
|
||||
# Note: XML-RPC uses base64 for Binary payloads, so the request is larger than the raw .torrent file.
|
||||
commands = []
|
||||
if directory:
|
||||
commands.append(f'd.directory.set={directory}')
|
||||
if label:
|
||||
commands.append(f'd.custom1.set={label}')
|
||||
method = 'load.raw' if file_priorities else ('load.raw_start' if start else 'load.raw')
|
||||
return len(dumps(("", Binary(data), *commands), methodname=method, allow_none=True).encode('utf-8'))
|
||||
|
||||
|
||||
def validate_torrent_upload_size(profile: dict, data: bytes, start: bool = True, directory: str = '', label: str = '', file_priorities: list[dict] | None = None) -> dict:
|
||||
"""Check whether a .torrent upload fits the active rTorrent XML-RPC size limit."""
|
||||
limit = xmlrpc_size_limit(profile)
|
||||
request_bytes = estimate_torrent_upload_request_size(data, start, directory, label, file_priorities)
|
||||
allowed = request_bytes <= int(limit.get('bytes') or XMLRPC_DEFAULT_SIZE_LIMIT_BYTES)
|
||||
return {
|
||||
'ok': allowed,
|
||||
'request_bytes': request_bytes,
|
||||
'request_h': human_size(request_bytes),
|
||||
'limit_bytes': int(limit.get('bytes') or XMLRPC_DEFAULT_SIZE_LIMIT_BYTES),
|
||||
'limit_h': limit.get('human') or human_size(XMLRPC_DEFAULT_SIZE_LIMIT_BYTES),
|
||||
'limit_raw': limit.get('raw') or '',
|
||||
'limit_read_ok': bool(limit.get('ok')),
|
||||
'limit_error': limit.get('error') or '',
|
||||
'setting': 'network.xmlrpc.size_limit',
|
||||
'suggested_value': '16M',
|
||||
}
|
||||
|
||||
|
||||
def _mark_post_check_watch(profile_id: int, torrent_hash: str) -> None:
|
||||
if not torrent_hash:
|
||||
return
|
||||
_POST_CHECK_WATCH.setdefault(int(profile_id), {})[str(torrent_hash)] = time.time()
|
||||
|
||||
|
||||
def _clear_post_check_watch(profile_id: int, torrent_hash: str) -> None:
|
||||
profile_watch = _POST_CHECK_WATCH.get(int(profile_id))
|
||||
if not profile_watch:
|
||||
return
|
||||
profile_watch.pop(str(torrent_hash), None)
|
||||
if not profile_watch:
|
||||
_POST_CHECK_WATCH.pop(int(profile_id), None)
|
||||
|
||||
|
||||
def _is_post_check_watched(profile_id: int, torrent_hash: str) -> bool:
|
||||
profile_watch = _POST_CHECK_WATCH.get(int(profile_id)) or {}
|
||||
started_at = profile_watch.get(str(torrent_hash))
|
||||
if not started_at:
|
||||
return False
|
||||
age = time.time() - started_at
|
||||
if age > _POST_CHECK_WATCH_TTL_SECONDS:
|
||||
_clear_post_check_watch(profile_id, torrent_hash)
|
||||
return False
|
||||
# Note: A short grace period prevents labeling a recheck that was queued but has not visibly entered hashing yet.
|
||||
return age >= _POST_CHECK_WATCH_MIN_SECONDS
|
||||
|
||||
|
||||
def _label_names(value: str) -> list[str]:
|
||||
names: list[str] = []
|
||||
for part in str(value or "").replace(";", ",").replace("|", ",").split(","):
|
||||
label = part.strip()
|
||||
if label and label not in names:
|
||||
names.append(label)
|
||||
return names
|
||||
|
||||
|
||||
def _label_value(labels: list[str]) -> str:
|
||||
return ", ".join([label for label in labels if str(label or "").strip()])
|
||||
|
||||
|
||||
def _without_post_check_download_label(value: str | None) -> str:
|
||||
return _label_value([label for label in _label_names(str(value or "")) if label != POST_CHECK_DOWNLOAD_LABEL])
|
||||
|
||||
|
||||
def clear_post_check_download_label(c: ScgiRtorrentClient, torrent_hash: str, current_label: str | None = None) -> bool:
|
||||
label_source = current_label
|
||||
if label_source is None:
|
||||
try:
|
||||
label_source = str(c.call("d.custom1", str(torrent_hash or "")) or "")
|
||||
except Exception:
|
||||
label_source = ""
|
||||
labels = _label_names(str(label_source or ""))
|
||||
if POST_CHECK_DOWNLOAD_LABEL not in labels:
|
||||
return False
|
||||
# Note: The temporary post-check label is removed only after the torrent leaves the stopped waiting queue.
|
||||
c.call("d.custom1.set", str(torrent_hash or ""), _label_value([label for label in labels if label != POST_CHECK_DOWNLOAD_LABEL]))
|
||||
return True
|
||||
|
||||
|
||||
def _message_indicates_active_check(message: str) -> bool:
|
||||
msg = str(message or "").lower()
|
||||
if not msg:
|
||||
return False
|
||||
finished_markers = ("complete", "completed", "finished", "success", "succeeded", "failed", "done")
|
||||
if any(marker in msg for marker in finished_markers):
|
||||
return False
|
||||
active_markers = ("checking", "hashing", "hash check queued", "hash check scheduled", "check hash queued", "recheck queued", "rechecking")
|
||||
return any(marker in msg for marker in active_markers)
|
||||
|
||||
|
||||
def _row_progress_complete(row: dict) -> bool:
|
||||
size = int(row.get("size") or 0)
|
||||
completed = int(row.get("completed_bytes") or 0)
|
||||
return bool(row.get("complete")) or (size > 0 and completed >= size) or float(row.get("progress") or 0) >= 100.0
|
||||
|
||||
|
||||
def _cleanup_post_check_label_if_ready(c: ScgiRtorrentClient, row: dict) -> bool:
|
||||
labels = _label_names(str(row.get("label") or ""))
|
||||
if POST_CHECK_DOWNLOAD_LABEL not in labels:
|
||||
return False
|
||||
status = str(row.get("status") or "").lower()
|
||||
started_after_wait = bool(int(row.get("state") or 0)) and status != "checking"
|
||||
if not (_row_progress_complete(row) or status == "seeding" or started_after_wait):
|
||||
return False
|
||||
# Note: Keep the post-check label while the torrent is stopped; remove it once it is started for download/seeding.
|
||||
clear_post_check_download_label(c, str(row.get("hash") or ""), str(row.get("label") or ""))
|
||||
row["label"] = _without_post_check_download_label(str(row.get("label") or ""))
|
||||
return True
|
||||
|
||||
|
||||
def apply_post_check_policy(profile: dict, rows: list[dict], previous_rows: dict[str, dict] | None = None) -> list[dict]:
|
||||
"""Start complete torrents after check; stop and label incomplete ones for Smart Queue."""
|
||||
previous_rows = previous_rows or {}
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
c = client_for(profile)
|
||||
changes: list[dict] = []
|
||||
for row in rows:
|
||||
h = str(row.get("hash") or "")
|
||||
prev = previous_rows.get(h) or {}
|
||||
try:
|
||||
if h and _cleanup_post_check_label_if_ready(c, row):
|
||||
changes.append({"hash": h, "action": "remove_post_check_label"})
|
||||
except Exception as exc:
|
||||
changes.append({"hash": h, "action": "remove_post_check_label_failed", "error": str(exc)})
|
||||
was_checking = str(prev.get("status") or "") == "Checking" or int(prev.get("hashing") or 0) > 0
|
||||
watched_recheck = _is_post_check_watched(profile_id, h)
|
||||
is_checking = str(row.get("status") or "") == "Checking" or int(row.get("hashing") or 0) > 0
|
||||
if not h or not (was_checking or watched_recheck) or is_checking:
|
||||
continue
|
||||
complete = _row_progress_complete(row)
|
||||
try:
|
||||
if complete:
|
||||
# Note: A fully checked torrent is started with the same helper as the manual Start action so it seeds immediately.
|
||||
start_result = start_or_resume_hash(c, h)
|
||||
clear_post_check_download_label(c, h, str(row.get("label") or ""))
|
||||
row.update({"state": 1, "active": 1, "paused": False, "status": "Seeding", "label": _without_post_check_download_label(str(row.get("label") or ""))})
|
||||
changes.append({"hash": h, "action": "start_seed_after_check", "complete": True, "result": start_result})
|
||||
else:
|
||||
labels = _label_names(str(row.get("label") or ""))
|
||||
if POST_CHECK_DOWNLOAD_LABEL not in labels:
|
||||
labels.append(POST_CHECK_DOWNLOAD_LABEL)
|
||||
label_value = _label_value(labels)
|
||||
# Note: Incomplete torrents are left stopped after check so Smart Queue can start them later within the global limit.
|
||||
c.call("d.stop", h)
|
||||
try:
|
||||
c.call("d.close", h)
|
||||
except Exception:
|
||||
pass
|
||||
c.call("d.custom1.set", h, label_value)
|
||||
row.update({"state": 0, "active": 0, "paused": False, "status": "Stopped", "label": label_value})
|
||||
changes.append({"hash": h, "action": "stop_and_label_after_check", "complete": False, "label": POST_CHECK_DOWNLOAD_LABEL})
|
||||
_clear_post_check_watch(profile_id, h)
|
||||
except Exception as exc:
|
||||
changes.append({"hash": h, "action": "post_check_policy_failed", "error": str(exc)})
|
||||
return changes
|
||||
|
||||
|
||||
TORRENT_FIELDS = [
|
||||
"d.hash=", "d.name=", "d.state=", "d.complete=", "d.size_bytes=", "d.completed_bytes=",
|
||||
"d.ratio=", "d.up.rate=", "d.down.rate=", "d.up.total=", "d.down.total=", "d.peers_connected=",
|
||||
"d.peers_complete=", "d.priority=", "d.directory=", "d.base_path=", "d.creation_date=", "d.custom1=",
|
||||
"d.custom=py_ratio_group", "d.message=", "d.hashing=", "d.is_active=", "d.is_multi_file=",
|
||||
]
|
||||
|
||||
TORRENT_OPTIONAL_FIELDS = [
|
||||
"d.timestamp.finished=",
|
||||
]
|
||||
|
||||
|
||||
def human_duration(seconds: int) -> str:
|
||||
# Note: Download ETA is derived locally from remaining bytes and current download speed.
|
||||
seconds = max(0, int(seconds or 0))
|
||||
if seconds <= 0:
|
||||
return '-'
|
||||
days, rem = divmod(seconds, 86400)
|
||||
hours, rem = divmod(rem, 3600)
|
||||
minutes, _ = divmod(rem, 60)
|
||||
if days:
|
||||
return f"{days}d {hours}h"
|
||||
if hours:
|
||||
return f"{hours}h {minutes}m"
|
||||
return f"{minutes}m"
|
||||
|
||||
|
||||
def normalize_row(row: list) -> dict:
|
||||
size = int(row[4] or 0)
|
||||
completed = int(row[5] or 0)
|
||||
progress = 100.0 if size <= 0 and int(row[3] or 0) else round((completed / size) * 100, 2) if size else 0.0
|
||||
ratio_raw = int(row[6] or 0)
|
||||
down_rate = int(row[8] or 0)
|
||||
up_rate = int(row[7] or 0)
|
||||
remaining_bytes = max(0, size - completed)
|
||||
eta_seconds = int(remaining_bytes / down_rate) if down_rate > 0 and not int(row[3] or 0) else 0
|
||||
directory = str(row[14] or "")
|
||||
base_path = str(row[15] or "")
|
||||
is_multi_file = int(row[22] or 0) if len(row) > 22 else 0
|
||||
completed_at = int(row[23] or 0) if len(row) > 23 else 0
|
||||
|
||||
# Show the selected download location only. Hide the torrent root
|
||||
# directory for multi-file torrents and the filename for single-file
|
||||
# torrents. Data deletion still uses the full d.base_path elsewhere.
|
||||
if base_path and base_path != "/":
|
||||
display_parent = posixpath.dirname(base_path.rstrip("/")) or "/"
|
||||
display_path = display_parent.rstrip("/") + "/" if display_parent != "/" else display_parent
|
||||
elif directory and is_multi_file and directory != "/":
|
||||
display_parent = posixpath.dirname(directory.rstrip("/")) or "/"
|
||||
display_path = display_parent.rstrip("/") + "/" if display_parent != "/" else display_parent
|
||||
elif directory:
|
||||
display_path = directory.rstrip("/") + "/" if directory != "/" else directory
|
||||
else:
|
||||
display_path = ""
|
||||
msg = str(row[19] or "")
|
||||
msg_l = msg.lower()
|
||||
hashing = int(row[20] or 0) if len(row) > 20 else 0
|
||||
is_active = int(row[21] or 0) if len(row) > 21 else int(row[2] or 0)
|
||||
state = int(row[2] or 0)
|
||||
complete = int(row[3] or 0)
|
||||
# Note: d.hashing is authoritative; stale "hash check complete" messages must not keep the UI in Checking forever.
|
||||
is_checking = bool(hashing) or _message_indicates_active_check(msg_l)
|
||||
is_paused = bool(state) and not bool(is_active) and not is_checking
|
||||
status = "Checking" if is_checking else "Paused" if is_paused else "Seeding" if complete and state else "Downloading" if state else "Stopped"
|
||||
to_download_bytes = remaining_bytes if not complete else 0
|
||||
# Note: The To download column is only meaningful for incomplete torrents; complete rows expose an empty display value.
|
||||
return {
|
||||
"hash": str(row[0] or ""),
|
||||
"name": str(row[1] or ""),
|
||||
"state": state,
|
||||
"active": is_active,
|
||||
"paused": is_paused,
|
||||
"complete": complete,
|
||||
"size": size,
|
||||
"size_h": human_size(size),
|
||||
"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[9] or 0),
|
||||
"up_total_h": human_size(row[9] or 0),
|
||||
"down_total": int(row[10] or 0),
|
||||
"down_total_h": human_size(row[10] or 0),
|
||||
"to_download": to_download_bytes,
|
||||
"to_download_h": human_size(to_download_bytes) if to_download_bytes else "",
|
||||
"peers": int(row[11] or 0),
|
||||
"seeds": int(row[12] or 0),
|
||||
"priority": int(row[13] or 0),
|
||||
"path": display_path,
|
||||
"created": int(row[16] or 0),
|
||||
"completed_at": completed_at,
|
||||
"label": str(row[17] or ""),
|
||||
"ratio_group": str(row[18] or ""),
|
||||
"message": msg,
|
||||
"status": status,
|
||||
"hashing": hashing,
|
||||
}
|
||||
|
||||
|
||||
def list_torrents(profile: dict) -> list[dict]:
|
||||
c = client_for(profile)
|
||||
try:
|
||||
rows = c.d.multicall2("", "main", *(TORRENT_FIELDS + TORRENT_OPTIONAL_FIELDS))
|
||||
except Exception:
|
||||
# Keep compatibility with older rTorrent builds that do not expose optional timestamp fields.
|
||||
rows = c.d.multicall2("", "main", *TORRENT_FIELDS)
|
||||
return [normalize_row(list(row)) for row in rows]
|
||||
|
||||
|
||||
|
||||
|
||||
def torrent_peers(profile: dict, torrent_hash: str) -> list[dict]:
|
||||
fields = [
|
||||
"p.address=", "p.client_version=", "p.completed_percent=", "p.down_rate=",
|
||||
"p.up_rate=", "p.port=", "p.is_encrypted=", "p.is_incoming=",
|
||||
"p.is_snubbed=", "p.is_banned=",
|
||||
]
|
||||
try:
|
||||
rows = client_for(profile).p.multicall(torrent_hash, "", *fields)
|
||||
except Exception:
|
||||
fields = ["p.address=", "p.client_version=", "p.completed_percent=", "p.down_rate=", "p.up_rate=", "p.port=", "p.is_encrypted="]
|
||||
rows = client_for(profile).p.multicall(torrent_hash, "", *fields)
|
||||
peers = []
|
||||
for idx, r in enumerate(rows):
|
||||
peers.append({
|
||||
"index": idx,
|
||||
"ip": r[0],
|
||||
"client": r[1],
|
||||
"completed": int(r[2] or 0),
|
||||
"down_rate": int(r[3] or 0),
|
||||
"down_rate_h": human_rate(r[3] or 0),
|
||||
"up_rate": int(r[4] or 0),
|
||||
"up_rate_h": human_rate(r[4] or 0),
|
||||
"port": int(r[5] or 0),
|
||||
"encrypted": bool(r[6]) if len(r) > 6 else False,
|
||||
"incoming": bool(r[7]) if len(r) > 7 else False,
|
||||
"snubbed": bool(r[8]) if len(r) > 8 else False,
|
||||
"banned": bool(r[9]) if len(r) > 9 else False,
|
||||
})
|
||||
return peers
|
||||
|
||||
|
||||
|
||||
|
||||
def _call_first(c: ScgiRtorrentClient, candidates: list[tuple[str, tuple]]) -> dict:
|
||||
errors = []
|
||||
for method, args in candidates:
|
||||
try:
|
||||
result = c.call(method, *args)
|
||||
return {"ok": True, "method": method, "result": result}
|
||||
except Exception as exc:
|
||||
errors.append(f"{method}: {exc}")
|
||||
raise RuntimeError("; ".join(errors))
|
||||
|
||||
|
||||
|
||||
def _tracker_domain(url: str) -> str:
|
||||
raw = str(url or '').strip()
|
||||
if not raw:
|
||||
return ''
|
||||
parsed = urlparse(raw if '://' in raw else f'http://{raw}')
|
||||
host = (parsed.hostname or '').lower().strip('.')
|
||||
if host.startswith('www.'):
|
||||
host = host[4:]
|
||||
return host
|
||||
|
||||
|
||||
def tracker_summary(profile: dict, torrent_hashes: list[str] | None = None, limit: int = 1000) -> dict:
|
||||
"""Return tracker domains grouped by torrent for the sidebar filter."""
|
||||
# Note: Tracker summary is read-only and isolated from the normal torrent snapshot, so slow tracker RPC calls cannot break the main list.
|
||||
hashes = [str(h or '').strip() for h in (torrent_hashes or []) if str(h or '').strip()]
|
||||
if not hashes:
|
||||
hashes = [t.get('hash') for t in list_torrents(profile) if t.get('hash')]
|
||||
hashes = hashes[:max(1, int(limit or 1000))]
|
||||
by_hash: dict[str, list[dict]] = {}
|
||||
counts: dict[str, dict] = {}
|
||||
errors = []
|
||||
for h in hashes:
|
||||
try:
|
||||
items = []
|
||||
seen = set()
|
||||
for tr in torrent_trackers(profile, h):
|
||||
url = str(tr.get('url') or '')
|
||||
domain = _tracker_domain(url)
|
||||
if not domain or domain in seen:
|
||||
continue
|
||||
seen.add(domain)
|
||||
item = {'domain': domain, 'url': url}
|
||||
items.append(item)
|
||||
row = counts.setdefault(domain, {'domain': domain, 'url': url, 'count': 0})
|
||||
row['count'] += 1
|
||||
by_hash[h] = items
|
||||
except Exception as exc:
|
||||
errors.append({'hash': h, 'error': str(exc)})
|
||||
by_hash[h] = []
|
||||
trackers = sorted(counts.values(), key=lambda x: (-int(x.get('count') or 0), str(x.get('domain') or '')))
|
||||
return {'hashes': by_hash, 'trackers': trackers, 'errors': errors, 'scanned': len(hashes)}
|
||||
|
||||
def _safe_tracker_call(c: ScgiRtorrentClient, method: str, target: str, default=None):
|
||||
try:
|
||||
return c.call(method, target)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _tracker_target(torrent_hash: str, index: int) -> str:
|
||||
return f"{torrent_hash}:t{int(index)}"
|
||||
|
||||
def _tracker_int(value, default=None):
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
return int(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _tracker_rows(c: ScgiRtorrentClient, torrent_hash: str) -> list[list]:
|
||||
fields = ("t.url=", "t.is_enabled=", "t.scrape_complete=", "t.scrape_incomplete=", "t.scrape_downloaded=")
|
||||
errors: list[str] = []
|
||||
for args in ((torrent_hash, "", *fields), ("", torrent_hash, *fields)):
|
||||
try:
|
||||
rows = c.call("t.multicall", *args)
|
||||
return [list(r) for r in (rows or [])]
|
||||
except Exception as exc:
|
||||
errors.append(f"t.multicall{args[:2]}: {exc}")
|
||||
# Note: Fallback keeps the sidebar tracker filter usable on rTorrent builds without t.multicall scrape fields.
|
||||
total = _tracker_int(_safe_tracker_call(c, "d.tracker_size", torrent_hash, 0), 0) or 0
|
||||
rows: list[list] = []
|
||||
for index in range(max(0, total)):
|
||||
target = _tracker_target(torrent_hash, index)
|
||||
url = _safe_tracker_call(c, "t.url", target, "")
|
||||
if not url:
|
||||
for args in ((torrent_hash, index), ("", torrent_hash, index)):
|
||||
try:
|
||||
url = c.call("t.url", *args)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if url:
|
||||
enabled = _safe_tracker_call(c, "t.is_enabled", target, 1)
|
||||
rows.append([url, enabled, None, None, None])
|
||||
if rows:
|
||||
return rows
|
||||
raise RuntimeError("Cannot read trackers: " + "; ".join(errors))
|
||||
|
||||
|
||||
def torrent_trackers(profile: dict, torrent_hash: str) -> list[dict]:
|
||||
c = client_for(profile)
|
||||
rows = _tracker_rows(c, torrent_hash)
|
||||
trackers = []
|
||||
for idx, r in enumerate(rows):
|
||||
target = _tracker_target(torrent_hash, idx)
|
||||
last_announce = _safe_tracker_call(c, "t.activity_time_last", target, 0)
|
||||
scrape_time = _safe_tracker_call(c, "t.scrape_time_last", target, 0)
|
||||
if not last_announce:
|
||||
last_announce = scrape_time
|
||||
next_announce = _safe_tracker_call(c, "t.activity_time_next", target, 0)
|
||||
raw_seeds = _tracker_int(r[2], None)
|
||||
raw_peers = _tracker_int(r[3], None)
|
||||
raw_downloaded = _tracker_int(r[4], None)
|
||||
has_scrape = bool(_tracker_int(scrape_time, 0)) or raw_seeds not in (None, 0) or raw_peers not in (None, 0) or raw_downloaded not in (None, 0)
|
||||
trackers.append({
|
||||
"index": idx,
|
||||
"url": str(r[0] or ""),
|
||||
"enabled": bool(r[1]),
|
||||
"seeds": raw_seeds if has_scrape else None,
|
||||
"peers": raw_peers if has_scrape else None,
|
||||
"downloaded": raw_downloaded if has_scrape else None,
|
||||
"has_scrape": has_scrape,
|
||||
"last_announce": int(last_announce or 0),
|
||||
"next_announce": int(next_announce or 0),
|
||||
})
|
||||
return trackers
|
||||
|
||||
def tracker_action(profile: dict, torrent_hash: str, action_name: str, payload: dict | None = None) -> dict:
|
||||
payload = payload or {}
|
||||
c = client_for(profile)
|
||||
if action_name == "reannounce":
|
||||
return _call_first(c, [
|
||||
("d.tracker_announce", (torrent_hash,)),
|
||||
("d.tracker_announce", ("", torrent_hash)),
|
||||
("d.tracker_announce.force", (torrent_hash,)),
|
||||
])
|
||||
if action_name == "add":
|
||||
url = str(payload.get("url") or "").strip()
|
||||
if not url:
|
||||
raise ValueError("Missing tracker URL")
|
||||
return _call_first(c, [
|
||||
("d.tracker.insert", (torrent_hash, "", url)),
|
||||
("d.tracker.insert", (torrent_hash, 0, url)),
|
||||
("d.tracker.insert", ("", torrent_hash, "", url)),
|
||||
])
|
||||
if action_name in {"delete", "remove"}:
|
||||
# Note: Deleting trackers is guarded to keep at least one tracker attached to the torrent.
|
||||
index = int(payload.get("index", -1))
|
||||
if index < 0:
|
||||
raise ValueError("Invalid tracker index")
|
||||
total = _tracker_int(_safe_tracker_call(c, "d.tracker_size", torrent_hash, 0), 0) or len(torrent_trackers(profile, torrent_hash))
|
||||
if total <= 1:
|
||||
raise ValueError("Cannot delete the last tracker")
|
||||
if index >= total:
|
||||
raise ValueError("Invalid tracker index")
|
||||
return _call_first(c, [
|
||||
("d.tracker.remove", (torrent_hash, index)),
|
||||
("d.tracker.remove", (torrent_hash, "", index)),
|
||||
("d.tracker.erase", (torrent_hash, index)),
|
||||
("d.tracker.erase", (torrent_hash, "", index)),
|
||||
("d.tracker.delete", (torrent_hash, index)),
|
||||
("d.tracker.delete", (torrent_hash, "", index)),
|
||||
])
|
||||
raise ValueError(f"Unknown tracker action: {action_name}")
|
||||
|
||||
|
||||
|
||||
def _int_rpc(c: ScgiRtorrentClient, method: str, h: str, default: int = 0) -> int:
|
||||
try:
|
||||
return int(c.call(method, h) or 0)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _str_rpc(c: ScgiRtorrentClient, method: str, h: str, default: str = '') -> str:
|
||||
try:
|
||||
return str(c.call(method, h) or '')
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _download_runtime_state(c: ScgiRtorrentClient, h: str) -> dict:
|
||||
"""Read rTorrent state using the native pause model: stopped, paused or active."""
|
||||
state = _int_rpc(c, 'd.state', h)
|
||||
active = _int_rpc(c, 'd.is_active', h)
|
||||
opened = _int_rpc(c, 'd.is_open', h)
|
||||
# Note: In rTorrent, pause does not change d.state. Paused means state=1, open=1, active=0.
|
||||
return {
|
||||
'state': state,
|
||||
'open': opened,
|
||||
'active': active,
|
||||
'paused': bool(state and opened and not active),
|
||||
'stopped': not bool(state),
|
||||
'message': _str_rpc(c, 'd.message', h),
|
||||
}
|
||||
|
||||
|
||||
def pause_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
||||
"""Pause an active rTorrent item without stopping or closing it."""
|
||||
h = str(torrent_hash or '')
|
||||
if not h:
|
||||
return {'hash': h, 'ok': False, 'error': 'missing hash'}
|
||||
before = _download_runtime_state(c, h)
|
||||
result = {'hash': h, 'before': before, 'commands': []}
|
||||
try:
|
||||
if before.get('stopped'):
|
||||
# Note: rTorrent does not turn a stopped item into a paused one with d.pause alone.
|
||||
# First move it out of STOP, then pause it, which matches the expected START -> PAUSE flow.
|
||||
try:
|
||||
c.call('d.open', h)
|
||||
result['commands'].append('d.open')
|
||||
except Exception as exc:
|
||||
result.setdefault('ignored_errors', []).append(f'd.open: {exc}')
|
||||
c.call('d.start', h)
|
||||
result['commands'].append('d.start')
|
||||
# Note: Smart Queue frees a slot with d.pause, not d.stop, so later d.resume behaves like ruTorrent.
|
||||
c.call('d.pause', h)
|
||||
result['commands'].append('d.pause')
|
||||
result['after'] = _download_runtime_state(c, h)
|
||||
result['ok'] = True
|
||||
except Exception as exc:
|
||||
result.update({'ok': False, 'error': str(exc), 'after': _download_runtime_state(c, h)})
|
||||
return result
|
||||
|
||||
|
||||
def stop_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
||||
"""Stop an active rTorrent item without using pause semantics."""
|
||||
h = str(torrent_hash or '')
|
||||
if not h:
|
||||
return {'hash': h, 'ok': False, 'error': 'missing hash'}
|
||||
before = _download_runtime_state(c, h)
|
||||
result = {'hash': h, 'before': before, 'commands': []}
|
||||
if before.get('stopped'):
|
||||
result.update({'ok': True, 'skipped': 'already_stopped', 'after': before})
|
||||
return result
|
||||
try:
|
||||
# Note: Smart Queue now enforces the queue with d.stop only; user-paused torrents stay untouched.
|
||||
c.call('d.stop', h)
|
||||
result['commands'].append('d.stop')
|
||||
result['after'] = _download_runtime_state(c, h)
|
||||
result['ok'] = True
|
||||
except Exception as exc:
|
||||
result.update({'ok': False, 'error': str(exc), 'after': _download_runtime_state(c, h)})
|
||||
return result
|
||||
|
||||
|
||||
def resume_paused_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
||||
"""Resume only a paused rTorrent item; never convert it through stop/start."""
|
||||
h = str(torrent_hash or '')
|
||||
if not h:
|
||||
return {'hash': h, 'ok': False, 'error': 'missing hash'}
|
||||
before = _download_runtime_state(c, h)
|
||||
result: dict = {'hash': h, 'before': before, 'commands': []}
|
||||
if before.get('stopped'):
|
||||
result.update({'ok': False, 'skipped': 'stopped_not_paused', 'after': before})
|
||||
return result
|
||||
if before.get('active'):
|
||||
result.update({'ok': True, 'skipped': 'already_active', 'after': before})
|
||||
return result
|
||||
try:
|
||||
# Note: ruTorrent unpauses with the equivalent of d.resume. Do not add d.start/d.open,
|
||||
# because those commands belong to Stopped/Open state, not a clean Paused state.
|
||||
c.call('d.resume', h)
|
||||
result['commands'].append('d.resume')
|
||||
result['after'] = _download_runtime_state(c, h)
|
||||
result['ok'] = True
|
||||
except Exception as exc:
|
||||
result.update({'ok': False, 'error': str(exc), 'after': _download_runtime_state(c, h)})
|
||||
return result
|
||||
|
||||
|
||||
def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str, prefer_start: bool = False) -> dict:
|
||||
"""Start stopped torrents or resume real paused torrents.
|
||||
|
||||
Smart Queue passes prefer_start=True for candidates that were selected as stopped.
|
||||
This avoids treating rTorrent's intermediate open/inactive state after a check as
|
||||
a user pause and sending only d.resume, which can leave items pending forever.
|
||||
"""
|
||||
h = str(torrent_hash or '')
|
||||
if not h:
|
||||
return {'hash': h, 'ok': False, 'error': 'missing hash'}
|
||||
before = _download_runtime_state(c, h)
|
||||
result: dict = {'hash': h, 'before': before, 'commands': []}
|
||||
|
||||
if before.get('active'):
|
||||
result.update({'ok': True, 'skipped': 'already_active', 'after': before})
|
||||
return result
|
||||
|
||||
if before.get('paused') and not prefer_start:
|
||||
# Note: Manual Start keeps the clean pause-to-resume path. Do not classify every
|
||||
# state=1/active=0 item as paused; after auto-check this can be only a transient
|
||||
# open/inactive rTorrent state and needs d.open + d.start.
|
||||
resumed = resume_paused_hash(c, h)
|
||||
resumed['mode'] = 'resume_paused'
|
||||
return resumed
|
||||
|
||||
try:
|
||||
c.call('d.open', h)
|
||||
result['commands'].append('d.open')
|
||||
except Exception as exc:
|
||||
result.setdefault('ignored_errors', []).append(f'd.open: {exc}')
|
||||
try:
|
||||
c.call('d.start', h)
|
||||
result['commands'].append('d.start')
|
||||
except Exception as exc:
|
||||
result.setdefault('ignored_errors', []).append(f'd.start: {exc}')
|
||||
try:
|
||||
c.call('d.try_start', h)
|
||||
result['commands'].append('d.try_start')
|
||||
except Exception as exc2:
|
||||
result.setdefault('ignored_errors', []).append(f'd.try_start: {exc2}')
|
||||
result['ok'] = False
|
||||
result['after'] = _download_runtime_state(c, h)
|
||||
result['ok'] = result.get('ok', True)
|
||||
return result
|
||||
|
||||
def action(profile: dict, torrent_hashes: list[str], name: str, payload: dict | None = None, checkpoint=None, resume_state: dict | None = None) -> dict:
|
||||
payload = payload or {}
|
||||
resume_state = resume_state or {}
|
||||
completed_hashes = set(str(x) for x in (resume_state.get("completed_hashes") or []))
|
||||
previous_results = list(resume_state.get("results") or [])
|
||||
|
||||
def mark_done(torrent_hash: str, item: dict, results: list) -> None:
|
||||
completed_hashes.add(str(torrent_hash))
|
||||
state = {"completed_hashes": sorted(completed_hashes), "results": results}
|
||||
if checkpoint:
|
||||
checkpoint(state, len(completed_hashes), len(torrent_hashes))
|
||||
|
||||
def pending_hashes() -> list[str]:
|
||||
return [h for h in torrent_hashes if str(h) not in completed_hashes]
|
||||
|
||||
c = client_for(profile)
|
||||
methods = {
|
||||
"stop": "d.stop",
|
||||
"recheck": "d.check_hash",
|
||||
"reannounce": "d.tracker_announce",
|
||||
"remove": "d.erase",
|
||||
}
|
||||
if name == "set_label":
|
||||
label = str(payload.get("label") or "").strip()
|
||||
results = previous_results
|
||||
for h in pending_hashes():
|
||||
c.call("d.custom1.set", h, label)
|
||||
item = {"hash": h, "label": label}
|
||||
results.append(item)
|
||||
mark_done(h, item, results)
|
||||
return {"ok": True, "count": len(torrent_hashes), "label": label, "results": results}
|
||||
if name == "set_ratio_group":
|
||||
group = str(payload.get("ratio_group") or "").strip()
|
||||
results = previous_results
|
||||
for h in pending_hashes():
|
||||
c.call("d.custom.set", h, "py_ratio_group", group)
|
||||
item = {"hash": h, "ratio_group": group}
|
||||
results.append(item)
|
||||
mark_done(h, item, results)
|
||||
return {"ok": True, "count": len(torrent_hashes), "ratio_group": group, "results": results}
|
||||
if name == "move":
|
||||
path = _remote_clean_path(payload.get("path") or "")
|
||||
move_data = bool(payload.get("move_data"))
|
||||
recheck = bool(payload.get("recheck", move_data))
|
||||
keep_seeding = bool(payload.get("keep_seeding"))
|
||||
# Note: Automations can force seeding after a physical move even if the torrent was not active before.
|
||||
if not path:
|
||||
raise ValueError("Missing path")
|
||||
results = previous_results
|
||||
if move_data:
|
||||
_rt_execute_allow_timeout(c, "execute.throw", "mkdir", "-p", path)
|
||||
for h in pending_hashes():
|
||||
item = {"hash": h, "path": path, "move_data": move_data, "keep_seeding": keep_seeding}
|
||||
try:
|
||||
was_state = int(c.call("d.state", h) or 0)
|
||||
except Exception:
|
||||
was_state = 0
|
||||
try:
|
||||
was_active = int(c.call("d.is_active", h) or 0)
|
||||
except Exception:
|
||||
was_active = was_state
|
||||
if move_data:
|
||||
if was_state == 0:
|
||||
c.call("d.directory.set", h, path)
|
||||
item["move_data"] = False
|
||||
item["skipped"] = "state is 0; data is not present, only directory updated"
|
||||
results.append(item)
|
||||
mark_done(h, item, results)
|
||||
continue
|
||||
src = _remote_clean_path(_torrent_data_path(c, h))
|
||||
if not src:
|
||||
raise ValueError(f"Cannot determine source path for {h}")
|
||||
dst = _remote_join(path, posixpath.basename(src.rstrip("/")))
|
||||
if src != dst:
|
||||
try:
|
||||
c.call("d.stop", h)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
c.call("d.close", h)
|
||||
except Exception:
|
||||
pass
|
||||
_run_remote_move(c, src, dst)
|
||||
item["moved_from"] = src
|
||||
item["moved_to"] = dst
|
||||
else:
|
||||
item["skipped"] = "source and destination are the same"
|
||||
c.call("d.directory.set", h, path)
|
||||
if recheck:
|
||||
try:
|
||||
c.call("d.check_hash", h)
|
||||
except Exception as exc:
|
||||
item["recheck_error"] = str(exc)
|
||||
if keep_seeding or was_state or was_active:
|
||||
try:
|
||||
c.call("d.start", h)
|
||||
item["started_after_move"] = True
|
||||
except Exception as exc:
|
||||
item["start_after_move_error"] = str(exc)
|
||||
else:
|
||||
c.call("d.directory.set", h, path)
|
||||
results.append(item)
|
||||
mark_done(h, item, results)
|
||||
return {"ok": True, "count": len(torrent_hashes), "move_data": move_data, "keep_seeding": keep_seeding, "results": results}
|
||||
if name == "pause":
|
||||
# Note: The app pause action is now a pure d.pause so later resume works without stop/start.
|
||||
results = previous_results
|
||||
for h in pending_hashes():
|
||||
item = pause_hash(c, h)
|
||||
results.append(item)
|
||||
mark_done(h, item, results)
|
||||
return {"ok": True, "count": len(torrent_hashes), "remove_data": False, "results": results}
|
||||
if name in {"resume", "unpause"}:
|
||||
# Note: Resume/Unpause uses only d.resume for Paused state.
|
||||
results = previous_results
|
||||
for h in pending_hashes():
|
||||
item = resume_paused_hash(c, h)
|
||||
results.append(item)
|
||||
mark_done(h, item, results)
|
||||
return {"ok": True, "count": len(torrent_hashes), "remove_data": False, "results": results}
|
||||
if name == "start":
|
||||
# Note: Start separates Stopped from Paused; paused items go through d.resume, stopped items through d.start.
|
||||
results = previous_results
|
||||
for h in pending_hashes():
|
||||
item = start_or_resume_hash(c, h)
|
||||
results.append(item)
|
||||
mark_done(h, item, results)
|
||||
return {"ok": True, "count": len(torrent_hashes), "remove_data": False, "results": results}
|
||||
|
||||
method = methods.get(name)
|
||||
if not method:
|
||||
raise ValueError(f"Unknown action: {name}")
|
||||
remove_data = bool(payload.get("remove_data")) if name == "remove" else False
|
||||
results = previous_results
|
||||
for h in pending_hashes():
|
||||
item = {"hash": h}
|
||||
if remove_data:
|
||||
item = _remove_torrent_data(c, h)
|
||||
c.call(method, h)
|
||||
if name == "recheck":
|
||||
# Note: Recheck is tracked so even very fast checks still receive the after-check start/stop policy.
|
||||
_mark_post_check_watch(int(profile.get("id") or 0), h)
|
||||
results.append(item)
|
||||
mark_done(h, item, results)
|
||||
return {"ok": True, "count": len(torrent_hashes), "remove_data": remove_data, "results": results}
|
||||
|
||||
def add_magnet(profile: dict, uri: str, start: bool = True, directory: str = "", label: str = "") -> dict:
|
||||
c = client_for(profile)
|
||||
commands = []
|
||||
if directory:
|
||||
commands.append(f"d.directory.set={directory}")
|
||||
if label:
|
||||
commands.append(f"d.custom1.set={label}")
|
||||
if start:
|
||||
c.load.start_verbose("", uri, *commands)
|
||||
else:
|
||||
c.load.normal("", uri, *commands)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def set_limits(profile: dict, down: int | None, up: int | None):
|
||||
"""Set global speed limits in bytes/s.
|
||||
|
||||
rTorrent XML-RPC setters need an empty target string as the first
|
||||
argument. Without it rTorrent returns: target must be a string.
|
||||
"""
|
||||
c = client_for(profile)
|
||||
if down is not None:
|
||||
c.call("throttle.global_down.max_rate.set", "", int(down))
|
||||
if up is not None:
|
||||
c.call("throttle.global_up.max_rate.set", "", int(up))
|
||||
return {"ok": True, "down": int(down or 0), "up": int(up or 0)}
|
||||
|
||||
|
||||
def add_torrent_raw(profile: dict, data: bytes, start: bool = True, directory: str = "", label: str = "", file_priorities: list[dict] | None = None) -> dict:
|
||||
c = client_for(profile)
|
||||
commands = []
|
||||
if directory:
|
||||
commands.append(f"d.directory.set={directory}")
|
||||
if label:
|
||||
commands.append(f"d.custom1.set={label}")
|
||||
# Note: File selection before start loads the torrent stopped, changes priorities, then starts it if requested.
|
||||
method = "load.raw" if file_priorities else ("load.raw_start" if start else "load.raw")
|
||||
c.call(method, "", Binary(data), *commands)
|
||||
info_hash = ""
|
||||
if file_priorities:
|
||||
try:
|
||||
from ..torrent_meta import parse_torrent
|
||||
info_hash = parse_torrent(data).get("info_hash") or ""
|
||||
set_file_priorities(profile, info_hash, file_priorities)
|
||||
if start:
|
||||
c.call("d.start", info_hash)
|
||||
except Exception as exc:
|
||||
return {"ok": False, "info_hash": info_hash, "error": str(exc)}
|
||||
return {"ok": True, "info_hash": info_hash}
|
||||
|
||||
|
||||
|
||||
# Note: Export all service functions, including compatibility helpers used by routes and older imports.
|
||||
__all__ = [
|
||||
name for name in globals()
|
||||
if not name.startswith("__") and name not in {"annotations"}
|
||||
]
|
||||
1993
pytorrent/services/rtorrent_original_TO_DELETE
Normal file
1993
pytorrent/services/rtorrent_original_TO_DELETE
Normal file
File diff suppressed because it is too large
Load Diff
1438
pytorrent/services/smart_queue.py
Normal file
1438
pytorrent/services/smart_queue.py
Normal file
File diff suppressed because it is too large
Load Diff
159
pytorrent/services/speed_peaks.py
Normal file
159
pytorrent/services/speed_peaks.py
Normal file
@@ -0,0 +1,159 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from ..db import connect, utcnow
|
||||
from .rtorrent import human_rate
|
||||
|
||||
_SESSION_STARTED_AT = utcnow()
|
||||
_CACHE: dict[int, dict[str, Any]] = {}
|
||||
_LOADED = False
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _empty_peak(profile_id: int, all_time: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
# Note: One in-memory structure keeps the current session and all-time record for the rTorrent profile.
|
||||
all_time = all_time or {}
|
||||
return {
|
||||
"profile_id": int(profile_id),
|
||||
"session_started_at": _SESSION_STARTED_AT,
|
||||
"session_down_peak": 0,
|
||||
"session_up_peak": 0,
|
||||
"session_down_peak_at": None,
|
||||
"session_up_peak_at": None,
|
||||
"all_time_down_peak": int(all_time.get("all_time_down_peak") or 0),
|
||||
"all_time_up_peak": int(all_time.get("all_time_up_peak") or 0),
|
||||
"all_time_down_peak_at": all_time.get("all_time_down_peak_at"),
|
||||
"all_time_up_peak_at": all_time.get("all_time_up_peak_at"),
|
||||
}
|
||||
|
||||
|
||||
def load_cache() -> None:
|
||||
# Note: All-time records are loaded on application start, while the session record starts from zero.
|
||||
global _LOADED
|
||||
with _LOCK:
|
||||
if _LOADED:
|
||||
return
|
||||
with connect() as conn:
|
||||
rows = conn.execute("SELECT * FROM transfer_speed_peaks").fetchall()
|
||||
for row in rows:
|
||||
profile_id = int(row.get("profile_id") or 0)
|
||||
if profile_id:
|
||||
_CACHE[profile_id] = _empty_peak(profile_id, row)
|
||||
_LOADED = True
|
||||
|
||||
|
||||
def _ensure_profile(profile_id: int) -> dict[str, Any]:
|
||||
# Note: Lazy loading protects profiles added after startup from empty records.
|
||||
profile_id = int(profile_id)
|
||||
item = _CACHE.get(profile_id)
|
||||
if item:
|
||||
return item
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT * FROM transfer_speed_peaks WHERE profile_id=?", (profile_id,)).fetchone()
|
||||
item = _empty_peak(profile_id, row)
|
||||
_CACHE[profile_id] = item
|
||||
return item
|
||||
|
||||
|
||||
def _persist(item: dict[str, Any]) -> None:
|
||||
# Note: SQLite is updated only when a new session or all-time record appears.
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO transfer_speed_peaks(
|
||||
profile_id, session_started_at, session_down_peak, session_up_peak,
|
||||
session_down_peak_at, session_up_peak_at, all_time_down_peak,
|
||||
all_time_up_peak, all_time_down_peak_at, all_time_up_peak_at,
|
||||
created_at, updated_at
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(profile_id) DO UPDATE SET
|
||||
session_started_at=excluded.session_started_at,
|
||||
session_down_peak=excluded.session_down_peak,
|
||||
session_up_peak=excluded.session_up_peak,
|
||||
session_down_peak_at=excluded.session_down_peak_at,
|
||||
session_up_peak_at=excluded.session_up_peak_at,
|
||||
all_time_down_peak=excluded.all_time_down_peak,
|
||||
all_time_up_peak=excluded.all_time_up_peak,
|
||||
all_time_down_peak_at=excluded.all_time_down_peak_at,
|
||||
all_time_up_peak_at=excluded.all_time_up_peak_at,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(
|
||||
int(item["profile_id"]),
|
||||
item["session_started_at"],
|
||||
int(item["session_down_peak"]),
|
||||
int(item["session_up_peak"]),
|
||||
item.get("session_down_peak_at"),
|
||||
item.get("session_up_peak_at"),
|
||||
int(item["all_time_down_peak"]),
|
||||
int(item["all_time_up_peak"]),
|
||||
item.get("all_time_down_peak_at"),
|
||||
item.get("all_time_up_peak_at"),
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _public(item: dict[str, Any]) -> dict[str, Any]:
|
||||
# Note: The frontend receives bytes/s and ready labels matching the existing speed format.
|
||||
return {
|
||||
"session_started_at": item["session_started_at"],
|
||||
"session": {
|
||||
"down": int(item["session_down_peak"]),
|
||||
"up": int(item["session_up_peak"]),
|
||||
"down_h": human_rate(int(item["session_down_peak"])),
|
||||
"up_h": human_rate(int(item["session_up_peak"])),
|
||||
"down_at": item.get("session_down_peak_at"),
|
||||
"up_at": item.get("session_up_peak_at"),
|
||||
},
|
||||
"all_time": {
|
||||
"down": int(item["all_time_down_peak"]),
|
||||
"up": int(item["all_time_up_peak"]),
|
||||
"down_h": human_rate(int(item["all_time_down_peak"])),
|
||||
"up_h": human_rate(int(item["all_time_up_peak"])),
|
||||
"down_at": item.get("all_time_down_peak_at"),
|
||||
"up_at": item.get("all_time_up_peak_at"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def record(profile_id: int, down_rate: int = 0, up_rate: int = 0) -> dict[str, Any]:
|
||||
# Note: The poller calls this in the background; the database updates only after a record is beaten.
|
||||
load_cache()
|
||||
down_rate = max(0, int(down_rate or 0))
|
||||
up_rate = max(0, int(up_rate or 0))
|
||||
measured_at = utcnow()
|
||||
changed = False
|
||||
with _LOCK:
|
||||
item = _ensure_profile(int(profile_id))
|
||||
if down_rate > int(item["session_down_peak"]):
|
||||
item["session_down_peak"] = down_rate
|
||||
item["session_down_peak_at"] = measured_at
|
||||
changed = True
|
||||
if up_rate > int(item["session_up_peak"]):
|
||||
item["session_up_peak"] = up_rate
|
||||
item["session_up_peak_at"] = measured_at
|
||||
changed = True
|
||||
if down_rate > int(item["all_time_down_peak"]):
|
||||
item["all_time_down_peak"] = down_rate
|
||||
item["all_time_down_peak_at"] = measured_at
|
||||
changed = True
|
||||
if up_rate > int(item["all_time_up_peak"]):
|
||||
item["all_time_up_peak"] = up_rate
|
||||
item["all_time_up_peak_at"] = measured_at
|
||||
changed = True
|
||||
result = _public(item)
|
||||
if changed:
|
||||
_persist(item)
|
||||
return result
|
||||
|
||||
|
||||
def current(profile_id: int) -> dict[str, Any]:
|
||||
# Note: The REST API can show the latest known record without forcing a new measurement.
|
||||
load_cache()
|
||||
with _LOCK:
|
||||
return _public(_ensure_profile(int(profile_id)))
|
||||
26
pytorrent/services/startup_config.py
Normal file
26
pytorrent/services/startup_config.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from time import sleep
|
||||
from . import preferences, rtorrent
|
||||
|
||||
_started = False
|
||||
|
||||
|
||||
def schedule_startup_config_apply(socketio, delay_seconds: int = 60) -> None:
|
||||
"""Apply saved rTorrent UI overrides after pyTorrent has been running for a moment."""
|
||||
global _started
|
||||
if _started:
|
||||
return
|
||||
_started = True
|
||||
|
||||
def runner():
|
||||
sleep(max(0, int(delay_seconds)))
|
||||
try:
|
||||
for profile in preferences.list_profiles():
|
||||
result = rtorrent.apply_startup_overrides(profile)
|
||||
if not result.get("skipped"):
|
||||
socketio.emit("rtorrent_config_applied", {"profile_id": profile["id"], "result": result})
|
||||
except Exception as exc:
|
||||
socketio.emit("rtorrent_config_applied", {"ok": False, "error": str(exc)})
|
||||
|
||||
socketio.start_background_task(runner)
|
||||
68
pytorrent/services/torrent_cache.py
Normal file
68
pytorrent/services/torrent_cache.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from threading import RLock
|
||||
from time import time
|
||||
from . import rtorrent
|
||||
|
||||
_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"}
|
||||
|
||||
|
||||
class TorrentCache:
|
||||
def __init__(self):
|
||||
self._lock = RLock()
|
||||
self._data: dict[int, dict[str, dict]] = {}
|
||||
self._errors: dict[int, str] = {}
|
||||
self._updated_at: dict[int, float] = {}
|
||||
|
||||
def snapshot(self, profile_id: int) -> list[dict]:
|
||||
with self._lock:
|
||||
return list(self._data.get(profile_id, {}).values())
|
||||
|
||||
def error(self, profile_id: int) -> str:
|
||||
with self._lock:
|
||||
return self._errors.get(profile_id, "")
|
||||
|
||||
def clear_profile(self, profile_id: int) -> int:
|
||||
"""Clear cached torrent rows for one profile and return removed row count."""
|
||||
# Note: Cleanup clears only in-memory rows for the selected profile; rTorrent data is untouched.
|
||||
profile_id = int(profile_id or 0)
|
||||
with self._lock:
|
||||
removed = len(self._data.get(profile_id, {}))
|
||||
self._data.pop(profile_id, None)
|
||||
self._errors.pop(profile_id, None)
|
||||
self._updated_at.pop(profile_id, None)
|
||||
return removed
|
||||
|
||||
def refresh(self, profile: dict) -> dict:
|
||||
profile_id = int(profile["id"])
|
||||
try:
|
||||
rows = rtorrent.list_torrents(profile)
|
||||
with self._lock:
|
||||
old = dict(self._data.get(profile_id, {}))
|
||||
post_check_changes = rtorrent.apply_post_check_policy(profile, rows, old)
|
||||
fresh = {t["hash"]: t for t in rows}
|
||||
with self._lock:
|
||||
added = [v for h, v in fresh.items() if h not in old]
|
||||
removed = [h for h in old.keys() if h not in fresh]
|
||||
updated = []
|
||||
for h, new in fresh.items():
|
||||
prev = old.get(h)
|
||||
if not prev:
|
||||
continue
|
||||
patch = {"hash": h}
|
||||
for key, value in new.items():
|
||||
if prev.get(key) != value:
|
||||
patch[key] = value
|
||||
if len(patch) > 1:
|
||||
updated.append(patch)
|
||||
self._data[profile_id] = fresh
|
||||
self._errors[profile_id] = ""
|
||||
self._updated_at[profile_id] = time()
|
||||
return {"ok": True, "profile_id": profile_id, "added": added, "updated": updated, "removed": removed, "post_check_changes": post_check_changes}
|
||||
except Exception as exc:
|
||||
with self._lock:
|
||||
self._errors[profile_id] = str(exc)
|
||||
return {"ok": False, "profile_id": profile_id, "error": str(exc), "added": [], "updated": [], "removed": []}
|
||||
|
||||
|
||||
torrent_cache = TorrentCache()
|
||||
155
pytorrent/services/torrent_creator.py
Normal file
155
pytorrent/services/torrent_creator.py
Normal file
@@ -0,0 +1,155 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
DEFAULT_PIECE_KIB = 256
|
||||
MIN_PIECE_KIB = 16
|
||||
MAX_PIECE_KIB = 16384
|
||||
|
||||
|
||||
def _bencode(value: Any) -> bytes:
|
||||
if isinstance(value, bool):
|
||||
value = int(value)
|
||||
if isinstance(value, int):
|
||||
return b"i" + str(value).encode("ascii") + b"e"
|
||||
if isinstance(value, bytes):
|
||||
return str(len(value)).encode("ascii") + b":" + value
|
||||
if isinstance(value, str):
|
||||
raw = value.encode("utf-8")
|
||||
return str(len(raw)).encode("ascii") + b":" + raw
|
||||
if isinstance(value, (list, tuple)):
|
||||
return b"l" + b"".join(_bencode(item) for item in value) + b"e"
|
||||
if isinstance(value, dict):
|
||||
items = []
|
||||
for key in sorted(value.keys(), key=lambda k: k.encode("utf-8") if isinstance(k, str) else bytes(k)):
|
||||
bkey = key.encode("utf-8") if isinstance(key, str) else bytes(key)
|
||||
items.append(_bencode(bkey) + _bencode(value[key]))
|
||||
return b"d" + b"".join(items) + b"e"
|
||||
raise TypeError(f"Unsupported bencode value: {type(value)!r}")
|
||||
|
||||
|
||||
def _clean_tracker_lines(raw: str) -> list[str]:
|
||||
lines = []
|
||||
seen = set()
|
||||
for item in str(raw or "").replace("\r", "\n").split("\n"):
|
||||
url = item.strip()
|
||||
if not url or url in seen:
|
||||
continue
|
||||
seen.add(url)
|
||||
lines.append(url)
|
||||
return lines
|
||||
|
||||
|
||||
def _normalize_piece_size(piece_size_kib: int | str | None) -> int:
|
||||
try:
|
||||
kib = int(piece_size_kib or DEFAULT_PIECE_KIB)
|
||||
except Exception:
|
||||
kib = DEFAULT_PIECE_KIB
|
||||
kib = max(MIN_PIECE_KIB, min(MAX_PIECE_KIB, kib))
|
||||
return kib * 1024
|
||||
|
||||
|
||||
def _safe_path_parts(path: Path) -> list[str]:
|
||||
parts = [part for part in path.parts if part not in {"", ".", ".."}]
|
||||
if not parts:
|
||||
raise ValueError("File path inside torrent is empty")
|
||||
return parts
|
||||
|
||||
|
||||
def _iter_files(source: Path) -> list[tuple[Path, list[str], int]]:
|
||||
if source.is_file():
|
||||
return [(source, [source.name], source.stat().st_size)]
|
||||
if not source.is_dir():
|
||||
raise ValueError("Source must be an existing file or directory")
|
||||
rows: list[tuple[Path, list[str], int]] = []
|
||||
for root, dirs, files in os.walk(source):
|
||||
dirs[:] = sorted(d for d in dirs if not (Path(root) / d).is_symlink())
|
||||
for filename in sorted(files):
|
||||
full = Path(root) / filename
|
||||
if full.is_symlink() or not full.is_file():
|
||||
continue
|
||||
rel = full.relative_to(source)
|
||||
rows.append((full, _safe_path_parts(rel), full.stat().st_size))
|
||||
if not rows:
|
||||
raise ValueError("Source directory does not contain regular files")
|
||||
return rows
|
||||
|
||||
|
||||
def _piece_hashes(files: list[tuple[Path, list[str], int]], piece_size: int) -> bytes:
|
||||
pieces = bytearray()
|
||||
buffer = bytearray()
|
||||
for full, _parts, _size in files:
|
||||
with full.open("rb") as handle:
|
||||
while True:
|
||||
chunk = handle.read(max(64 * 1024, min(piece_size, 1024 * 1024)))
|
||||
if not chunk:
|
||||
break
|
||||
buffer.extend(chunk)
|
||||
while len(buffer) >= piece_size:
|
||||
piece = bytes(buffer[:piece_size])
|
||||
del buffer[:piece_size]
|
||||
pieces.extend(hashlib.sha1(piece).digest())
|
||||
if buffer:
|
||||
pieces.extend(hashlib.sha1(bytes(buffer)).digest())
|
||||
return bytes(pieces)
|
||||
|
||||
|
||||
def build_torrent(
|
||||
source_path: str,
|
||||
trackers: str = "",
|
||||
comment: str = "",
|
||||
source: str = "",
|
||||
piece_size_kib: int | str | None = DEFAULT_PIECE_KIB,
|
||||
private: bool = False,
|
||||
created_by: str = "pyTorrent",
|
||||
) -> dict[str, Any]:
|
||||
source_path = str(source_path or "").strip()
|
||||
if not source_path:
|
||||
raise ValueError("Source path is required")
|
||||
path = Path(source_path).expanduser().resolve()
|
||||
files = _iter_files(path)
|
||||
piece_size = _normalize_piece_size(piece_size_kib)
|
||||
|
||||
info: dict[str, Any] = {
|
||||
"name": path.name,
|
||||
"piece length": piece_size,
|
||||
"pieces": _piece_hashes(files, piece_size),
|
||||
}
|
||||
if private:
|
||||
info["private"] = 1
|
||||
if source:
|
||||
info["source"] = str(source).strip()
|
||||
if path.is_file():
|
||||
info["length"] = files[0][2]
|
||||
else:
|
||||
info["files"] = [{"length": size, "path": parts} for _full, parts, size in files]
|
||||
|
||||
tracker_lines = _clean_tracker_lines(trackers)
|
||||
meta: dict[str, Any] = {
|
||||
"created by": created_by,
|
||||
"creation date": int(time.time()),
|
||||
"info": info,
|
||||
}
|
||||
if tracker_lines:
|
||||
meta["announce"] = tracker_lines[0]
|
||||
meta["announce-list"] = [[url] for url in tracker_lines]
|
||||
if comment:
|
||||
meta["comment"] = str(comment).strip()
|
||||
|
||||
data = _bencode(meta)
|
||||
info_hash = hashlib.sha1(_bencode(info)).hexdigest().upper()
|
||||
return {
|
||||
"data": data,
|
||||
"filename": f"{path.name}.torrent",
|
||||
"info_hash": info_hash,
|
||||
"source_parent": str(path.parent),
|
||||
"file_count": len(files),
|
||||
"total_size": sum(size for _full, _parts, size in files),
|
||||
"piece_size": piece_size,
|
||||
"private": bool(private),
|
||||
"trackers": tracker_lines,
|
||||
}
|
||||
150
pytorrent/services/torrent_meta.py
Normal file
150
pytorrent/services/torrent_meta.py
Normal file
@@ -0,0 +1,150 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
|
||||
class BencodeError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class BencodeReader:
|
||||
def __init__(self, data: bytes):
|
||||
self.data = data
|
||||
self.pos = 0
|
||||
|
||||
def parse(self) -> Any:
|
||||
value = self._read_value()
|
||||
if self.pos != len(self.data):
|
||||
raise BencodeError("Trailing data in torrent file")
|
||||
return value
|
||||
|
||||
def _read_value(self) -> Any:
|
||||
if self.pos >= len(self.data):
|
||||
raise BencodeError("Unexpected end of bencoded data")
|
||||
token = self.data[self.pos:self.pos + 1]
|
||||
if token == b"i":
|
||||
return self._read_int()
|
||||
if token == b"l":
|
||||
return self._read_list()
|
||||
if token == b"d":
|
||||
return self._read_dict()
|
||||
if b"0" <= token <= b"9":
|
||||
return self._read_bytes()
|
||||
raise BencodeError(f"Invalid bencode token at offset {self.pos}")
|
||||
|
||||
def _read_int(self) -> int:
|
||||
self.pos += 1
|
||||
end = self.data.find(b"e", self.pos)
|
||||
if end < 0:
|
||||
raise BencodeError("Unterminated integer")
|
||||
raw = self.data[self.pos:end]
|
||||
self.pos = end + 1
|
||||
return int(raw)
|
||||
|
||||
def _read_bytes(self) -> bytes:
|
||||
colon = self.data.find(b":", self.pos)
|
||||
if colon < 0:
|
||||
raise BencodeError("Invalid byte string length")
|
||||
length = int(self.data[self.pos:colon])
|
||||
self.pos = colon + 1
|
||||
end = self.pos + length
|
||||
if end > len(self.data):
|
||||
raise BencodeError("Byte string exceeds input size")
|
||||
value = self.data[self.pos:end]
|
||||
self.pos = end
|
||||
return value
|
||||
|
||||
def _read_list(self) -> list[Any]:
|
||||
self.pos += 1
|
||||
out: list[Any] = []
|
||||
while self.pos < len(self.data) and self.data[self.pos:self.pos + 1] != b"e":
|
||||
out.append(self._read_value())
|
||||
if self.pos >= len(self.data):
|
||||
raise BencodeError("Unterminated list")
|
||||
self.pos += 1
|
||||
return out
|
||||
|
||||
def _read_dict(self) -> dict[bytes, Any]:
|
||||
self.pos += 1
|
||||
out: dict[bytes, Any] = {}
|
||||
while self.pos < len(self.data) and self.data[self.pos:self.pos + 1] != b"e":
|
||||
key = self._read_bytes()
|
||||
out[key] = self._read_value()
|
||||
if self.pos >= len(self.data):
|
||||
raise BencodeError("Unterminated dictionary")
|
||||
self.pos += 1
|
||||
return out
|
||||
|
||||
|
||||
def bencode(value: Any) -> bytes:
|
||||
if isinstance(value, int):
|
||||
return b"i" + str(value).encode("ascii") + b"e"
|
||||
if isinstance(value, bytes):
|
||||
return str(len(value)).encode("ascii") + b":" + value
|
||||
if isinstance(value, str):
|
||||
raw = value.encode("utf-8")
|
||||
return str(len(raw)).encode("ascii") + b":" + raw
|
||||
if isinstance(value, list):
|
||||
return b"l" + b"".join(bencode(item) for item in value) + b"e"
|
||||
if isinstance(value, dict):
|
||||
items = sorted(value.items(), key=lambda item: item[0] if isinstance(item[0], bytes) else str(item[0]).encode("utf-8"))
|
||||
raw = []
|
||||
for key, item in items:
|
||||
raw.append(bencode(key if isinstance(key, bytes) else str(key)))
|
||||
raw.append(bencode(item))
|
||||
return b"d" + b"".join(raw) + b"e"
|
||||
raise TypeError(f"Unsupported bencode type: {type(value)!r}")
|
||||
|
||||
|
||||
def _text(value: Any) -> str:
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8", "replace")
|
||||
return str(value or "")
|
||||
|
||||
|
||||
def parse_torrent(data: bytes) -> dict:
|
||||
# Note: The parser is dependency-free so .torrent preview works in offline installations.
|
||||
root = BencodeReader(data).parse()
|
||||
if not isinstance(root, dict) or b"info" not in root:
|
||||
raise BencodeError("Missing torrent info dictionary")
|
||||
info = root[b"info"]
|
||||
if not isinstance(info, dict):
|
||||
raise BencodeError("Invalid torrent info dictionary")
|
||||
info_hash = hashlib.sha1(bencode(info)).hexdigest().upper()
|
||||
name = _text(info.get(b"name") or "")
|
||||
piece_length = int(info.get(b"piece length") or 0)
|
||||
private = int(info.get(b"private") or 0)
|
||||
files: list[dict] = []
|
||||
total = 0
|
||||
if b"files" in info:
|
||||
for entry in info.get(b"files") or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
length = int(entry.get(b"length") or 0)
|
||||
path_parts = [_text(part) for part in entry.get(b"path") or []]
|
||||
rel_path = str(PurePosixPath(name, *path_parts)) if path_parts else name
|
||||
total += length
|
||||
files.append({"path": rel_path, "size": length})
|
||||
else:
|
||||
length = int(info.get(b"length") or 0)
|
||||
total = length
|
||||
files.append({"path": name, "size": length})
|
||||
announce = _text(root.get(b"announce") or "")
|
||||
trackers = [announce] if announce else []
|
||||
for tier in root.get(b"announce-list") or []:
|
||||
for tracker in tier if isinstance(tier, list) else [tier]:
|
||||
value = _text(tracker)
|
||||
if value and value not in trackers:
|
||||
trackers.append(value)
|
||||
return {
|
||||
"name": name,
|
||||
"info_hash": info_hash,
|
||||
"size": total,
|
||||
"file_count": len(files),
|
||||
"files": files,
|
||||
"trackers": trackers,
|
||||
"piece_length": piece_length,
|
||||
"private": private,
|
||||
}
|
||||
209
pytorrent/services/torrent_stats.py
Normal file
209
pytorrent/services/torrent_stats.py
Normal file
@@ -0,0 +1,209 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from ..db import connect, utcnow
|
||||
from . import rtorrent
|
||||
from .torrent_cache import torrent_cache
|
||||
|
||||
CACHE_SECONDS = 15 * 60
|
||||
_STARTUP_DELAY_SECONDS = 3 * 60
|
||||
_STARTED_AT = time.monotonic()
|
||||
_LOCK = threading.Lock()
|
||||
_BACKGROUND_LOCK = threading.Lock()
|
||||
_BACKGROUND_PROFILE_IDS: set[int] = set()
|
||||
|
||||
|
||||
def _human_size(value: int | float) -> str:
|
||||
size = float(value or 0)
|
||||
for unit in ("B", "KiB", "MiB", "GiB", "TiB", "PiB"):
|
||||
if abs(size) < 1024 or unit == "PiB":
|
||||
return f"{size:.1f} {unit}" if unit != "B" else f"{int(size)} B"
|
||||
size /= 1024
|
||||
return f"{size:.1f} PiB"
|
||||
|
||||
|
||||
def _empty(profile_id: int, error: str = "") -> dict[str, Any]:
|
||||
now = utcnow()
|
||||
return {
|
||||
"profile_id": profile_id,
|
||||
"torrent_count": 0,
|
||||
"complete_count": 0,
|
||||
"incomplete_count": 0,
|
||||
"total_torrent_size": 0,
|
||||
"total_torrent_size_h": _human_size(0),
|
||||
"total_file_size": 0,
|
||||
"total_file_size_h": _human_size(0),
|
||||
"file_count": 0,
|
||||
"seeds_total": 0,
|
||||
"peers_total": 0,
|
||||
"down_rate_total": 0,
|
||||
"up_rate_total": 0,
|
||||
"down_rate_total_h": "0 B/s",
|
||||
"up_rate_total_h": "0 B/s",
|
||||
"sampled_torrents": 0,
|
||||
"errors": [],
|
||||
"error": error,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"age_seconds": 0,
|
||||
"stale": True,
|
||||
}
|
||||
|
||||
|
||||
def _load_cached(profile_id: int) -> dict[str, Any] | None:
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT * FROM torrent_stats_cache WHERE profile_id=?", (profile_id,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
payload = json.loads(row.get("payload_json") or "{}")
|
||||
payload["created_at"] = row.get("created_at")
|
||||
payload["updated_at"] = row.get("updated_at")
|
||||
try:
|
||||
payload["age_seconds"] = max(0, int(time.time() - float(row.get("updated_epoch") or 0)))
|
||||
except Exception:
|
||||
payload["age_seconds"] = 0
|
||||
payload["stale"] = payload["age_seconds"] >= CACHE_SECONDS
|
||||
return payload
|
||||
|
||||
|
||||
def _save(profile_id: int, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
now = utcnow()
|
||||
payload = dict(payload)
|
||||
payload["updated_at"] = now
|
||||
payload["age_seconds"] = 0
|
||||
payload["stale"] = False
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO torrent_stats_cache(profile_id,payload_json,created_at,updated_at,updated_epoch)
|
||||
VALUES(?,?,?,?,?)
|
||||
ON CONFLICT(profile_id) DO UPDATE SET
|
||||
payload_json=excluded.payload_json,
|
||||
updated_at=excluded.updated_at,
|
||||
updated_epoch=excluded.updated_epoch
|
||||
""",
|
||||
(profile_id, json.dumps(payload), now, now, time.time()),
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def collect(profile: dict) -> dict[str, Any]:
|
||||
"""Collect heavier torrent/file statistics on demand or every cache window."""
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
torrents = rtorrent.list_torrents(profile)
|
||||
total_torrent_size = sum(int(t.get("size") or 0) for t in torrents)
|
||||
seeds_total = sum(int(t.get("seeds") or 0) for t in torrents)
|
||||
peers_total = sum(int(t.get("peers") or 0) for t in torrents)
|
||||
down_rate_total = sum(int(t.get("down_rate") or 0) for t in torrents)
|
||||
up_rate_total = sum(int(t.get("up_rate") or 0) for t in torrents)
|
||||
total_file_size = 0
|
||||
file_count = 0
|
||||
errors: list[dict[str, str]] = []
|
||||
|
||||
# Note: File metadata is queried per torrent only during cached statistics refresh, not during every UI poll.
|
||||
for torrent in torrents:
|
||||
h = str(torrent.get("hash") or "")
|
||||
if not h:
|
||||
continue
|
||||
try:
|
||||
files = rtorrent.torrent_files(profile, h)
|
||||
file_count += len(files)
|
||||
total_file_size += sum(int(f.get("size") or 0) for f in files)
|
||||
except Exception as exc:
|
||||
errors.append({"hash": h, "name": str(torrent.get("name") or ""), "error": str(exc)})
|
||||
|
||||
torrent_cache.refresh(profile)
|
||||
payload = {
|
||||
"profile_id": profile_id,
|
||||
"torrent_count": len(torrents),
|
||||
"complete_count": sum(1 for t in torrents if int(t.get("complete") or 0)),
|
||||
"incomplete_count": sum(1 for t in torrents if not int(t.get("complete") or 0)),
|
||||
"total_torrent_size": total_torrent_size,
|
||||
"total_torrent_size_h": _human_size(total_torrent_size),
|
||||
"total_file_size": total_file_size,
|
||||
"total_file_size_h": _human_size(total_file_size),
|
||||
"file_count": file_count,
|
||||
"seeds_total": seeds_total,
|
||||
"peers_total": peers_total,
|
||||
"down_rate_total": down_rate_total,
|
||||
"up_rate_total": up_rate_total,
|
||||
"down_rate_total_h": rtorrent.human_rate(down_rate_total),
|
||||
"up_rate_total_h": rtorrent.human_rate(up_rate_total),
|
||||
"sampled_torrents": len(torrents),
|
||||
"errors": errors[:25],
|
||||
"error": "" if not errors else f"File metadata failed for {len(errors)} torrent(s)",
|
||||
"created_at": utcnow(),
|
||||
}
|
||||
return _save(profile_id, payload)
|
||||
|
||||
|
||||
def get(profile: dict | None, force: bool = False) -> dict[str, Any]:
|
||||
if not profile:
|
||||
return _empty(0, "No active rTorrent profile")
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
cached = _load_cached(profile_id)
|
||||
if cached and not force and not cached.get("stale"):
|
||||
return cached
|
||||
if cached and not force:
|
||||
return cached
|
||||
with _LOCK:
|
||||
cached = _load_cached(profile_id)
|
||||
if cached and not force and not cached.get("stale"):
|
||||
return cached
|
||||
return collect(profile)
|
||||
|
||||
|
||||
def maybe_refresh(profile: dict | None, force: bool = False) -> dict[str, Any] | None:
|
||||
if not profile:
|
||||
return None
|
||||
if not force and time.monotonic() - _STARTED_AT < _STARTUP_DELAY_SECONDS:
|
||||
return None
|
||||
cached = _load_cached(int(profile.get("id") or 0))
|
||||
if cached and not cached.get("stale") and not force:
|
||||
return cached
|
||||
try:
|
||||
return get(profile, force=True)
|
||||
except Exception:
|
||||
return cached
|
||||
|
||||
|
||||
def queue_refresh(socketio, profile: dict | None, force: bool = False, emit_update: bool = True, room: str | None = None) -> dict[str, Any] | None:
|
||||
"""Schedule heavier statistics refresh outside the main WebSocket/system poller."""
|
||||
if not profile:
|
||||
return None
|
||||
if not force and time.monotonic() - _STARTED_AT < _STARTUP_DELAY_SECONDS:
|
||||
return _load_cached(int(profile.get("id") or 0))
|
||||
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
cached = _load_cached(profile_id)
|
||||
if cached and not cached.get("stale") and not force:
|
||||
return cached
|
||||
|
||||
with _BACKGROUND_LOCK:
|
||||
if profile_id in _BACKGROUND_PROFILE_IDS:
|
||||
return cached
|
||||
_BACKGROUND_PROFILE_IDS.add(profile_id)
|
||||
|
||||
profile_snapshot = dict(profile)
|
||||
|
||||
def runner():
|
||||
try:
|
||||
# Note: This can query file metadata per torrent, so it never runs inside the fast CPU/RAM/disk poller.
|
||||
stats = get(profile_snapshot, force=True)
|
||||
if emit_update and stats:
|
||||
payload = {"profile_id": profile_id, "stats": stats}
|
||||
socketio.emit("torrent_stats_update", payload, to=room) if room else socketio.emit("torrent_stats_update", payload)
|
||||
except Exception as exc:
|
||||
if emit_update:
|
||||
payload = {"profile_id": profile_id, "ok": False, "error": str(exc)}
|
||||
socketio.emit("torrent_stats_update", payload, to=room) if room else socketio.emit("torrent_stats_update", payload)
|
||||
finally:
|
||||
with _BACKGROUND_LOCK:
|
||||
_BACKGROUND_PROFILE_IDS.discard(profile_id)
|
||||
|
||||
socketio.start_background_task(runner)
|
||||
return cached
|
||||
136
pytorrent/services/torrent_summary.py
Normal file
136
pytorrent/services/torrent_summary.py
Normal file
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from threading import RLock
|
||||
from time import time
|
||||
|
||||
SUMMARY_CACHE_TTL_SECONDS = 60
|
||||
|
||||
_ERROR_PATTERNS = (
|
||||
"error",
|
||||
"failed",
|
||||
"failure",
|
||||
"timeout",
|
||||
"timed out",
|
||||
"tracker",
|
||||
"could not",
|
||||
"cannot",
|
||||
"refused",
|
||||
"unreachable",
|
||||
"denied",
|
||||
)
|
||||
_SUMMARY_TYPES = ("all", "downloading", "seeding", "paused", "checking", "error", "stopped")
|
||||
_summary_cache: dict[int, dict] = {}
|
||||
_summary_lock = RLock()
|
||||
|
||||
|
||||
def _number(row: dict, key: str) -> int:
|
||||
try:
|
||||
return int(float(row.get(key) or 0))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _has_error(row: dict) -> bool:
|
||||
message = str(row.get("message") or "").strip().lower()
|
||||
return bool(message and any(pattern in message for pattern in _ERROR_PATTERNS))
|
||||
|
||||
|
||||
def _is_checking(row: dict) -> bool:
|
||||
return str(row.get("status") or "") == "Checking" or _number(row, "hashing") > 0
|
||||
|
||||
|
||||
def _matches(row: dict, summary_type: str) -> bool:
|
||||
status = str(row.get("status") or "")
|
||||
checking = _is_checking(row)
|
||||
if summary_type == "all":
|
||||
return True
|
||||
if summary_type == "downloading":
|
||||
return not checking and not bool(row.get("complete")) and bool(row.get("state")) and not bool(row.get("paused"))
|
||||
if summary_type == "seeding":
|
||||
return not checking and bool(row.get("complete")) and bool(row.get("state")) and not bool(row.get("paused"))
|
||||
if summary_type == "paused":
|
||||
return not checking and (bool(row.get("paused")) or status == "Paused")
|
||||
if summary_type == "checking":
|
||||
return checking
|
||||
if summary_type == "error":
|
||||
return _has_error(row)
|
||||
if summary_type == "stopped":
|
||||
# Note: Stopped count follows the UI filter exactly, so torrents being hash-checked do not inflate an empty Stopped list.
|
||||
return not checking and not bool(row.get("state"))
|
||||
return False
|
||||
|
||||
|
||||
def _empty_bucket() -> dict:
|
||||
return {
|
||||
"count": 0,
|
||||
"size": 0,
|
||||
"disk_bytes": 0,
|
||||
"completed_bytes": 0,
|
||||
"remaining_bytes": 0,
|
||||
"progress_percent": 0.0,
|
||||
"remaining_percent": 100.0,
|
||||
# Kept for backward compatibility with older clients; not used by the filters UI.
|
||||
"down_total": 0,
|
||||
"up_total": 0,
|
||||
}
|
||||
|
||||
|
||||
def build_summary(rows: list[dict]) -> dict:
|
||||
filters = {summary_type: _empty_bucket() for summary_type in _SUMMARY_TYPES}
|
||||
for row in rows:
|
||||
for summary_type in _SUMMARY_TYPES:
|
||||
if not _matches(row, summary_type):
|
||||
continue
|
||||
bucket = filters[summary_type]
|
||||
bucket["count"] += 1
|
||||
size = _number(row, "size")
|
||||
completed = min(size, _number(row, "completed_bytes")) if size else _number(row, "completed_bytes")
|
||||
bucket["size"] += size
|
||||
bucket["completed_bytes"] += completed
|
||||
bucket["disk_bytes"] += completed
|
||||
bucket["down_total"] += _number(row, "down_total")
|
||||
bucket["up_total"] += _number(row, "up_total")
|
||||
for bucket in filters.values():
|
||||
bucket["remaining_bytes"] = max(0, bucket["size"] - bucket["completed_bytes"])
|
||||
if bucket["size"] > 0:
|
||||
bucket["progress_percent"] = round((bucket["completed_bytes"] / bucket["size"]) * 100, 1)
|
||||
bucket["remaining_percent"] = round(100 - bucket["progress_percent"], 1)
|
||||
else:
|
||||
bucket["progress_percent"] = 0.0
|
||||
bucket["remaining_percent"] = 0.0
|
||||
now = time()
|
||||
return {
|
||||
"filters": filters,
|
||||
"cache_ttl_seconds": SUMMARY_CACHE_TTL_SECONDS,
|
||||
"generated_at_epoch": now,
|
||||
"cached": False,
|
||||
}
|
||||
|
||||
|
||||
def cached_summary(profile_id: int, rows: list[dict], force: bool = False) -> dict:
|
||||
now = time()
|
||||
with _summary_lock:
|
||||
cached = _summary_cache.get(int(profile_id))
|
||||
rows_count = len(rows or [])
|
||||
cached_count = int(((cached or {}).get("filters") or {}).get("all", {}).get("count") or 0)
|
||||
cache_is_fresh = cached and now - float(cached.get("generated_at_epoch") or 0) < SUMMARY_CACHE_TTL_SECONDS
|
||||
cache_is_usable = cache_is_fresh and not (cached_count == 0 and rows_count > 0)
|
||||
if not force and cache_is_usable:
|
||||
result = deepcopy(cached)
|
||||
result["cached"] = True
|
||||
return result
|
||||
result = build_summary(rows or [])
|
||||
# Do not cache an empty cold-start snapshot. On first connection the cache may be populated
|
||||
# before rTorrent refresh finishes, which would otherwise show zeros for the full TTL.
|
||||
if rows_count > 0 or force:
|
||||
_summary_cache[int(profile_id)] = deepcopy(result)
|
||||
return result
|
||||
|
||||
|
||||
def invalidate_summary(profile_id: int | None = None) -> None:
|
||||
with _summary_lock:
|
||||
if profile_id is None:
|
||||
_summary_cache.clear()
|
||||
else:
|
||||
_summary_cache.pop(int(profile_id), None)
|
||||
440
pytorrent/services/tracker_cache.py
Normal file
440
pytorrent/services/tracker_cache.py
Normal file
@@ -0,0 +1,440 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import mimetypes
|
||||
import re
|
||||
import time
|
||||
import threading
|
||||
import ssl
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
from ..config import BASE_DIR
|
||||
from ..db import connect, utcnow
|
||||
|
||||
TRACKER_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60
|
||||
FAVICON_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60
|
||||
TRACKER_SCAN_LIMIT = 80
|
||||
FAVICON_DIR = BASE_DIR / "data" / "tracker_favicons"
|
||||
PUBLIC_FAVICON_BASE = "/static/tracker_favicons"
|
||||
_TRACKER_SCAN_LOCKS: dict[int, threading.Lock] = {}
|
||||
_TRACKER_SCAN_LOCKS_GUARD = threading.Lock()
|
||||
|
||||
|
||||
class _IconParser(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.icons: list[str] = []
|
||||
|
||||
def handle_starttag(self, tag: str, attrs):
|
||||
if tag.lower() != "link":
|
||||
return
|
||||
data = {str(k).lower(): str(v or "") for k, v in attrs}
|
||||
rel = re.sub(r"\s+", " ", data.get("rel", "").lower()).strip()
|
||||
href = data.get("href", "").strip()
|
||||
if href and "icon" in rel:
|
||||
self.icons.append(href)
|
||||
|
||||
|
||||
def _now_epoch() -> float:
|
||||
return time.time()
|
||||
|
||||
|
||||
def tracker_domain(url: str) -> str:
|
||||
raw = str(url or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
parsed = urllib.parse.urlparse(raw if "://" in raw else f"http://{raw}")
|
||||
host = (parsed.hostname or "").lower().strip(".")
|
||||
if host.startswith("www."):
|
||||
host = host[4:]
|
||||
return host
|
||||
|
||||
|
||||
def _root_domain(domain: str) -> str:
|
||||
parts = [p for p in str(domain or "").lower().strip(".").split(".") if p]
|
||||
if len(parts) <= 2:
|
||||
return ".".join(parts)
|
||||
# Note: Tracker favicon discovery needs the real main site first; for t.pte.nu that is pte.nu, not t.pte.nu.
|
||||
known_second_level_suffixes = {"co", "com", "net", "org", "gov", "edu", "ac"}
|
||||
if len(parts[-1]) == 2 and parts[-2] in known_second_level_suffixes and len(parts) >= 3:
|
||||
return ".".join(parts[-3:])
|
||||
return ".".join(parts[-2:])
|
||||
|
||||
|
||||
def _safe_filename(domain: str) -> str:
|
||||
return re.sub(r"[^a-z0-9_.-]+", "_", domain.lower()).strip("._") or "tracker"
|
||||
|
||||
|
||||
def _read_cached(profile_id: int, hashes: list[str], ttl: int) -> tuple[dict[str, list[dict]], set[str]]:
|
||||
if not hashes:
|
||||
return {}, set()
|
||||
now = _now_epoch()
|
||||
cached: dict[str, list[dict]] = {}
|
||||
fresh: set[str] = set()
|
||||
with connect() as conn:
|
||||
for start in range(0, len(hashes), 900):
|
||||
chunk = hashes[start:start + 900]
|
||||
placeholders = ",".join("?" for _ in chunk)
|
||||
rows = conn.execute(
|
||||
f"SELECT torrent_hash, trackers_json, updated_epoch FROM tracker_summary_cache WHERE profile_id=? AND torrent_hash IN ({placeholders})",
|
||||
(profile_id, *chunk),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
h = str(row.get("torrent_hash") or "")
|
||||
try:
|
||||
items = json.loads(row.get("trackers_json") or "[]")
|
||||
except Exception:
|
||||
items = []
|
||||
cached[h] = items if isinstance(items, list) else []
|
||||
if now - float(row.get("updated_epoch") or 0) < ttl:
|
||||
fresh.add(h)
|
||||
return cached, fresh
|
||||
|
||||
|
||||
def _store(profile_id: int, torrent_hash: str, trackers: list[dict]) -> None:
|
||||
now = utcnow()
|
||||
epoch = _now_epoch()
|
||||
compact = []
|
||||
seen = set()
|
||||
for item in trackers:
|
||||
domain = tracker_domain(str(item.get("url") or item.get("domain") or "")) or str(item.get("domain") or "")
|
||||
if not domain or domain in seen:
|
||||
continue
|
||||
seen.add(domain)
|
||||
compact.append({"domain": domain, "url": str(item.get("url") or "")})
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO tracker_summary_cache(profile_id, torrent_hash, trackers_json, updated_at, updated_epoch)
|
||||
VALUES(?, ?, ?, ?, ?)
|
||||
ON CONFLICT(profile_id, torrent_hash) DO UPDATE SET
|
||||
trackers_json=excluded.trackers_json,
|
||||
updated_at=excluded.updated_at,
|
||||
updated_epoch=excluded.updated_epoch
|
||||
""",
|
||||
(profile_id, torrent_hash, json.dumps(compact), now, epoch),
|
||||
)
|
||||
|
||||
|
||||
def summary(profile: dict, hashes: list[str], loader, scan_limit: int = TRACKER_SCAN_LIMIT, include_favicons: bool = False) -> dict:
|
||||
"""Build tracker sidebar data from disk cache and refresh a small batch per request."""
|
||||
# Note: Tracker data is cached per torrent hash, so huge rTorrent libraries are never scanned in one UI request.
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
clean_hashes = [str(h or "").strip() for h in hashes if str(h or "").strip()]
|
||||
cached, fresh = _read_cached(profile_id, clean_hashes, TRACKER_CACHE_TTL_SECONDS)
|
||||
missing = [h for h in clean_hashes if h not in fresh]
|
||||
errors: list[dict] = []
|
||||
scanned_now = 0
|
||||
for h in missing[:max(0, int(scan_limit or 0))]:
|
||||
try:
|
||||
trackers = loader(h)
|
||||
_store(profile_id, h, trackers)
|
||||
cached[h] = [{"domain": tracker_domain(t.get("url") or t.get("domain") or ""), "url": str(t.get("url") or "")} for t in trackers]
|
||||
fresh.add(h)
|
||||
scanned_now += 1
|
||||
except Exception as exc:
|
||||
errors.append({"hash": h, "error": str(exc)})
|
||||
by_hash: dict[str, list[dict]] = {}
|
||||
counts: dict[str, dict] = {}
|
||||
for h in clean_hashes:
|
||||
items = []
|
||||
seen = set()
|
||||
for item in cached.get(h, []):
|
||||
domain = tracker_domain(str(item.get("url") or item.get("domain") or "")) or str(item.get("domain") or "")
|
||||
if not domain or domain in seen:
|
||||
continue
|
||||
seen.add(domain)
|
||||
row = {"domain": domain, "url": str(item.get("url") or "")}
|
||||
items.append(row)
|
||||
bucket = counts.setdefault(domain, {"domain": domain, "url": row["url"], "count": 0})
|
||||
bucket["count"] += 1
|
||||
if not bucket.get("url") and row["url"]:
|
||||
bucket["url"] = row["url"]
|
||||
by_hash[h] = items
|
||||
trackers = sorted(counts.values(), key=lambda x: (-int(x.get("count") or 0), str(x.get("domain") or "")))
|
||||
if include_favicons:
|
||||
# Note: Summary returns only already cached static favicon URLs; network favicon discovery stays outside the hot tracker count path.
|
||||
for item in trackers:
|
||||
item["favicon_url"] = favicon_public_url(str(item.get("domain") or ""), enabled=True, create=False)
|
||||
pending = max(0, len([h for h in clean_hashes if h not in fresh]))
|
||||
return {"hashes": by_hash, "trackers": trackers, "errors": errors[:25], "scanned": len(clean_hashes), "scanned_now": scanned_now, "pending": pending, "cached": len(clean_hashes) - pending}
|
||||
|
||||
|
||||
|
||||
def _scan_lock(profile_id: int) -> threading.Lock:
|
||||
with _TRACKER_SCAN_LOCKS_GUARD:
|
||||
if profile_id not in _TRACKER_SCAN_LOCKS:
|
||||
_TRACKER_SCAN_LOCKS[profile_id] = threading.Lock()
|
||||
return _TRACKER_SCAN_LOCKS[profile_id]
|
||||
|
||||
|
||||
def warm_summary_cache(profile: dict, hashes: list[str], loader, batch_size: int = TRACKER_SCAN_LIMIT) -> bool:
|
||||
"""Start a non-blocking tracker cache warmup for large libraries."""
|
||||
# Note: Tracker cache warming runs in one background thread per profile, so F5 returns cached data immediately instead of waiting for rTorrent scans.
|
||||
profile_id = int(profile.get("id") or 0)
|
||||
clean_hashes = [str(h or "").strip() for h in hashes if str(h or "").strip()]
|
||||
if not profile_id or not clean_hashes:
|
||||
return False
|
||||
lock = _scan_lock(profile_id)
|
||||
if lock.locked():
|
||||
return False
|
||||
|
||||
def _worker():
|
||||
if not lock.acquire(blocking=False):
|
||||
return
|
||||
try:
|
||||
while True:
|
||||
result = summary(profile, clean_hashes, loader, scan_limit=max(1, int(batch_size or TRACKER_SCAN_LIMIT)), include_favicons=False)
|
||||
if int(result.get("pending") or 0) <= 0 or int(result.get("scanned_now") or 0) <= 0:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
threading.Thread(target=_worker, name=f"tracker-cache-warm-{profile_id}", daemon=True).start()
|
||||
return True
|
||||
|
||||
|
||||
def favicon_public_url(domain: str, enabled: bool = True, create: bool = False, force: bool = False) -> str:
|
||||
"""Return the static URL for a cached tracker favicon, optionally creating or refreshing it first."""
|
||||
# Note: Favicon files stay in data/tracker_favicons, but the browser loads them via the static/tracker_favicons symlink.
|
||||
clean = tracker_domain(domain)
|
||||
if not enabled or not clean:
|
||||
return ""
|
||||
if create:
|
||||
favicon_path(clean, enabled=True, force=force)
|
||||
cached = _cached_favicon(clean)
|
||||
now = _now_epoch()
|
||||
if not cached or now - float(cached.get("updated_epoch") or 0) >= FAVICON_CACHE_TTL_SECONDS:
|
||||
return ""
|
||||
path = Path(str(cached.get("file_path") or ""))
|
||||
if not path.exists() or not path.is_file():
|
||||
return ""
|
||||
try:
|
||||
rel = path.resolve().relative_to(FAVICON_DIR.resolve())
|
||||
except Exception:
|
||||
rel = Path(path.name)
|
||||
return f"{PUBLIC_FAVICON_BASE}/{urllib.parse.quote(str(rel).replace(chr(92), '/'))}"
|
||||
|
||||
def _fetch(url: str, limit: int = 262144) -> tuple[bytes, str, str]:
|
||||
# Note: Favicon discovery uses browser-like headers and a certificate fallback, because tracker login pages/CDNs often reject minimal Python requests.
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0 (compatible; pyTorrent favicon fetcher)",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,image/*,*/*;q=0.8",
|
||||
"Connection": "close",
|
||||
},
|
||||
)
|
||||
|
||||
def _read(context=None):
|
||||
with urllib.request.urlopen(req, timeout=8, context=context) as resp:
|
||||
data = resp.read(limit + 1)
|
||||
if len(data) > limit:
|
||||
data = data[:limit]
|
||||
content_type = str(resp.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
|
||||
final_url = str(resp.geturl() or url)
|
||||
return data, content_type, final_url
|
||||
|
||||
try:
|
||||
return _read()
|
||||
except urllib.error.URLError as exc:
|
||||
reason = getattr(exc, "reason", None)
|
||||
if isinstance(reason, ssl.SSLError) or "CERTIFICATE_VERIFY_FAILED" in str(exc):
|
||||
return _read(ssl._create_unverified_context())
|
||||
raise
|
||||
|
||||
|
||||
def _is_icon(data: bytes, content_type: str, url: str) -> bool:
|
||||
"""Validate that downloaded bytes are a browser-readable image, not only an image-like HTTP header."""
|
||||
# Note: Some trackers serve a broken /favicon.ico with image/vnd.microsoft.icon; pyTorrent now validates bytes before caching it.
|
||||
if not data or len(data) < 16:
|
||||
return False
|
||||
head = data[:32]
|
||||
lower = data[:512].lstrip().lower()
|
||||
if head.startswith(b"\x00\x00\x01\x00") or head.startswith(b"\x00\x00\x02\x00"):
|
||||
try:
|
||||
count = int.from_bytes(data[4:6], "little")
|
||||
except Exception:
|
||||
count = 0
|
||||
return 0 < count <= 256 and len(data) >= 6 + (16 * count)
|
||||
if head.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
return True
|
||||
if head.startswith(b"\xff\xd8\xff"):
|
||||
return True
|
||||
if head.startswith((b"GIF87a", b"GIF89a")):
|
||||
return True
|
||||
if head.startswith(b"RIFF") and data[8:12] == b"WEBP":
|
||||
return True
|
||||
if lower.startswith(b"<svg") or b"<svg" in lower[:256]:
|
||||
return True
|
||||
ctype = content_type.lower()
|
||||
if ctype in {"image/svg+xml"}:
|
||||
return b"<svg" in lower[:512]
|
||||
return False
|
||||
|
||||
|
||||
|
||||
def _attr_value(tag: str, name: str) -> str:
|
||||
# Note: Accept quoted and unquoted HTML attributes so favicon discovery works with compact/minified tracker pages.
|
||||
match = re.search(rf"\b{name}\s*=\s*(['\"])(.*?)\1", tag, re.I | re.S)
|
||||
if match:
|
||||
return match.group(2).strip()
|
||||
match = re.search(rf"\b{name}\s*=\s*([^\s>]+)", tag, re.I | re.S)
|
||||
return match.group(1).strip().strip("'\"") if match else ""
|
||||
|
||||
|
||||
def _extract_icon_hrefs(html: str) -> list[str]:
|
||||
# Note: Read any <link rel=...icon... href=...> order, including shortcut icon and relative CDN paths.
|
||||
hrefs: list[str] = []
|
||||
parser = _IconParser()
|
||||
try:
|
||||
parser.feed(html)
|
||||
hrefs.extend(parser.icons)
|
||||
except Exception:
|
||||
pass
|
||||
for match in re.finditer(r"<link\b[^>]*>", html, re.I | re.S):
|
||||
tag = match.group(0)
|
||||
rel = _attr_value(tag, "rel").lower()
|
||||
href = _attr_value(tag, "href")
|
||||
if href and "icon" in rel:
|
||||
hrefs.append(href)
|
||||
clean = []
|
||||
seen = set()
|
||||
for href in hrefs:
|
||||
href = str(href or "").strip()
|
||||
if href and href not in seen:
|
||||
seen.add(href)
|
||||
clean.append(href)
|
||||
return clean
|
||||
|
||||
|
||||
def _tracker_icon_hosts(domain: str) -> list[str]:
|
||||
host = tracker_domain(domain)
|
||||
root = _root_domain(host)
|
||||
# Note: Direct favicon fallback checks the tracker host first, then the main domain.
|
||||
return [h for h in dict.fromkeys([host, root]) if h]
|
||||
|
||||
|
||||
def _tracker_html_hosts(domain: str) -> list[str]:
|
||||
host = tracker_domain(domain)
|
||||
root = _root_domain(host)
|
||||
# Note: HTML discovery checks the main site first, because tracker announce hosts often return text/plain.
|
||||
return [h for h in dict.fromkeys([root, host]) if h]
|
||||
|
||||
|
||||
def _favicon_candidates(domain: str) -> list[str]:
|
||||
candidates = []
|
||||
for h in _tracker_icon_hosts(domain):
|
||||
candidates.extend([f"https://{h}/favicon.ico", f"http://{h}/favicon.ico"])
|
||||
return list(dict.fromkeys(candidates))
|
||||
|
||||
|
||||
def _html_icon_candidates(domain: str, errors: list[str] | None = None) -> list[str]:
|
||||
urls = []
|
||||
for h in _tracker_html_hosts(domain):
|
||||
for scheme in ("https", "http"):
|
||||
base = f"{scheme}://{h}/"
|
||||
try:
|
||||
data, ctype, final_url = _fetch(base, limit=524288)
|
||||
except Exception as exc:
|
||||
if errors is not None:
|
||||
errors.append(f"{base}: {exc}")
|
||||
continue
|
||||
lower = data[:4096].lower()
|
||||
if "html" not in ctype and b"<html" not in lower and b"<link" not in data.lower():
|
||||
if errors is not None:
|
||||
errors.append(f"{base}: response is not html ({ctype or 'unknown content-type'})")
|
||||
continue
|
||||
html = data.decode("utf-8", errors="ignore")
|
||||
for href in _extract_icon_hrefs(html):
|
||||
urls.append(urllib.parse.urljoin(final_url, href))
|
||||
return list(dict.fromkeys(urls))
|
||||
|
||||
|
||||
def _cached_favicon(domain: str):
|
||||
clean = tracker_domain(domain)
|
||||
if not clean:
|
||||
return None
|
||||
with connect() as conn:
|
||||
return conn.execute("SELECT * FROM tracker_favicon_cache WHERE domain=?", (clean,)).fetchone()
|
||||
|
||||
|
||||
def favicon_cache_row(domain: str):
|
||||
"""Note: Expose the favicon cache row for diagnostics without duplicating SQL in routes or CLI."""
|
||||
return _cached_favicon(domain)
|
||||
|
||||
|
||||
def favicon_path(domain: str, enabled: bool = True, force: bool = False) -> tuple[Path | None, str | None]:
|
||||
clean = tracker_domain(domain)
|
||||
if not enabled or not clean:
|
||||
return None, None
|
||||
cached = _cached_favicon(clean)
|
||||
now = _now_epoch()
|
||||
if cached and not force and now - float(cached.get("updated_epoch") or 0) < FAVICON_CACHE_TTL_SECONDS:
|
||||
path = Path(str(cached.get("file_path") or ""))
|
||||
mime = str(cached.get("mime_type") or mimetypes.guess_type(path.name)[0] or "image/x-icon")
|
||||
if path.exists() and path.is_file():
|
||||
try:
|
||||
if _is_icon(path.read_bytes()[:524288], mime, str(cached.get("source_url") or path.name)):
|
||||
return path, mime
|
||||
except Exception:
|
||||
pass
|
||||
if cached.get("error"):
|
||||
return None, None
|
||||
# Note: Favicon lookup checks the main-domain HTML first, then tracker HTML, then direct /favicon.ico fallbacks.
|
||||
FAVICON_DIR.mkdir(parents=True, exist_ok=True)
|
||||
errors = []
|
||||
candidates = _html_icon_candidates(clean, errors) + _favicon_candidates(clean)
|
||||
candidates = list(dict.fromkeys(candidates))
|
||||
idx = 0
|
||||
while idx < len(candidates):
|
||||
url = candidates[idx]
|
||||
idx += 1
|
||||
try:
|
||||
data, ctype, final_url = _fetch(url, limit=524288)
|
||||
if not _is_icon(data, ctype, final_url):
|
||||
errors.append(f"{url}: invalid icon ({ctype or 'unknown content-type'}, {len(data)} bytes)")
|
||||
continue
|
||||
ext = Path(urllib.parse.urlparse(final_url).path).suffix.lower() or mimetypes.guess_extension(ctype) or ".ico"
|
||||
if ext not in {".ico", ".png", ".jpg", ".jpeg", ".svg", ".webp"}:
|
||||
ext = ".ico"
|
||||
path = FAVICON_DIR / f"{_safe_filename(clean)}{ext}"
|
||||
path.write_bytes(data)
|
||||
mime = ctype if ctype.startswith("image/") else (mimetypes.guess_type(path.name)[0] or "image/x-icon")
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO tracker_favicon_cache(domain, source_url, file_path, mime_type, updated_at, updated_epoch, error)
|
||||
VALUES(?, ?, ?, ?, ?, ?, NULL)
|
||||
ON CONFLICT(domain) DO UPDATE SET
|
||||
source_url=excluded.source_url,
|
||||
file_path=excluded.file_path,
|
||||
mime_type=excluded.mime_type,
|
||||
updated_at=excluded.updated_at,
|
||||
updated_epoch=excluded.updated_epoch,
|
||||
error=NULL
|
||||
""",
|
||||
(clean, final_url, str(path), mime, utcnow(), now),
|
||||
)
|
||||
return path, mime
|
||||
except Exception as exc:
|
||||
errors.append(f"{url}: {exc}")
|
||||
# HTML is checked once before direct /favicon.ico probes; do not guess cdn/static/www hosts unless HTML points there.
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO tracker_favicon_cache(domain, source_url, file_path, mime_type, updated_at, updated_epoch, error)
|
||||
VALUES(?, '', '', '', ?, ?, ?)
|
||||
ON CONFLICT(domain) DO UPDATE SET
|
||||
updated_at=excluded.updated_at,
|
||||
updated_epoch=excluded.updated_epoch,
|
||||
error=excluded.error
|
||||
""",
|
||||
(clean, utcnow(), now, "; ".join(errors[-8:]) or "favicon not found"),
|
||||
)
|
||||
return None, None
|
||||
117
pytorrent/services/traffic_history.py
Normal file
117
pytorrent/services/traffic_history.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from ..config import TRAFFIC_HISTORY_RETENTION_DAYS
|
||||
from ..db import connect, utcnow
|
||||
from . import retention
|
||||
|
||||
_LAST_WRITE: dict[int, float] = {}
|
||||
WRITE_EVERY_SECONDS = 60
|
||||
|
||||
|
||||
def _now_ts() -> float:
|
||||
return datetime.now(timezone.utc).timestamp()
|
||||
|
||||
|
||||
def record(profile_id: int, down_rate: int = 0, up_rate: int = 0, total_down: int = 0, total_up: int = 0, force: bool = False) -> None:
|
||||
"""Store compact transfer samples. One sample per minute per profile keeps SQLite small."""
|
||||
profile_id = int(profile_id)
|
||||
now_ts = _now_ts()
|
||||
if not force and now_ts - _LAST_WRITE.get(profile_id, 0.0) < WRITE_EVERY_SECONDS:
|
||||
return
|
||||
_LAST_WRITE[profile_id] = now_ts
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO traffic_history(profile_id,down_rate,up_rate,total_down,total_up,created_at) VALUES(?,?,?,?,?,?)",
|
||||
(profile_id, int(down_rate or 0), int(up_rate or 0), int(total_down or 0), int(total_up or 0), utcnow()),
|
||||
)
|
||||
retention.cleanup()
|
||||
|
||||
|
||||
def _range_to_cutoff(range_name: str) -> datetime:
|
||||
now = datetime.now(timezone.utc)
|
||||
if range_name == "15m":
|
||||
return now - timedelta(minutes=15)
|
||||
if range_name == "1h":
|
||||
return now - timedelta(hours=1)
|
||||
if range_name == "3h":
|
||||
return now - timedelta(hours=3)
|
||||
if range_name == "6h":
|
||||
return now - timedelta(hours=6)
|
||||
if range_name == "24h":
|
||||
return now - timedelta(hours=24)
|
||||
if range_name == "30d":
|
||||
return now - timedelta(days=30)
|
||||
if range_name == "90d":
|
||||
return now - timedelta(days=90)
|
||||
return now - timedelta(days=7)
|
||||
|
||||
|
||||
def _bucket_for(range_name: str) -> str:
|
||||
if range_name in {"15m", "1h", "3h"}:
|
||||
return "%Y-%m-%d %H:%M"
|
||||
if range_name in {"6h", "24h"}:
|
||||
return "%Y-%m-%d %H:00"
|
||||
return "%Y-%m-%d"
|
||||
|
||||
|
||||
def _row_value(row: Any, key: str, index: int, default: Any = 0) -> Any:
|
||||
# connect() uses dict_factory, so SQLite rows are dicts. The fallback keeps
|
||||
# this function compatible with tuple/list rows in tests or future refactors.
|
||||
if isinstance(row, dict):
|
||||
return row.get(key, default)
|
||||
try:
|
||||
return row[index]
|
||||
except (IndexError, KeyError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def history(profile_id: int, range_name: str = "7d") -> dict[str, Any]:
|
||||
cutoff = _range_to_cutoff(range_name)
|
||||
bucket = _bucket_for(range_name)
|
||||
cutoff_s = cutoff.isoformat(timespec="seconds")
|
||||
bucket_name = "minute" if range_name in {"15m", "1h", "3h"} else ("hour" if range_name in {"6h", "24h"} else "day")
|
||||
with connect() as conn:
|
||||
raw = conn.execute(
|
||||
"""
|
||||
SELECT down_rate, up_rate, total_down, total_up, created_at
|
||||
FROM traffic_history
|
||||
WHERE profile_id=? AND created_at >= ?
|
||||
ORDER BY created_at ASC
|
||||
""",
|
||||
(int(profile_id), cutoff_s),
|
||||
).fetchall()
|
||||
|
||||
rows_by_bucket: dict[str, dict[str, Any]] = {}
|
||||
prev_down = prev_up = None
|
||||
for r in raw:
|
||||
created = str(_row_value(r, "created_at", 4, ""))
|
||||
try:
|
||||
dt = datetime.fromisoformat(created.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
continue
|
||||
b = dt.strftime(bucket)
|
||||
item = rows_by_bucket.setdefault(b, {"bucket": b, "avg_down_rate": 0, "avg_up_rate": 0, "downloaded": 0, "uploaded": 0, "samples": 0})
|
||||
down_rate = int(_row_value(r, "down_rate", 0, 0) or 0)
|
||||
up_rate = int(_row_value(r, "up_rate", 1, 0) or 0)
|
||||
total_down = int(_row_value(r, "total_down", 2, 0) or 0)
|
||||
total_up = int(_row_value(r, "total_up", 3, 0) or 0)
|
||||
item["avg_down_rate"] += down_rate
|
||||
item["avg_up_rate"] += up_rate
|
||||
item["samples"] += 1
|
||||
if prev_down is not None and total_down >= prev_down:
|
||||
item["downloaded"] += total_down - prev_down
|
||||
if prev_up is not None and total_up >= prev_up:
|
||||
item["uploaded"] += total_up - prev_up
|
||||
prev_down, prev_up = total_down, total_up
|
||||
|
||||
rows = []
|
||||
for item in rows_by_bucket.values():
|
||||
samples = max(1, int(item["samples"] or 1))
|
||||
item["avg_down_rate"] = round(item["avg_down_rate"] / samples)
|
||||
item["avg_up_rate"] = round(item["avg_up_rate"] / samples)
|
||||
rows.append(item)
|
||||
rows.sort(key=lambda x: x["bucket"])
|
||||
return {"range": range_name, "bucket": bucket_name, "retention_days": TRAFFIC_HISTORY_RETENTION_DAYS, "rows": rows}
|
||||
256
pytorrent/services/websocket.py
Normal file
256
pytorrent/services/websocket.py
Normal file
@@ -0,0 +1,256 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
import json
|
||||
import psutil
|
||||
from flask_socketio import emit, join_room, leave_room, disconnect
|
||||
from .preferences import active_profile, get_profile
|
||||
from .torrent_cache import torrent_cache
|
||||
from .torrent_summary import cached_summary
|
||||
from . import rtorrent, smart_queue, traffic_history, automation_rules, torrent_stats, auth, speed_peaks, poller_control, download_planner
|
||||
|
||||
|
||||
def _profile_room(profile_id: int) -> str:
|
||||
return f"profile:{int(profile_id)}"
|
||||
|
||||
|
||||
def _poller_profiles() -> list[dict]:
|
||||
# Background polling has no browser session, so auth-enabled mode refreshes all profiles and emits only to per-profile rooms.
|
||||
if not auth.enabled():
|
||||
profile = active_profile()
|
||||
return [profile] if profile else []
|
||||
from ..db import connect
|
||||
with connect() as conn:
|
||||
return conn.execute("SELECT * FROM rtorrent_profiles ORDER BY id").fetchall()
|
||||
|
||||
|
||||
def emit_profile_event(socketio, event: str, payload: dict, profile_id: int) -> None:
|
||||
target = _profile_room(profile_id) if auth.enabled() else None
|
||||
socketio.emit(event, payload, to=target) if target else socketio.emit(event, payload)
|
||||
|
||||
|
||||
def _emit_profile(socketio, event: str, payload: dict, profile_id: int) -> None:
|
||||
emit_profile_event(socketio, event, payload, profile_id)
|
||||
|
||||
|
||||
|
||||
|
||||
def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None:
|
||||
state = poller_control.state_for(profile_id)
|
||||
try:
|
||||
try:
|
||||
torrent_stats.queue_refresh(socketio, profile, force=False, room=_profile_room(profile_id) if auth.enabled() else None)
|
||||
except Exception as exc:
|
||||
_emit_profile(socketio, "torrent_stats_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
|
||||
try:
|
||||
result = smart_queue.check(profile, force=False)
|
||||
if result.get("enabled"):
|
||||
_emit_profile(socketio, "smart_queue_update", result, profile_id)
|
||||
if result.get("stopped") or result.get("started") or result.get("start_requested") or result.get("paused") or result.get("resumed"):
|
||||
queue_diff = torrent_cache.refresh(profile)
|
||||
if queue_diff.get("ok"):
|
||||
payload = {**queue_diff, "summary": cached_summary(profile_id, torrent_cache.snapshot(profile_id), force=True)}
|
||||
_emit_profile(socketio, "torrent_patch", payload, profile_id)
|
||||
except Exception as exc:
|
||||
_emit_profile(socketio, "smart_queue_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
|
||||
try:
|
||||
auto_result = automation_rules.check(profile, force=False)
|
||||
if auto_result.get("applied"):
|
||||
_emit_profile(socketio, "automation_update", auto_result, profile_id)
|
||||
except Exception as exc:
|
||||
_emit_profile(socketio, "automation_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
|
||||
try:
|
||||
plan_result = download_planner.enforce(profile, force=False)
|
||||
if plan_result.get("enabled") and not plan_result.get("skipped"):
|
||||
_emit_profile(socketio, "download_plan_update", plan_result, profile_id)
|
||||
except Exception as exc:
|
||||
_emit_profile(socketio, "download_plan_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
|
||||
finally:
|
||||
state.slow_task_running = False
|
||||
|
||||
|
||||
def _is_active_rows(rows: list[dict]) -> bool:
|
||||
for row in rows or []:
|
||||
try:
|
||||
if int(row.get("state") or 0) and (int(row.get("down_rate") or 0) > 0 or int(row.get("up_rate") or 0) > 0):
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def _speed_status_from_rows(profile_id: int, rows: list[dict]) -> dict:
|
||||
# Note: Fast-poller speed status keeps browser-title speed and peaks independent from slower system_stats.
|
||||
down_rate = sum(int(row.get("down_rate") or 0) for row in rows or [])
|
||||
up_rate = sum(int(row.get("up_rate") or 0) for row in rows or [])
|
||||
return {
|
||||
"profile_id": int(profile_id),
|
||||
"down_rate": down_rate,
|
||||
"up_rate": up_rate,
|
||||
"down_rate_h": rtorrent.human_rate(down_rate),
|
||||
"up_rate_h": rtorrent.human_rate(up_rate),
|
||||
"speed_peaks": speed_peaks.record(profile_id, down_rate, up_rate),
|
||||
}
|
||||
|
||||
|
||||
_started = False
|
||||
_start_lock = threading.Lock()
|
||||
|
||||
|
||||
def register_socketio_handlers(socketio):
|
||||
|
||||
def poller():
|
||||
while True:
|
||||
loop_started = time.monotonic()
|
||||
next_sleep = poller_control.MIN_POLL_INTERVAL_SECONDS
|
||||
for profile in _poller_profiles():
|
||||
if not profile:
|
||||
continue
|
||||
pid = int(profile["id"])
|
||||
settings = poller_control.get_settings(pid)
|
||||
state = poller_control.state_for(pid)
|
||||
now = time.monotonic()
|
||||
next_sleep = min(next_sleep, poller_control.effective_fast_interval(settings, state))
|
||||
if not poller_control.should_fast_poll(now, settings, state):
|
||||
continue
|
||||
|
||||
tick_started = time.monotonic()
|
||||
changed = False
|
||||
ok = True
|
||||
error = ""
|
||||
active = False
|
||||
emitted_payload_size = 0
|
||||
rtorrent_call_count = 0
|
||||
skipped_emissions = 0
|
||||
heartbeat = {"ok": True, "profile_id": pid, "tick": state.tick_count + 1, "error": ""}
|
||||
|
||||
try:
|
||||
diff = torrent_cache.refresh(profile)
|
||||
rtorrent_call_count += 1
|
||||
state.last_fast_at = now
|
||||
ok = bool(diff.get("ok"))
|
||||
error = str(diff.get("error") or "")
|
||||
rows = torrent_cache.snapshot(pid)
|
||||
active = _is_active_rows(rows)
|
||||
speed_status = _speed_status_from_rows(pid, rows) if diff.get("ok") else None
|
||||
if diff.get("ok") and (diff["added"] or diff["updated"] or diff["removed"]):
|
||||
changed = True
|
||||
payload = {**diff, "summary": cached_summary(pid, rows, force=True), "speed_status": speed_status}
|
||||
emitted_payload_size += len(json.dumps(payload, default=str))
|
||||
_emit_profile(socketio, "torrent_patch", payload, pid)
|
||||
elif not diff.get("ok"):
|
||||
_emit_profile(socketio, "rtorrent_error", diff, pid)
|
||||
else:
|
||||
# Note: Speeds and peak records may change even when no torrent rows need repainting.
|
||||
if speed_status:
|
||||
payload = {"ok": True, "profile_id": pid, "added": [], "updated": [], "removed": [], "speed_status": speed_status}
|
||||
emitted_payload_size += len(json.dumps(payload, default=str))
|
||||
_emit_profile(socketio, "torrent_patch", payload, pid)
|
||||
else:
|
||||
skipped_emissions += 1
|
||||
|
||||
if poller_control.should_system_poll(now, settings, state):
|
||||
state.last_system_at = now
|
||||
status = rtorrent.system_status(profile, rows)
|
||||
rtorrent_call_count += 1
|
||||
if bool(profile.get("is_remote")):
|
||||
try:
|
||||
# Note: Remote profiles must report CPU/RAM from the rTorrent host, not hide the footer stats.
|
||||
usage = rtorrent.remote_system_usage(profile)
|
||||
status.update(usage)
|
||||
status["usage_available"] = True
|
||||
except Exception as exc:
|
||||
status["usage_source"] = "rtorrent-remote"
|
||||
status["usage_available"] = False
|
||||
status["usage_error"] = str(exc)
|
||||
else:
|
||||
status["cpu"] = psutil.cpu_percent(interval=None)
|
||||
status["ram"] = psutil.virtual_memory().percent
|
||||
status["usage_source"] = "local"
|
||||
status["usage_available"] = True
|
||||
status["profile_id"] = pid
|
||||
traffic_history.record(pid, status.get("down_rate", 0), status.get("up_rate", 0), status.get("total_down", 0), status.get("total_up", 0))
|
||||
status["speed_peaks"] = (speed_status or _speed_status_from_rows(pid, rows))["speed_peaks"]
|
||||
status["poller"] = poller_control.snapshot(pid)
|
||||
emitted_payload_size += len(json.dumps(status, default=str))
|
||||
_emit_profile(socketio, "system_stats", status, pid)
|
||||
|
||||
if poller_control.should_disk_poll(now, settings, state):
|
||||
state.last_disk_at = now
|
||||
|
||||
if poller_control.should_tracker_poll(now, settings, state):
|
||||
state.last_tracker_at = now
|
||||
|
||||
if poller_control.should_slow_poll(now, settings, state) or poller_control.should_queue_poll(now, settings, state):
|
||||
state.last_slow_at = now
|
||||
state.last_queue_at = now
|
||||
if state.slow_task_running:
|
||||
skipped_emissions += 1
|
||||
else:
|
||||
state.slow_task_running = True
|
||||
socketio.start_background_task(_run_slow_profile_tasks, socketio, dict(profile), pid)
|
||||
except Exception as exc:
|
||||
ok = False
|
||||
error = str(exc)
|
||||
_emit_profile(socketio, "rtorrent_error", {"profile_id": pid, "error": error}, pid)
|
||||
|
||||
runtime = poller_control.mark_tick(state, tick_started, active=active, ok=ok, error=error, emitted_payload_size=emitted_payload_size, rtorrent_call_count=rtorrent_call_count, skipped_emissions=skipped_emissions, settings=settings)
|
||||
heartbeat.update({"ok": ok, "error": error, "active": active, "poller": runtime})
|
||||
if poller_control.should_heartbeat(time.monotonic(), settings, state, changed):
|
||||
state.last_heartbeat_at = time.monotonic()
|
||||
_emit_profile(socketio, "heartbeat", heartbeat, pid)
|
||||
|
||||
elapsed = time.monotonic() - loop_started
|
||||
socketio.sleep(max(poller_control.MIN_POLL_INTERVAL_SECONDS, min(10.0, next_sleep - elapsed)))
|
||||
|
||||
def ensure_poller_started():
|
||||
global _started
|
||||
with _start_lock:
|
||||
if not _started:
|
||||
# The poller starts with the app, so Smart Queue, planner and automations work without an open UI.
|
||||
socketio.start_background_task(poller)
|
||||
_started = True
|
||||
|
||||
ensure_poller_started()
|
||||
|
||||
@socketio.on("connect")
|
||||
def handle_connect():
|
||||
ensure_poller_started()
|
||||
if auth.enabled() and not auth.current_user_id():
|
||||
disconnect()
|
||||
return False
|
||||
profile = active_profile()
|
||||
if profile:
|
||||
join_room(_profile_room(profile["id"]))
|
||||
emit("connected", {"ok": True, "profile": profile})
|
||||
if not profile:
|
||||
emit("profile_required", {"ok": True, "profiles": []})
|
||||
return
|
||||
rows = torrent_cache.snapshot(profile["id"])
|
||||
emit("torrent_snapshot", {"profile_id": profile["id"], "torrents": rows, "summary": cached_summary(profile["id"], rows), "speed_status": _speed_status_from_rows(profile["id"], rows)})
|
||||
emit("poller_settings", {"settings": poller_control.get_settings(int(profile["id"])), "runtime": poller_control.snapshot(int(profile["id"]))})
|
||||
emit("download_plan_update", {"settings": download_planner.get_settings(int(profile["id"]))})
|
||||
|
||||
@socketio.on("select_profile")
|
||||
def handle_select_profile(data):
|
||||
if auth.enabled() and not auth.current_user_id():
|
||||
disconnect()
|
||||
return
|
||||
old_profile = active_profile()
|
||||
if old_profile:
|
||||
leave_room(_profile_room(old_profile["id"]))
|
||||
profile_id = int((data or {}).get("profile_id") or 0)
|
||||
if not profile_id:
|
||||
emit("profile_required", {"ok": True, "profiles": []})
|
||||
return
|
||||
profile = get_profile(profile_id)
|
||||
if not profile:
|
||||
emit("rtorrent_error", {"error": "Profile access denied or profile does not exist"})
|
||||
return
|
||||
join_room(_profile_room(profile_id))
|
||||
diff = torrent_cache.refresh(profile)
|
||||
rows = torrent_cache.snapshot(profile_id)
|
||||
emit("torrent_snapshot", {"profile_id": profile_id, "torrents": rows, "summary": cached_summary(profile_id, rows, force=True), "speed_status": _speed_status_from_rows(profile_id, rows), "error": diff.get("error", "")})
|
||||
emit("poller_settings", {"settings": poller_control.get_settings(profile_id), "runtime": poller_control.snapshot(profile_id)})
|
||||
emit("download_plan_update", {"settings": download_planner.get_settings(profile_id)})
|
||||
569
pytorrent/services/workers.py
Normal file
569
pytorrent/services/workers.py
Normal file
@@ -0,0 +1,569 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from . import rtorrent, auth, disk_guard
|
||||
from .preferences import get_profile
|
||||
from ..config import WORKERS
|
||||
from ..db import connect, utcnow, default_user_id
|
||||
|
||||
LIGHT_ACTIONS = {"start", "stop", "pause", "resume", "unpause", "set_label", "set_ratio_group", "reannounce", "set_limits"}
|
||||
WATCHDOG_INTERVAL_SECONDS = 30
|
||||
|
||||
_heavy_executor = ThreadPoolExecutor(max_workers=WORKERS, thread_name_prefix="pytorrent-heavy-job")
|
||||
_light_executor = ThreadPoolExecutor(max_workers=max(4, min(WORKERS, 16)), thread_name_prefix="pytorrent-light-job")
|
||||
_socketio = None
|
||||
_heavy_semaphores: dict[int, tuple[int, threading.Semaphore]] = {}
|
||||
_light_semaphores: dict[int, tuple[int, threading.Semaphore]] = {}
|
||||
_exclusive_locks: dict[int, threading.Lock] = {}
|
||||
_active_runners: set[str] = set()
|
||||
_sem_lock = threading.Lock()
|
||||
_runner_lock = threading.Lock()
|
||||
_watchdog_started = False
|
||||
_watchdog_lock = threading.Lock()
|
||||
|
||||
|
||||
def set_socketio(socketio):
|
||||
global _socketio
|
||||
_socketio = socketio
|
||||
|
||||
|
||||
def _emit(name: str, payload: dict):
|
||||
if not _socketio:
|
||||
return
|
||||
profile_id = payload.get("profile_id")
|
||||
if auth.enabled() and profile_id:
|
||||
# Note: Job/socket events are sent only to clients joined to the affected profile room.
|
||||
_socketio.emit(name, payload, to=f"profile:{int(profile_id)}")
|
||||
else:
|
||||
_socketio.emit(name, payload)
|
||||
|
||||
|
||||
def _bounded_int(value, default: int, minimum: int = 1) -> int:
|
||||
try:
|
||||
parsed = int(value if value is not None else default)
|
||||
except (TypeError, ValueError):
|
||||
parsed = default
|
||||
return max(minimum, parsed)
|
||||
|
||||
|
||||
def _is_light_action(action_name: str) -> bool:
|
||||
return str(action_name or "") in LIGHT_ACTIONS
|
||||
|
||||
|
||||
def _profile_heavy_limit(profile: dict) -> int:
|
||||
return _bounded_int(profile.get("max_parallel_jobs"), 5)
|
||||
|
||||
|
||||
def _profile_light_limit(profile: dict) -> int:
|
||||
return _bounded_int(profile.get("light_parallel_jobs"), 4)
|
||||
|
||||
|
||||
def _get_sem(profile: dict, light: bool = False) -> threading.Semaphore:
|
||||
profile_id = int(profile["id"])
|
||||
limit = _profile_light_limit(profile) if light else _profile_heavy_limit(profile)
|
||||
registry = _light_semaphores if light else _heavy_semaphores
|
||||
with _sem_lock:
|
||||
current = registry.get(profile_id)
|
||||
if not current or current[0] != limit:
|
||||
registry[profile_id] = (limit, threading.Semaphore(limit))
|
||||
return registry[profile_id][1]
|
||||
|
||||
|
||||
def _get_exclusive_lock(profile_id: int) -> threading.Lock:
|
||||
with _sem_lock:
|
||||
if profile_id not in _exclusive_locks:
|
||||
_exclusive_locks[profile_id] = threading.Lock()
|
||||
return _exclusive_locks[profile_id]
|
||||
|
||||
|
||||
def _job_row(job_id: str):
|
||||
with connect() as conn:
|
||||
return conn.execute("SELECT rowid AS _rowid, * FROM jobs WHERE id=?", (job_id,)).fetchone()
|
||||
|
||||
|
||||
def _job_payload(row) -> dict:
|
||||
try:
|
||||
return json.loads((row or {}).get("payload_json") or "{}")
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _is_ordered_job(row) -> bool:
|
||||
payload = _job_payload(row)
|
||||
action = str((row or {}).get("action") or "")
|
||||
# Note: Only long/destructive tasks are ordered; lightweight start/stop/label jobs may run beside other work.
|
||||
return action in {"move", "remove", "add_magnet", "add_torrent_raw"} or bool(payload.get("requires_order"))
|
||||
|
||||
|
||||
def _is_priority_job(row) -> bool:
|
||||
payload = _job_payload(row)
|
||||
return bool(payload.get('priority_job') or payload.get('force_job')) or str((row or {}).get('action') or '') == 'set_limits'
|
||||
|
||||
|
||||
def _is_light_job(row) -> bool:
|
||||
return _is_light_action(str((row or {}).get("action") or ""))
|
||||
|
||||
|
||||
def _has_prior_ordered_jobs(profile_id: int, rowid: int) -> bool:
|
||||
with connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT rowid AS _rowid, action, payload_json
|
||||
FROM jobs
|
||||
WHERE profile_id=?
|
||||
AND rowid<?
|
||||
AND status IN ('pending', 'running')
|
||||
ORDER BY rowid
|
||||
""",
|
||||
(profile_id, rowid),
|
||||
).fetchall()
|
||||
return any(_is_ordered_job(row) and not _is_priority_job(row) for row in rows)
|
||||
|
||||
|
||||
def _wait_for_prior_ordered_jobs(job_id: str, profile_id: int, rowid: int) -> bool:
|
||||
while _has_prior_ordered_jobs(profile_id, rowid):
|
||||
fresh = _job_row(job_id)
|
||||
if not fresh or fresh["status"] == "cancelled":
|
||||
return False
|
||||
if _is_priority_job(fresh):
|
||||
return True
|
||||
time.sleep(0.5)
|
||||
return True
|
||||
|
||||
|
||||
def _set_job(job_id: str, status: str, error: str = "", result: dict | None = None, started: bool = False, finished: bool = False):
|
||||
now = utcnow()
|
||||
fields = ["status=?", "error=?", "updated_at=?"]
|
||||
values: list = [status, error, now]
|
||||
if result is not None:
|
||||
fields.append("result_json=?")
|
||||
values.append(json.dumps(result))
|
||||
if started:
|
||||
fields.append("started_at=?")
|
||||
values.append(now)
|
||||
if finished:
|
||||
fields.append("finished_at=?")
|
||||
values.append(now)
|
||||
values.append(job_id)
|
||||
with connect() as conn:
|
||||
conn.execute(f"UPDATE jobs SET {', '.join(fields)} WHERE id=?", values)
|
||||
|
||||
|
||||
def _job_state(row) -> dict:
|
||||
try:
|
||||
return json.loads((row or {}).get("state_json") or "{}")
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _checkpoint_job(job_id: str, state: dict, progress_current: int | None = None, progress_total: int | None = None) -> None:
|
||||
now = utcnow()
|
||||
fields = ["state_json=?", "heartbeat_at=?", "updated_at=?"]
|
||||
values: list = [json.dumps(state), now, now]
|
||||
if progress_current is not None:
|
||||
fields.append("progress_current=?")
|
||||
values.append(int(progress_current))
|
||||
if progress_total is not None:
|
||||
fields.append("progress_total=?")
|
||||
values.append(int(progress_total))
|
||||
values.append(job_id)
|
||||
with connect() as conn:
|
||||
conn.execute(f"UPDATE jobs SET {', '.join(fields)} WHERE id=? AND status='running'", values)
|
||||
|
||||
|
||||
def _submit_job(job_id: str, action_name: str | None = None):
|
||||
if action_name is None:
|
||||
row = _job_row(job_id)
|
||||
action_name = str((row or {}).get("action") or "")
|
||||
executor = _light_executor if _is_light_action(str(action_name or "")) else _heavy_executor
|
||||
executor.submit(_run, job_id)
|
||||
|
||||
|
||||
def enqueue(action_name: str, profile_id: int, payload: dict, user_id: int | None = None, max_attempts: int = 2, force: bool = False) -> str:
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
job_id = uuid.uuid4().hex
|
||||
if force:
|
||||
payload = dict(payload or {})
|
||||
# Note: Forced pending jobs bypass ordered waits and run in a separate worker slot after explicit user confirmation.
|
||||
payload['force_job'] = True
|
||||
payload['priority_job'] = True
|
||||
now = utcnow()
|
||||
progress_total = len((payload or {}).get("hashes") or [])
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO jobs(id,user_id,profile_id,action,payload_json,status,attempts,max_attempts,progress_total,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(job_id, user_id, profile_id, action_name, json.dumps(payload), "pending", 0, max_attempts, progress_total, now, now),
|
||||
)
|
||||
_emit("job_update", {"id": job_id, "action": action_name, "profile_id": profile_id, "status": "pending"})
|
||||
_submit_job(job_id, action_name)
|
||||
return job_id
|
||||
|
||||
|
||||
def _job_event_meta(payload: dict) -> dict:
|
||||
ctx = payload.get("job_context") or {}
|
||||
source = str(ctx.get("source") or payload.get("source") or "user")
|
||||
meta = {"source": source}
|
||||
if source == "automation":
|
||||
# Note: Socket operation toasts use this flag so automation notifications respect user preferences.
|
||||
meta["automation"] = True
|
||||
meta["source_label"] = str(ctx.get("rule_name") or "automation")
|
||||
if ctx.get("rule_id") is not None:
|
||||
meta["rule_id"] = ctx.get("rule_id")
|
||||
return meta
|
||||
|
||||
|
||||
def _execute(profile: dict, action_name: str, payload: dict):
|
||||
if action_name == "smart_queue_check":
|
||||
from . import smart_queue
|
||||
return smart_queue.check(profile, user_id=auth.current_user_id() or default_user_id(), force=True)
|
||||
if action_name == "add_magnet":
|
||||
if bool(payload.get("start", True)):
|
||||
disk_guard.assert_can_start_download(profile)
|
||||
return rtorrent.add_magnet(profile, payload["uri"], bool(payload.get("start", True)), str(payload.get("directory") or ""), str(payload.get("label") or ""))
|
||||
if action_name == "add_torrent_raw":
|
||||
import base64
|
||||
raw = base64.b64decode(payload["data_b64"])
|
||||
if bool(payload.get("start", True)):
|
||||
disk_guard.assert_can_start_download(profile)
|
||||
return rtorrent.add_torrent_raw(profile, raw, bool(payload.get("start", True)), str(payload.get("directory") or ""), str(payload.get("label") or ""), payload.get("file_priorities") or None)
|
||||
if action_name == "set_limits":
|
||||
return rtorrent.set_limits(profile, payload.get("down"), payload.get("up"))
|
||||
hashes = payload.get("hashes") or []
|
||||
if action_name in {"start", "resume", "unpause"}:
|
||||
disk_guard.assert_can_start_download(profile)
|
||||
state = payload.get("__resume_state") or {}
|
||||
|
||||
def checkpoint(next_state: dict, current: int, total: int):
|
||||
job_id = payload.get("__job_id")
|
||||
if job_id:
|
||||
_checkpoint_job(str(job_id), next_state, current, total)
|
||||
|
||||
return rtorrent.action(profile, hashes, action_name, payload, checkpoint=checkpoint, resume_state=state)
|
||||
|
||||
|
||||
def _claim_runner(job_id: str) -> bool:
|
||||
with _runner_lock:
|
||||
if job_id in _active_runners:
|
||||
return False
|
||||
_active_runners.add(job_id)
|
||||
return True
|
||||
|
||||
|
||||
def _release_runner(job_id: str) -> None:
|
||||
with _runner_lock:
|
||||
_active_runners.discard(job_id)
|
||||
|
||||
|
||||
def _mark_running(job_id: str, attempts: int) -> bool:
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
cur = conn.execute(
|
||||
"UPDATE jobs SET status='running', attempts=?, started_at=COALESCE(started_at, ?), updated_at=? WHERE id=? AND status='pending'",
|
||||
(attempts, now, now, job_id),
|
||||
)
|
||||
return int(cur.rowcount or 0) == 1
|
||||
|
||||
|
||||
def _run(job_id: str):
|
||||
if not _claim_runner(job_id):
|
||||
return
|
||||
sem = None
|
||||
ordered_lock = None
|
||||
try:
|
||||
job = _job_row(job_id)
|
||||
if not job or job["status"] == "cancelled":
|
||||
return
|
||||
profile = get_profile(int(job["profile_id"]), int(job["user_id"]))
|
||||
if not profile:
|
||||
_set_job(job_id, "failed", "rTorrent profile does not exist", finished=True)
|
||||
_emit("job_update", {"id": job_id, "profile_id": job.get("profile_id"), "status": "failed", "error": "profile not found"})
|
||||
return
|
||||
profile_id = int(profile["id"])
|
||||
if _is_ordered_job(job) and not _is_priority_job(job):
|
||||
if not _wait_for_prior_ordered_jobs(job_id, profile_id, int(job["_rowid"])):
|
||||
return
|
||||
ordered_lock = _get_exclusive_lock(profile_id)
|
||||
ordered_lock.acquire()
|
||||
sem = _get_sem(profile, light=_is_light_job(job))
|
||||
sem.acquire()
|
||||
job = _job_row(job_id)
|
||||
if not job or job["status"] == "cancelled":
|
||||
return
|
||||
payload = json.loads(job.get("payload_json") or "{}")
|
||||
payload["__job_id"] = job_id
|
||||
payload["__resume_state"] = _job_state(job)
|
||||
attempts = int(job.get("attempts") or 0) + 1
|
||||
if not _mark_running(job_id, attempts):
|
||||
return
|
||||
event_meta = _job_event_meta(payload)
|
||||
_emit("operation_started", {"job_id": job_id, "action": job["action"], "profile_id": profile["id"], "hashes": payload.get("hashes") or [], "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1, **event_meta})
|
||||
_emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "running", "attempts": attempts})
|
||||
result = _execute(profile, job["action"], payload)
|
||||
fresh = _job_row(job_id)
|
||||
# Note: Emergency cancel and watchdog timeout keep late work from overwriting a terminal state.
|
||||
if fresh and fresh["status"] != "running":
|
||||
return
|
||||
_set_job(job_id, "done", result=result, finished=True)
|
||||
_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("job_update", {"id": job_id, "profile_id": profile["id"], "status": "done", "result": result})
|
||||
except Exception as exc:
|
||||
fresh = _job_row(job_id) or {}
|
||||
attempts = int(fresh.get("attempts") or 1)
|
||||
max_attempts = int(fresh.get("max_attempts") or 2)
|
||||
# Note: Emergency cancel keeps an exception from a cancelled job from moving it back to retry or failed.
|
||||
if fresh and fresh.get("status") != "running":
|
||||
return
|
||||
status = "pending" if attempts < max_attempts else "failed"
|
||||
_set_job(job_id, status, str(exc), finished=(status == "failed"))
|
||||
_emit("operation_failed", {"job_id": job_id, "action": job.get("action"), "profile_id": job.get("profile_id"), "hashes": payload.get("hashes") or [], "error": str(exc), **_job_event_meta(payload)})
|
||||
_emit("job_update", {"id": job_id, "profile_id": job.get("profile_id"), "status": status, "error": str(exc), "attempts": attempts})
|
||||
if status == "pending":
|
||||
_submit_job(job_id, job.get("action"))
|
||||
finally:
|
||||
if sem:
|
||||
sem.release()
|
||||
if ordered_lock:
|
||||
ordered_lock.release()
|
||||
_release_runner(job_id)
|
||||
|
||||
|
||||
|
||||
def _parse_ts(value: str | None) -> float | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
from datetime import datetime
|
||||
return datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _job_timeout_seconds(profile: dict, row) -> int:
|
||||
key = "light_job_timeout_seconds" if _is_light_job(row) else "heavy_job_timeout_seconds"
|
||||
default = 300 if _is_light_job(row) else 7200
|
||||
return _bounded_int(profile.get(key), default, 30)
|
||||
|
||||
|
||||
def _pending_timeout_seconds(profile: dict) -> int:
|
||||
return _bounded_int(profile.get("pending_job_timeout_seconds"), 900, 60)
|
||||
|
||||
|
||||
def _timeout_running_jobs() -> None:
|
||||
now_ts = time.time()
|
||||
with connect() as conn:
|
||||
rows = conn.execute("SELECT id,user_id,profile_id,action,started_at FROM jobs WHERE status='running'").fetchall()
|
||||
for row in rows:
|
||||
profile = get_profile(int(row["profile_id"]), int(row["user_id"]))
|
||||
if not profile:
|
||||
continue
|
||||
started_ts = _parse_ts(row.get("started_at"))
|
||||
if started_ts is None or now_ts - started_ts < _job_timeout_seconds(profile, row):
|
||||
continue
|
||||
message = f"Watchdog timeout after {_job_timeout_seconds(profile, row)} seconds"
|
||||
_set_job(row["id"], "failed", message, finished=True)
|
||||
_emit("operation_failed", {"job_id": row["id"], "action": row.get("action"), "profile_id": row.get("profile_id"), "hashes": [], "error": message, "source": "watchdog"})
|
||||
_emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "failed", "error": message})
|
||||
|
||||
|
||||
def _resubmit_interrupted_running_jobs() -> None:
|
||||
now_ts = time.time()
|
||||
with connect() as conn:
|
||||
rows = conn.execute("SELECT id,user_id,profile_id,action,heartbeat_at,updated_at FROM jobs WHERE status='running'").fetchall()
|
||||
for row in rows:
|
||||
with _runner_lock:
|
||||
active = row["id"] in _active_runners
|
||||
if active:
|
||||
continue
|
||||
profile = get_profile(int(row["profile_id"]), int(row["user_id"]))
|
||||
if not profile:
|
||||
continue
|
||||
last_seen_ts = _parse_ts(row.get("heartbeat_at") or row.get("updated_at"))
|
||||
# Note: After process restart there is no in-memory runner for this job.
|
||||
# A short grace avoids stealing work from another still-alive Gunicorn worker.
|
||||
if last_seen_ts is not None and now_ts - last_seen_ts < 90:
|
||||
continue
|
||||
with connect() as conn:
|
||||
cur = conn.execute(
|
||||
"UPDATE jobs SET status='pending', error=?, updated_at=? WHERE id=? AND status='running'",
|
||||
("Resuming interrupted job from last checkpoint", utcnow(), row["id"]),
|
||||
)
|
||||
if int(cur.rowcount or 0):
|
||||
_emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "pending", "resumed": True})
|
||||
_submit_job(row["id"], row.get("action"))
|
||||
|
||||
|
||||
def _resubmit_stale_pending_jobs() -> None:
|
||||
now_ts = time.time()
|
||||
with connect() as conn:
|
||||
rows = conn.execute("SELECT id,user_id,profile_id,action,updated_at FROM jobs WHERE status='pending'").fetchall()
|
||||
for row in rows:
|
||||
with _runner_lock:
|
||||
active = row["id"] in _active_runners
|
||||
if active:
|
||||
continue
|
||||
profile = get_profile(int(row["profile_id"]), int(row["user_id"]))
|
||||
if not profile:
|
||||
continue
|
||||
updated_ts = _parse_ts(row.get("updated_at"))
|
||||
if updated_ts is None or now_ts - updated_ts < _pending_timeout_seconds(profile):
|
||||
continue
|
||||
with connect() as conn:
|
||||
conn.execute("UPDATE jobs SET error=?, updated_at=? WHERE id=? AND status='pending'", ("Watchdog resubmitted stale pending job", utcnow(), row["id"]))
|
||||
_emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "pending", "watchdog": True})
|
||||
_submit_job(row["id"], row.get("action"))
|
||||
|
||||
|
||||
def _watchdog_loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
_resubmit_interrupted_running_jobs()
|
||||
_timeout_running_jobs()
|
||||
_resubmit_stale_pending_jobs()
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(WATCHDOG_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
def start_watchdog() -> None:
|
||||
global _watchdog_started
|
||||
with _watchdog_lock:
|
||||
if _watchdog_started:
|
||||
return
|
||||
_watchdog_started = True
|
||||
thread = threading.Thread(target=_watchdog_loop, name="pytorrent-job-watchdog", daemon=True)
|
||||
thread.start()
|
||||
|
||||
|
||||
def _safe_json(value, fallback):
|
||||
try:
|
||||
return json.loads(value or "")
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
|
||||
def _job_summary(row: dict, payload: dict, result: dict) -> str:
|
||||
ctx = payload.get("job_context") or {}
|
||||
count = int(ctx.get("hash_count") or len(payload.get("hashes") or []) or result.get("count") or 0)
|
||||
parts = []
|
||||
if ctx.get("bulk_label"):
|
||||
# Note: Shows which generated bulk part is being displayed in the job queue.
|
||||
parts.append(f"{ctx.get('bulk_label')} of {ctx.get('bulk_parts')}")
|
||||
if count:
|
||||
parts.append(("bulk " if count > 1 else "single ") + f"{count} torrent(s)")
|
||||
if ctx.get("target_path"):
|
||||
parts.append(f"target: {ctx.get('target_path')}")
|
||||
if ctx.get("remove_data"):
|
||||
parts.append("remove data")
|
||||
if ctx.get("move_data"):
|
||||
parts.append("move data")
|
||||
if result.get("count") is not None:
|
||||
parts.append(f"done: {result.get('count')}")
|
||||
if result.get("errors"):
|
||||
parts.append(f"errors: {len(result.get('errors') or [])}")
|
||||
return "; ".join(parts)
|
||||
|
||||
|
||||
def _public_job(row) -> dict:
|
||||
d = dict(row)
|
||||
payload = _safe_json(d.get("payload_json"), {})
|
||||
result = _safe_json(d.get("result_json"), {})
|
||||
ctx = payload.get("job_context") or {}
|
||||
d["payload"] = payload
|
||||
state = _safe_json(d.get("state_json"), {})
|
||||
d["result"] = result
|
||||
d["state"] = state
|
||||
d["progress_current"] = int(d.get("progress_current") or len(state.get("completed_hashes") or []))
|
||||
d["progress_total"] = int(d.get("progress_total") or len(payload.get("hashes") or []) or result.get("count") or 0)
|
||||
d["hash_count"] = int(ctx.get("hash_count") or len(payload.get("hashes") or []) or result.get("count") or 0)
|
||||
d["is_bulk"] = bool(ctx.get("bulk") or d["hash_count"] > 1)
|
||||
d["summary"] = _job_summary(d, payload, result)
|
||||
d["source"] = str(ctx.get("source") or "user")
|
||||
d["source_label"] = str(ctx.get("rule_name") or ctx.get("source") or "user")
|
||||
d["is_forced"] = bool(payload.get("force_job") or payload.get("priority_job"))
|
||||
items = ctx.get("items") or []
|
||||
if d["is_bulk"]:
|
||||
d["items_preview"] = ""
|
||||
else:
|
||||
d["items_preview"] = ", ".join([str((x or {}).get("name") or (x or {}).get("hash") or "") for x in items[:1] if x])
|
||||
return d
|
||||
|
||||
|
||||
def _job_scope_sql(writable: bool = False) -> tuple[str, tuple]:
|
||||
visible = auth.writable_profile_ids() if writable else auth.visible_profile_ids()
|
||||
if visible is None:
|
||||
return "", ()
|
||||
if not visible:
|
||||
return " WHERE 1=0", ()
|
||||
placeholders = ",".join("?" for _ in visible)
|
||||
return f" WHERE profile_id IN ({placeholders})", tuple(visible)
|
||||
|
||||
|
||||
def list_jobs(limit: int = 200, offset: int = 0):
|
||||
limit = max(1, min(int(limit or 50), 500))
|
||||
offset = max(0, int(offset or 0))
|
||||
where, params = _job_scope_sql()
|
||||
with connect() as conn:
|
||||
rows = conn.execute(f"SELECT * FROM jobs{where} ORDER BY created_at DESC LIMIT ? OFFSET ?", (*params, limit, offset)).fetchall()
|
||||
total = conn.execute(f"SELECT COUNT(*) AS n FROM jobs{where}", params).fetchone()["n"]
|
||||
return {"rows": [_public_job(r) for r in rows], "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
def cancel_job(job_id: str) -> bool:
|
||||
row = _job_row(job_id)
|
||||
if not row or row["status"] not in {"pending", "running"}:
|
||||
return False
|
||||
# Note: Emergency cancel is useful only for unfinished jobs; failed/done entries stay available for retry or log cleanup.
|
||||
_set_job(job_id, "cancelled", finished=True)
|
||||
_emit("job_update", {"id": job_id, "profile_id": row.get("profile_id"), "status": "cancelled"})
|
||||
return True
|
||||
|
||||
|
||||
def clear_jobs() -> int:
|
||||
where, params = _job_scope_sql(writable=True)
|
||||
status_clause = "status NOT IN ('pending', 'running')"
|
||||
sql = f"DELETE FROM jobs{where} AND {status_clause}" if where else f"DELETE FROM jobs WHERE {status_clause}"
|
||||
with connect() as conn:
|
||||
cur = conn.execute(sql, params)
|
||||
return int(cur.rowcount or 0)
|
||||
|
||||
|
||||
def emergency_clear_jobs() -> int:
|
||||
# Note: Emergency cleanup first marks active jobs as cancelled, then clears the whole job log list.
|
||||
now = utcnow()
|
||||
where, params = _job_scope_sql(writable=True)
|
||||
status_clause = "status IN ('pending', 'running')"
|
||||
update_sql = f"UPDATE jobs SET status='cancelled', error='Emergency cancelled by user', finished_at=COALESCE(finished_at, ?), updated_at=?{where} AND {status_clause}" if where else "UPDATE jobs SET status='cancelled', error='Emergency cancelled by user', finished_at=COALESCE(finished_at, ?), updated_at=? WHERE status IN ('pending', 'running')"
|
||||
with connect() as conn:
|
||||
conn.execute(update_sql, (now, now, *params) if where else (now, now))
|
||||
cur = conn.execute(f"DELETE FROM jobs{where}", params) if where else conn.execute("DELETE FROM jobs")
|
||||
deleted = int(cur.rowcount or 0)
|
||||
_emit("job_update", {"status": "cleared", "emergency": True})
|
||||
return deleted
|
||||
|
||||
|
||||
def force_job(job_id: str) -> bool:
|
||||
row = _job_row(job_id)
|
||||
if not row or row['status'] != 'pending':
|
||||
return False
|
||||
payload = _job_payload(row)
|
||||
payload['force_job'] = True
|
||||
payload['priority_job'] = True
|
||||
with connect() as conn:
|
||||
conn.execute("UPDATE jobs SET payload_json=?, updated_at=? WHERE id=?", (json.dumps(payload), utcnow(), job_id))
|
||||
_emit('job_update', {'id': job_id, 'profile_id': row.get('profile_id'), 'status': 'pending', 'forced': True})
|
||||
_submit_job(job_id, row.get('action'))
|
||||
return True
|
||||
|
||||
def retry_job(job_id: str) -> bool:
|
||||
row = _job_row(job_id)
|
||||
if not row or row["status"] not in {"failed", "cancelled"}:
|
||||
return False
|
||||
with connect() as conn:
|
||||
conn.execute("UPDATE jobs SET status='pending', error='', finished_at=NULL, state_json=NULL, progress_current=0, heartbeat_at=NULL, updated_at=? WHERE id=?", (utcnow(), job_id))
|
||||
_emit("job_update", {"id": job_id, "profile_id": row.get("profile_id"), "status": "pending"})
|
||||
_submit_job(job_id, row.get("action"))
|
||||
return True
|
||||
9
pytorrent/static/favicon.svg
Normal file
9
pytorrent/static/favicon.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect x="14" y="20" width="36" height="30" rx="8" fill="#f8fafc" stroke="#0f172a" stroke-width="4"></rect>
|
||||
<rect x="22" y="30" width="6" height="6" rx="3" fill="#0f172a"></rect>
|
||||
<rect x="36" y="30" width="6" height="6" rx="3" fill="#0f172a"></rect>
|
||||
<path d="M25 42h14" stroke="#0f172a" stroke-width="4" stroke-linecap="round"></path>
|
||||
<path d="M32 20V10" stroke="#0f172a" stroke-width="4" stroke-linecap="round"></path>
|
||||
<circle cx="32" cy="8" r="4" fill="#0f172a"></circle>
|
||||
<path d="M14 34H8M56 34h-6" stroke="#0f172a" stroke-width="4" stroke-linecap="round"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 647 B |
1
pytorrent/static/js/api.js
Normal file
1
pytorrent/static/js/api.js
Normal file
@@ -0,0 +1 @@
|
||||
export const apiSource = " async function post(url,data,method='POST'){\n const res=await fetch(url,{method,headers:{'Content-Type':'application/json','Accept':'application/json'},body:JSON.stringify(data||{})});\n const text=await res.text();\n let json;\n try{ json=JSON.parse(text); }\n catch(e){\n const clean=(text||'').replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim().slice(0,180);\n throw new Error(clean?`Invalid server response (${res.status}): ${clean}`:`Invalid server response (${res.status})`);\n }\n if(!res.ok || !json.ok) throw new Error(json.error||`Operation failed (${res.status})`);\n return json;\n }\n\n async function runAction(action, extra={}){ const hashes=selectedHashes(); if(!hashes.length) return toast('No torrents selected','warning'); let payload={hashes,...extra}; if(action==='move'){ openPathPicker('move'); return; } setBusy(true); try{ const j=await post(`/api/torrents/${action}`,payload); markQueuedJobs(j, hashes, action); if(action==='recheck'){ hashes.forEach(h=>{ const t=torrents.get(h); if(t) torrents.set(h,{...t,status:'Checking',hashing:1,message:'Force recheck queued'}); }); scheduleRender(true); } const parts=Number(j.bulk_parts||1); toast(parts>1?`${action} queued in ${parts} bulk parts`:`${action} queued`,'success'); if(action==='set_label') await loadLabels(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }\n function flag(iso){ const code=String(iso||'').toLowerCase(); return code?`<span class=\"fi fi-${esc(code)}\"></span> <span>${esc(code.toUpperCase())}</span>`:'-'; }\n function table(headers,rows,extraClass=''){ const cls=extraClass?` ${extraClass}`:''; return `<table class=\"table table-sm detail-table${cls}\"><thead><tr>${headers.map(h=>`<th>${esc(h)}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${r.map(c=>`<td>${c}</td>`).join('')}</tr>`).join('')}</tbody></table>`; }\n function responsiveTable(headers,rows,extraClass=''){ return `<div class=\"responsive-table-wrap\">${table(headers,rows,extraClass)}</div>`; }\n function downloadJson(filename, data){ const blob=new Blob([JSON.stringify(data,null,2)],{type:'application/json'}); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(url),500); }\n function filenameFromResponse(res, fallback){ const cd=res.headers.get('Content-Disposition')||''; const m=cd.match(/filename\\*=UTF-8''([^;]+)|filename=\"?([^\";]+)\"?/i); try{ return decodeURIComponent(m?.[1]||m?.[2]||fallback); }catch(e){ return m?.[1]||m?.[2]||fallback; } }\n async function downloadResponse(url, options={}, fallback='download.bin', label='Preparing download...'){\n setBusy(true,label);\n try{\n const res=await fetch(url,options);\n if(!res.ok){ const j=await res.json().catch(()=>({})); throw new Error(j.error||`Download failed: HTTP ${res.status}`); }\n const total=Number(res.headers.get('Content-Length')||0);\n const name=filenameFromResponse(res,fallback);\n let blob;\n if(res.body){\n const reader=res.body.getReader();\n const chunks=[]; let received=0;\n while(true){\n const {done,value}=await reader.read();\n if(done) break;\n chunks.push(value); received += value.length;\n const loader=$('globalLoader');\n const span=loader?.querySelector('span:last-child');\n if(span){\n if(total){\n const pct=Math.max(0,Math.min(100,Math.round((received/total)*100)));\n span.textContent=`Downloading ${pct}%`;\n } else {\n span.textContent=`Downloading ${(received/1024/1024).toFixed(1)} MB`;\n }\n }\n }\n blob=new Blob(chunks);\n } else {\n blob=await res.blob();\n }\n const obj=URL.createObjectURL(blob);\n const a=document.createElement('a'); a.href=obj; a.download=name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(obj),1000);\n toast('Download started','success');\n } finally { setBusy(false); }\n }\n async function downloadTorrentFiles(hashes=null){\n const list=hashes||selectedHashes();\n if(!list.length) return toast('No torrents selected','warning');\n if(list.length===1) return downloadResponse(`/api/torrents/${encodeURIComponent(list[0])}/torrent-file`,{},`${list[0]}.torrent`,'Preparing .torrent...').catch(e=>toast(e.message,'danger'));\n return downloadResponse('/api/torrents/torrent-files.zip',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({hashes:list})},'pytorrent-torrents.zip','Preparing torrent ZIP...').catch(e=>toast(e.message,'danger'));\n }\n";
|
||||
44
pytorrent/static/js/app.js
Normal file
44
pytorrent/static/js/app.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { stateSource } from './state.js';
|
||||
import { torrentsSource } from './torrents.js';
|
||||
import { apiSource } from './api.js';
|
||||
import { createTorrentSource } from './createTorrent.js';
|
||||
import { torrentDetailsSource } from './torrentDetails.js';
|
||||
import { modalsSource } from './modals.js';
|
||||
import { rssSource } from './rss.js';
|
||||
import { smartQueueSource } from './smartQueue.js';
|
||||
import { plannerSource } from './planner.js';
|
||||
import { pollerSource } from './poller.js';
|
||||
import { dashboardSource } from './dashboard.js';
|
||||
import { chartsSource } from './charts.js';
|
||||
import { bootstrapSource } from './bootstrap.js';
|
||||
|
||||
export const moduleSources = [
|
||||
stateSource,
|
||||
torrentsSource,
|
||||
apiSource,
|
||||
createTorrentSource,
|
||||
torrentDetailsSource,
|
||||
modalsSource,
|
||||
rssSource,
|
||||
smartQueueSource,
|
||||
plannerSource,
|
||||
dashboardSource,
|
||||
pollerSource,
|
||||
chartsSource,
|
||||
bootstrapSource,
|
||||
];
|
||||
|
||||
export function buildRuntimeSource(){
|
||||
return `(() => {\n${moduleSources.join('\n')}\n})();\n`;
|
||||
}
|
||||
|
||||
export function startApp(){
|
||||
const runtimeSource = buildRuntimeSource();
|
||||
// Keep the original shared lexical scope while loading the source from smaller ES modules.
|
||||
// `io` is passed explicitly so Socket.IO remains available inside the generated runtime.
|
||||
return Function('io', runtimeSource)(window.io);
|
||||
}
|
||||
|
||||
if(typeof window !== 'undefined' && !window.PYTORRENT_DISABLE_AUTOSTART){
|
||||
startApp();
|
||||
}
|
||||
1
pytorrent/static/js/bootstrap.js
vendored
Normal file
1
pytorrent/static/js/bootstrap.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
pytorrent/static/js/charts.js
Normal file
1
pytorrent/static/js/charts.js
Normal file
File diff suppressed because one or more lines are too long
1
pytorrent/static/js/createTorrent.js
Normal file
1
pytorrent/static/js/createTorrent.js
Normal file
@@ -0,0 +1 @@
|
||||
export const createTorrentSource = " function isCreateTorrentTabActive(){\n return $('createTorrentPane')?.classList.contains('active');\n }\n function syncAddAndCreateActions(){\n const createActive = isCreateTorrentTabActive();\n $('addBtn')?.classList.toggle('d-none', !!createActive);\n $('createTorrentBtn')?.classList.toggle('d-none', !createActive);\n }\n function createTorrentPayload(){\n const fd = new FormData();\n fd.append('source_path', $('createSourcePath')?.value || '');\n fd.append('trackers', $('createTrackers')?.value || '');\n fd.append('comment', $('createComment')?.value || '');\n fd.append('source', $('createSourceName')?.value || '');\n fd.append('piece_size_kib', $('createPieceSize')?.value || '256');\n fd.append('private', $('createPrivate')?.checked ? '1' : '0');\n fd.append('share', $('createShare')?.checked ? '1' : '0');\n fd.append('label', $('createLabel')?.value || '');\n return fd;\n }\n function downloadCreatedTorrent(blob,name){\n const obj = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = obj;\n a.download = name;\n document.body.appendChild(a);\n a.click();\n a.remove();\n setTimeout(()=>URL.revokeObjectURL(obj), 1000);\n }\n async function createTorrentFromModal(){\n const btn = $('createTorrentBtn');\n const info = $('createTorrentInfo');\n buttonBusy(btn, true);\n setBusy(true, 'Creating torrent...');\n if(info) info.textContent = 'Creating .torrent file...';\n try{\n const res = await fetch('/api/torrents/create', {method: 'POST', body: createTorrentPayload()});\n if(!res.ok){\n const j = await res.json().catch(()=>({}));\n throw new Error(j.error || `Create failed (${res.status})`);\n }\n const name = filenameFromResponse(res, 'created.torrent');\n const message = res.headers.get('X-PyTorrent-Create-Message') || 'Torrent created';\n const blob = await res.blob();\n downloadCreatedTorrent(blob, name);\n if(info) info.textContent = message;\n toast(message, 'success');\n }catch(e){\n if(info) info.textContent = e.message;\n toast(e.message, 'danger');\n }finally{\n setBusy(false);\n buttonBusy(btn, false);\n }\n }\n $('addModal')?.addEventListener('shown.bs.modal', syncAddAndCreateActions);\n document.querySelectorAll('#addModal [data-bs-toggle=\"pill\"]').forEach(tab => tab.addEventListener('shown.bs.tab', syncAddAndCreateActions));\n $('createTorrentBtn')?.addEventListener('click', createTorrentFromModal);\n";
|
||||
1
pytorrent/static/js/dashboard.js
Normal file
1
pytorrent/static/js/dashboard.js
Normal file
File diff suppressed because one or more lines are too long
1
pytorrent/static/js/modals.js
Normal file
1
pytorrent/static/js/modals.js
Normal file
File diff suppressed because one or more lines are too long
1
pytorrent/static/js/planner.js
Normal file
1
pytorrent/static/js/planner.js
Normal file
File diff suppressed because one or more lines are too long
1
pytorrent/static/js/poller.js
Normal file
1
pytorrent/static/js/poller.js
Normal file
File diff suppressed because one or more lines are too long
1
pytorrent/static/js/rss.js
Normal file
1
pytorrent/static/js/rss.js
Normal file
@@ -0,0 +1 @@
|
||||
export const rssSource = " async function loadRss(){ const j=await (await fetch('/api/rss')).json(); const feeds=j.feeds||[], rules=j.rules||[], history=j.history||[]; if($('rssManager')) $('rssManager').innerHTML=`<h6>Feeds</h6>${table(['Name','URL','Interval','Last check','Last error','Actions'],feeds.map(f=>[esc(f.name),esc(f.url),esc(f.interval_minutes||30)+' min',humanDateCell(f.last_checked_at),esc(f.last_error||''),`<button class=\"btn btn-xs btn-outline-primary rss-edit-feed\" data-feed='${esc(JSON.stringify(f))}'><i class=\"fa-solid fa-pen-to-square\"></i> Edit</button> <button class=\"btn btn-xs btn-outline-danger rss-delete-feed\" data-id=\"${esc(f.id)}\"><i class=\"fa-solid fa-trash-can\"></i> Delete</button>`]))}<h6 class=\"mt-3\">Rules</h6>${table(['Name','Include','Exclude','Filters','Path','Label','Actions'],rules.map(r=>[esc(r.name),esc(r.pattern),esc(r.exclude_pattern||''),esc([r.min_size_mb?`min ${r.min_size_mb}MB`:'',r.max_size_mb?`max ${r.max_size_mb}MB`:'',r.category,r.quality,r.season?`S${r.season}`:'',r.episode?`E${r.episode}`:''].filter(Boolean).join(', ')),esc(r.save_path),esc(r.label),`<button class=\"btn btn-xs btn-outline-primary rss-edit-rule\" data-rule='${esc(JSON.stringify(r))}'><i class=\"fa-solid fa-pen-to-square\"></i> Edit</button> <button class=\"btn btn-xs btn-outline-danger rss-delete-rule\" data-id=\"${esc(r.id)}\"><i class=\"fa-solid fa-trash-can\"></i> Delete</button>`]))}<h6 class=\"mt-3\">RSS log</h6>${table(['Time','Title','Status','Message'],history.map(h=>[humanDateCell(h.created_at),esc(h.title||h.link||''),esc(h.status),esc(h.message||'')]))}`; }\n \n\n function fillBackupSettings(settings={}){\n if($('backupAutoEnabled')) $('backupAutoEnabled').checked=!!settings.enabled;\n if($('backupAutoInterval')) $('backupAutoInterval').value=settings.interval_hours||24;\n if($('backupRetentionDays')) $('backupRetentionDays').value=settings.retention_days||30;\n }\n function backupPreviewDetails(table={}){\n const sample=table.sample||[];\n if(!sample.length) return '<div class=\"backup-preview-empty\">No saved rows in this table.</div>';\n const keys=[...new Set(sample.flatMap(row=>Object.keys(row||{})))].slice(0,8);\n return responsiveTable(keys.map(esc), sample.map(row=>keys.map(key=>esc(row?.[key] ?? ''))), 'backup-preview-sample-table');\n }\n function backupPreviewTable(preview={}){\n const tables=preview.tables||[];\n const rows=tables.map(t=>`<details class=\"backup-preview-table-details\"><summary><span><b>${esc(t.name)}</b><small>${esc(t.rows)} row(s) \u00b7 ${(t.columns||[]).length} column(s)</small></span></summary>${backupPreviewDetails(t)}</details>`).join('');\n return `<div class=\"surface-section backup-preview-card\"><div class=\"section-title\"><i class=\"fa-solid fa-eye\"></i> Backup preview</div><div class=\"small text-muted mb-2\">Created: ${esc(preview.created_at||'-')} \u00b7 ${preview.automatic?'automatic':'manual'} \u00b7 sensitive values hidden</div>${rows || '<div class=\"empty-mini\">Backup has no previewable settings.</div>'}</div>`;\n }\n async function loadBackup(){\n const j=await (await fetch('/api/backup')).json();\n const rows=j.backups||[];\n fillBackupSettings(j.auto||{});\n if($('backupManager')) $('backupManager').innerHTML=responsiveTable(['Name','Created','Type','Actions'],rows.map(b=>[esc(b.name),humanDateCell(b.created_at),b.automatic?'Auto':'Manual',`<div class=\"table-action-group backup-actions\"><button class=\"btn btn-xs btn-outline-info backup-preview-btn\" data-id=\"${esc(b.id)}\"><i class=\"fa-solid fa-eye\"></i> Preview</button><a class=\"btn btn-xs btn-outline-secondary\" href=\"/api/backup/${esc(b.id)}/download\"><i class=\"fa-solid fa-download\"></i> Download</a><button class=\"btn btn-xs btn-outline-warning backup-restore\" data-id=\"${esc(b.id)}\"><i class=\"fa-solid fa-rotate-left\"></i> Restore</button><button class=\"btn btn-xs btn-outline-danger backup-delete\" data-id=\"${esc(b.id)}\"><i class=\"fa-solid fa-trash-can\"></i> Delete</button></div>`]),'backup-table');\n }\n\n";
|
||||
1
pytorrent/static/js/smartQueue.js
Normal file
1
pytorrent/static/js/smartQueue.js
Normal file
File diff suppressed because one or more lines are too long
1
pytorrent/static/js/smartQueue.js.orig
Normal file
1
pytorrent/static/js/smartQueue.js.orig
Normal file
File diff suppressed because one or more lines are too long
1
pytorrent/static/js/state.js
Normal file
1
pytorrent/static/js/state.js
Normal file
File diff suppressed because one or more lines are too long
1
pytorrent/static/js/torrentDetails.js
Normal file
1
pytorrent/static/js/torrentDetails.js
Normal file
File diff suppressed because one or more lines are too long
1
pytorrent/static/js/torrents.js
Normal file
1
pytorrent/static/js/torrents.js
Normal file
File diff suppressed because one or more lines are too long
4181
pytorrent/static/styles.css
Normal file
4181
pytorrent/static/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
3933
pytorrent/static/styles.original.css
Normal file
3933
pytorrent/static/styles.original.css
Normal file
File diff suppressed because it is too large
Load Diff
1
pytorrent/static/tracker_favicons
Symbolic link
1
pytorrent/static/tracker_favicons
Symbolic link
@@ -0,0 +1 @@
|
||||
../../data/tracker_favicons
|
||||
26
pytorrent/templates/error.html
Normal file
26
pytorrent/templates/error.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>pyTorrent {{ code }}</title>
|
||||
<link rel="icon" href="{{ static_url('favicon.svg') }}" type="image/svg+xml">
|
||||
<link rel="shortcut icon" href="{{ static_url('favicon.svg') }}" type="image/svg+xml">
|
||||
<link href="{{ bootstrap_theme_url('default') }}" rel="stylesheet">
|
||||
<link href="{{ frontend_asset_url('fontawesome_css') }}" rel="stylesheet">
|
||||
<link href="{{ static_url('styles.css') }}" rel="stylesheet">
|
||||
</head>
|
||||
<body class="error-page">
|
||||
<main class="error-card" role="alert">
|
||||
<div class="error-brand"><i class="fa-solid fa-robot"></i> pyTorrent</div>
|
||||
<div class="error-icon" aria-hidden="true"><i class="fa-solid {{ icon }}"></i></div>
|
||||
<p class="error-code">{{ code }}</p>
|
||||
<h1>{{ title }}</h1>
|
||||
<p>{{ message }}</p>
|
||||
<div class="error-actions">
|
||||
<a class="btn btn-primary" href="{{ url_for('main.index') }}"><i class="fa-solid fa-house"></i> Back to dashboard</a>
|
||||
<a class="btn btn-outline-secondary" href="{{ url_for('main.docs') }}"><i class="fa-solid fa-book"></i> API docs</a>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
364
pytorrent/templates/index.html
Normal file
364
pytorrent/templates/index.html
Normal file
File diff suppressed because one or more lines are too long
29
pytorrent/templates/login.html
Normal file
29
pytorrent/templates/login.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>pyTorrent login</title>
|
||||
<link rel="icon" href="{{ static_url('favicon.svg') }}" type="image/svg+xml">
|
||||
<link rel="shortcut icon" href="{{ static_url('favicon.svg') }}" type="image/svg+xml">
|
||||
<link href="{{ bootstrap_theme_url('default') }}" rel="stylesheet">
|
||||
<link href="{{ frontend_asset_url('fontawesome_css') }}" rel="stylesheet">
|
||||
<link href="{{ static_url('styles.css') }}" rel="stylesheet">
|
||||
</head>
|
||||
<body class="auth-page">
|
||||
<main class="initial-loader-card auth-card">
|
||||
<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>
|
||||
<h1 class="initial-loader-title">Sign in</h1>
|
||||
<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 %}
|
||||
<form class="auth-form" method="post">
|
||||
<label class="form-label" for="username">User</label>
|
||||
<input id="username" class="form-control" name="username" autocomplete="username" autofocus>
|
||||
<label class="form-label" for="password">Password</label>
|
||||
<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>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
21
pytorrent/utils.py
Normal file
21
pytorrent/utils.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def human_size(num: int | float | None, suffix: str = "B") -> str:
|
||||
value = float(num or 0)
|
||||
for unit in ["", "K", "M", "G", "T", "P"]:
|
||||
if abs(value) < 1024.0:
|
||||
return f"{value:3.1f} {unit}{suffix}" if unit else f"{int(value)} {suffix}"
|
||||
value /= 1024.0
|
||||
return f"{value:.1f} E{suffix}"
|
||||
|
||||
|
||||
def human_rate(num: int | float | None) -> str:
|
||||
return f"{human_size(num)}/s"
|
||||
|
||||
|
||||
def file_md5(path: Path) -> str:
|
||||
return hashlib.md5(path.read_bytes()).hexdigest()[:12]
|
||||
7
requirements.txt
Normal file
7
requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
Flask>=3.0
|
||||
Flask-SocketIO>=5.3
|
||||
python-dotenv>=1.0
|
||||
geoip2>=4.8
|
||||
psutil>=5.9
|
||||
simple-websocket>=1.0
|
||||
gunicorn>=22.0
|
||||
198
scripts/INSTALL.md
Normal file
198
scripts/INSTALL.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# pyTorrent stack installer
|
||||
|
||||
This document describes the one-command installer for installing **rTorrent + pyTorrent** from a clean server.
|
||||
|
||||
The installer is split into two layers:
|
||||
|
||||
- `scripts/install_stack.sh` - public bootstrap script intended to be downloaded directly from Git.
|
||||
- `scripts/stack_installers/` - OS-specific installers and helper scripts used by the bootstrap script.
|
||||
|
||||
## Quick install
|
||||
|
||||
Run as root or through `sudo`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh | sudo bash
|
||||
```
|
||||
|
||||
The bootstrap script downloads the current pyTorrent repository, detects the operating system family, and runs the matching installer:
|
||||
|
||||
- Debian / Ubuntu: `scripts/stack_installers/install_stack_debian_ubuntu.sh`
|
||||
- RHEL-compatible systems: `scripts/stack_installers/install_stack_rhel.sh`
|
||||
|
||||
Supported RHEL-compatible systems include RHEL, Rocky Linux, AlmaLinux, CentOS Stream, and Fedora-like systems where `dnf` or `yum` is available.
|
||||
|
||||
## What gets installed
|
||||
|
||||
Default installation includes:
|
||||
|
||||
- rTorrent `v0.16.11`
|
||||
- libtorrent `v0.16.11`
|
||||
- minimal rTorrent build without c-ares/custom curl
|
||||
- rTorrent system user: `rtorrent`
|
||||
- rTorrent SCGI endpoint: `scgi://127.0.0.1:5000`
|
||||
- rTorrent incoming BitTorrent port: `51300`
|
||||
- pyTorrent application directory: `/opt/pytorrent`
|
||||
- pyTorrent HTTP port: `8090`
|
||||
- pyTorrent profile configured through the HTTP API
|
||||
|
||||
The installer creates or updates a pyTorrent rTorrent profile through API after both services are installed.
|
||||
|
||||
## Recommended usage with overrides
|
||||
|
||||
Environment variables must be passed to the `sudo bash` process.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
||||
| sudo PYTORRENT_PORT=8091 RTORRENT_SCGI_PORT=5001 bash
|
||||
```
|
||||
|
||||
Another example with a custom profile name:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
||||
| sudo PYTORRENT_PROFILE_NAME="Local rTorrent" PYTORRENT_PORT=8090 bash
|
||||
```
|
||||
|
||||
## Bootstrap parameters
|
||||
|
||||
These variables are used by `scripts/install_stack.sh`.
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `PYTORRENT_REPO_URL` | `https://git.linuxiarz.pl/gru/pyTorrent` | Git repository base URL. |
|
||||
| `PYTORRENT_REPO_BRANCH` | `master` | Branch used to download the repository archive. |
|
||||
| `PYTORRENT_ARCHIVE_URL` | derived from repo URL and branch | Custom repository archive URL. |
|
||||
| `PYTORRENT_BOOTSTRAP_DIR` | `/tmp/pytorrent-stack-installer` | Temporary directory used by the bootstrap script. |
|
||||
| `PYTORRENT_KEEP_BOOTSTRAP_DIR` | `0` | Set to `1` to keep the temporary directory after installation. |
|
||||
|
||||
Example using a different branch:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
||||
| sudo PYTORRENT_REPO_BRANCH=develop bash
|
||||
```
|
||||
|
||||
## rTorrent parameters
|
||||
|
||||
These variables are used by both stack installers.
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `RTORRENT_USER` | `rtorrent` | System user used to run rTorrent. |
|
||||
| `RTORRENT_HOME` | `/home/${RTORRENT_USER}` | Home directory for the rTorrent user. |
|
||||
| `RTORRENT_BASE_DIR` | `/opt/rtorrent_build` | Build and install directory for xmlrpc-c, libtorrent and rTorrent. |
|
||||
| `RTORRENT_SCGI_PORT` | `5000` | Local SCGI port for rTorrent XMLRPC/SCGI. |
|
||||
| `RTORRENT_TORRENT_PORT` | `51300` | Incoming BitTorrent listen port. |
|
||||
| `RTORRENT_REF` | `v0.16.11` | rTorrent Git tag, branch, or commit. |
|
||||
| `LIBTORRENT_REF` | `v0.16.11` | libtorrent Git tag, branch, or commit. |
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
||||
| sudo RTORRENT_USER=rtorrent RTORRENT_SCGI_PORT=5001 RTORRENT_TORRENT_PORT=51400 bash
|
||||
```
|
||||
|
||||
## pyTorrent parameters
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `PYTORRENT_APP_DIR` | `/opt/pytorrent` | pyTorrent installation directory. |
|
||||
| `PYTORRENT_PORT` | `8090` | HTTP port used by the pyTorrent service. |
|
||||
| `PYTORRENT_BASE_URL` | `http://127.0.0.1:${PYTORRENT_PORT}` | Base URL used by the API configurator. |
|
||||
| `PYTORRENT_PROFILE_NAME` | `Local rTorrent` | Name of the rTorrent profile created in pyTorrent. |
|
||||
| `PYTORRENT_API_TOKEN` | empty | Bearer token used when pyTorrent API authentication is enabled. |
|
||||
| `PYTORRENT_SERVICE_NAME` | `pytorrent` | systemd service name for pyTorrent. |
|
||||
| `PYTORRENT_RTORRENT_SCGI_URL` | `scgi://127.0.0.1:${RTORRENT_SCGI_PORT}` | SCGI URL saved in the pyTorrent rTorrent profile. |
|
||||
|
||||
Example with API token:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
||||
| sudo PYTORRENT_API_TOKEN="pt_xxx" bash
|
||||
```
|
||||
|
||||
## API configurator parameters
|
||||
|
||||
The API configurator can be run manually:
|
||||
|
||||
```bash
|
||||
/opt/pytorrent/venv/bin/python /opt/pytorrent/scripts/stack_installers/configure_pytorrent_api.py \
|
||||
--base-url http://127.0.0.1:8090 \
|
||||
--profile-name "Local rTorrent" \
|
||||
--scgi-url scgi://127.0.0.1:5000
|
||||
```
|
||||
|
||||
CLI options:
|
||||
|
||||
| Option | Environment variable | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `--base-url` | `PYTORRENT_BASE_URL` | `http://127.0.0.1:8090` | pyTorrent API base URL. |
|
||||
| `--api-token` | `PYTORRENT_API_TOKEN` | empty | Bearer token for authenticated API calls. |
|
||||
| `--profile-name` | `PYTORRENT_RTORRENT_PROFILE_NAME` | `Local rTorrent` | Profile name to create or update. |
|
||||
| `--scgi-url` | `PYTORRENT_RTORRENT_SCGI_URL` | `scgi://127.0.0.1:5000` | rTorrent SCGI URL. |
|
||||
| `--timeout` | `PYTORRENT_RTORRENT_TIMEOUT` | `10` | rTorrent request timeout in seconds. |
|
||||
| `--wait` | `PYTORRENT_API_WAIT_SECONDS` | `90` | Time to wait for the pyTorrent API to become available. |
|
||||
| `--remote` | `PYTORRENT_RTORRENT_REMOTE` | `0` | Mark profile as remote. Accepts `1`, `true`, `yes`, `on`. |
|
||||
|
||||
## Local installation without bootstrap
|
||||
|
||||
If the repository is already cloned:
|
||||
|
||||
Debian / Ubuntu:
|
||||
|
||||
```bash
|
||||
sudo bash scripts/stack_installers/install_stack_debian_ubuntu.sh
|
||||
```
|
||||
|
||||
RHEL-compatible systems:
|
||||
|
||||
```bash
|
||||
sudo bash scripts/stack_installers/install_stack_rhel.sh
|
||||
```
|
||||
|
||||
## Installed service hints
|
||||
|
||||
Check services:
|
||||
|
||||
```bash
|
||||
systemctl status pytorrent
|
||||
systemctl status rtorrent@rtorrent.service
|
||||
```
|
||||
|
||||
Check logs:
|
||||
|
||||
```bash
|
||||
tail -f /data/logs/app.log /data/logs/error.log
|
||||
journalctl -u pytorrent -f
|
||||
journalctl -u rtorrent@rtorrent.service -f
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The default rTorrent build is intentionally minimal.
|
||||
- c-ares and custom curl are not enabled by the stack installer defaults.
|
||||
- The rTorrent installer overwrites the generated `.rtorrent.rc` because the stack installer passes `--force-config`.
|
||||
- pyTorrent is configured through the HTTP API after the service starts.
|
||||
- If API authentication is enabled before profile configuration, pass `PYTORRENT_API_TOKEN`.
|
||||
|
||||
|
||||
## Build logs and troubleshooting
|
||||
|
||||
The stack installer writes quiet build output to `/var/log/pytorrent-installer` by default.
|
||||
Override it with:
|
||||
|
||||
```bash
|
||||
PYTORRENT_STACK_LOG_DIR=/tmp/pytorrent-build-logs
|
||||
```
|
||||
|
||||
For full command output during rTorrent/libtorrent compilation, run with:
|
||||
|
||||
```bash
|
||||
PYTORRENT_DEBUG_INSTALL=1
|
||||
```
|
||||
|
||||
On RHEL-compatible systems the installer also tries to enable CRB/PowerTools and installs `libcurl-devel`, `redhat-rpm-config`, `patch`, `diffutils`, `findutils`, `file`, and `libstdc++-devel`, because minimal Alma/Rocky images often do not include enough build tooling.
|
||||
11
scripts/check_pytorrent_health.sh
Executable file
11
scripts/check_pytorrent_health.sh
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
# Note: Simple Nagios-compatible pyTorrent API check; set PYTORRENT_URL if the app is not local.
|
||||
URL="${PYTORRENT_URL:-http://127.0.0.1:8000/api/health/nagios}"
|
||||
OUT=$(curl -fsS --max-time "${PYTORRENT_HEALTH_TIMEOUT:-5}" "$URL" 2>&1)
|
||||
RC=$?
|
||||
if [ "$RC" -eq 0 ]; then
|
||||
printf '%s\n' "$OUT"
|
||||
exit 0
|
||||
fi
|
||||
printf 'CRITICAL - pyTorrent health check failed: %s\n' "$OUT"
|
||||
exit 2
|
||||
113
scripts/download_frontend_libs.py
Executable file
113
scripts/download_frontend_libs.py
Executable file
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LIBS_STATIC_DIR = "libs"
|
||||
BOOTSTRAP_VERSION = "5.3.3"
|
||||
BOOTSWATCH_VERSION = "5.3.3"
|
||||
FONTAWESOME_VERSION = "6.5.2"
|
||||
FLAG_ICONS_VERSION = "7.2.3"
|
||||
SWAGGER_UI_VERSION = "5"
|
||||
SOCKET_IO_VERSION = "4.7.5"
|
||||
BOOTSTRAP_THEMES = (
|
||||
"default",
|
||||
"flatly",
|
||||
"litera",
|
||||
"lumen",
|
||||
"minty",
|
||||
"sketchy",
|
||||
"solar",
|
||||
"spacelab",
|
||||
"united",
|
||||
"zephyr",
|
||||
)
|
||||
STATIC_ASSETS = {
|
||||
"bootstrap_js": {
|
||||
"local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/js/bootstrap.bundle.min.js",
|
||||
"cdn": f"https://cdn.jsdelivr.net/npm/bootstrap@{BOOTSTRAP_VERSION}/dist/js/bootstrap.bundle.min.js",
|
||||
},
|
||||
"socket_io_js": {
|
||||
"local": f"{LIBS_STATIC_DIR}/socket.io/{SOCKET_IO_VERSION}/socket.io.min.js",
|
||||
"cdn": f"https://cdn.socket.io/{SOCKET_IO_VERSION}/socket.io.min.js",
|
||||
},
|
||||
"fontawesome_css": {
|
||||
"local": f"{LIBS_STATIC_DIR}/fontawesome/{FONTAWESOME_VERSION}/css/all.min.css",
|
||||
"cdn": f"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/{FONTAWESOME_VERSION}/css/all.min.css",
|
||||
},
|
||||
"flag_icons_css": {
|
||||
"local": f"{LIBS_STATIC_DIR}/flag-icons/{FLAG_ICONS_VERSION}/css/flag-icons.min.css",
|
||||
"cdn": f"https://cdn.jsdelivr.net/gh/lipis/flag-icons@{FLAG_ICONS_VERSION}/css/flag-icons.min.css",
|
||||
},
|
||||
"swagger_css": {
|
||||
"local": f"{LIBS_STATIC_DIR}/swagger-ui/{SWAGGER_UI_VERSION}/swagger-ui.css",
|
||||
"cdn": f"https://cdn.jsdelivr.net/npm/swagger-ui-dist@{SWAGGER_UI_VERSION}/swagger-ui.css",
|
||||
},
|
||||
"swagger_js": {
|
||||
"local": f"{LIBS_STATIC_DIR}/swagger-ui/{SWAGGER_UI_VERSION}/swagger-ui-bundle.js",
|
||||
"cdn": f"https://cdn.jsdelivr.net/npm/swagger-ui-dist@{SWAGGER_UI_VERSION}/swagger-ui-bundle.js",
|
||||
},
|
||||
}
|
||||
URL_RE = re.compile(r"url\((['\"]?)(?!data:)(?!https?:)([^)'\"]+)\1\)")
|
||||
|
||||
|
||||
def bootstrap_css_asset(theme: str) -> dict[str, str]:
|
||||
if theme == "default":
|
||||
return {
|
||||
"local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/css/bootstrap.min.css",
|
||||
"cdn": f"https://cdn.jsdelivr.net/npm/bootstrap@{BOOTSTRAP_VERSION}/dist/css/bootstrap.min.css",
|
||||
}
|
||||
return {
|
||||
"local": f"{LIBS_STATIC_DIR}/bootswatch/{BOOTSWATCH_VERSION}/{theme}/bootstrap.min.css",
|
||||
"cdn": f"https://cdn.jsdelivr.net/npm/bootswatch@{BOOTSWATCH_VERSION}/dist/{theme}/bootstrap.min.css",
|
||||
}
|
||||
|
||||
|
||||
def download(url: str, dest: Path) -> None:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
req = Request(url, headers={"User-Agent": "pyTorrent installer"})
|
||||
with urlopen(req, timeout=60) as response:
|
||||
data = response.read()
|
||||
if not data:
|
||||
raise RuntimeError(f"Empty response for {url}")
|
||||
tmp = dest.with_suffix(dest.suffix + ".tmp")
|
||||
tmp.write_bytes(data)
|
||||
tmp.replace(dest)
|
||||
print(f"OK {dest.relative_to(ROOT)}")
|
||||
|
||||
|
||||
def download_css_with_assets(url: str, dest: Path) -> None:
|
||||
download(url, dest)
|
||||
text = dest.read_text(encoding="utf-8", errors="ignore")
|
||||
for match in URL_RE.finditer(text):
|
||||
rel = match.group(2).split("#", 1)[0].split("?", 1)[0]
|
||||
if not rel:
|
||||
continue
|
||||
asset_url = urljoin(url, rel)
|
||||
asset_dest = (dest.parent / rel).resolve()
|
||||
try:
|
||||
asset_dest.relative_to(ROOT)
|
||||
except ValueError:
|
||||
continue
|
||||
if not asset_dest.exists():
|
||||
download(asset_url, asset_dest)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
items = list(STATIC_ASSETS.values())
|
||||
items.extend(bootstrap_css_asset(theme) for theme in BOOTSTRAP_THEMES)
|
||||
for item in items:
|
||||
url = item["cdn"]
|
||||
dest = ROOT / "pytorrent" / "static" / item["local"]
|
||||
if dest.suffix == ".css":
|
||||
download_css_with_assets(url, dest)
|
||||
else:
|
||||
download(url, dest)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
41
scripts/download_geoip.sh
Executable file
41
scripts/download_geoip.sh
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
DB_PATH="${1:-data/GeoLite2-City.mmdb}"
|
||||
PRIMARY_URL="https://git.io/GeoLite2-City.mmdb"
|
||||
FALLBACK_URL="https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb"
|
||||
DB_DIR="$(dirname "$DB_PATH")"
|
||||
TMP_FILE="${DB_PATH}.tmp"
|
||||
|
||||
mkdir -p "$DB_DIR"
|
||||
chmod 755 "$DB_DIR"
|
||||
|
||||
if [ -s "$DB_PATH" ]; then
|
||||
chmod 644 "$DB_PATH"
|
||||
echo "GeoIP database already exists: $DB_PATH"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
download() {
|
||||
url="$1"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fL --retry 3 --connect-timeout 15 --output "$TMP_FILE" "$url"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -O "$TMP_FILE" "$url"
|
||||
else
|
||||
echo "Missing downloader: install curl or wget" >&2
|
||||
return 127
|
||||
fi
|
||||
}
|
||||
|
||||
rm -f "$TMP_FILE"
|
||||
if ! download "$PRIMARY_URL"; then
|
||||
rm -f "$TMP_FILE"
|
||||
download "$FALLBACK_URL"
|
||||
fi
|
||||
|
||||
test -s "$TMP_FILE"
|
||||
mv "$TMP_FILE" "$DB_PATH"
|
||||
chmod 644 "$DB_PATH"
|
||||
|
||||
echo "GeoIP database downloaded: $DB_PATH"
|
||||
133
scripts/install_debian_ubuntu.sh
Executable file
133
scripts/install_debian_ubuntu.sh
Executable file
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
APP_USER="${PYTORRENT_USER:-pytorrent}"
|
||||
APP_DIR="${PYTORRENT_APP_DIR:-/opt/pytorrent}"
|
||||
SERVICE_NAME="${PYTORRENT_SERVICE_NAME:-pytorrent}"
|
||||
PYTHON_BIN="${PYTHON_BIN:-python3}"
|
||||
PYTORRENT_HOST_VALUE="${PYTORRENT_HOST:-0.0.0.0}"
|
||||
PYTORRENT_PORT_VALUE="${PYTORRENT_PORT:-8090}"
|
||||
PYTORRENT_LOG_DIR_VALUE="${PYTORRENT_LOG_DIR:-/data/logs}"
|
||||
PYTORRENT_LOG_RETENTION_HOURS_VALUE="${PYTORRENT_LOG_RETENTION_HOURS:-24}"
|
||||
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
echo "Run as root: sudo $0" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
apt-get update
|
||||
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
tar \
|
||||
gzip \
|
||||
sudo \
|
||||
git \
|
||||
rsync \
|
||||
pkg-config \
|
||||
python3 \
|
||||
python3-venv \
|
||||
python3-dev \
|
||||
python3-pip
|
||||
|
||||
if ! id -u "${APP_USER}" >/dev/null 2>&1; then
|
||||
useradd \
|
||||
--system \
|
||||
--create-home \
|
||||
--home-dir "/var/lib/${APP_USER}" \
|
||||
--shell /usr/sbin/nologin \
|
||||
"${APP_USER}"
|
||||
fi
|
||||
|
||||
mkdir -p "${APP_DIR}"
|
||||
|
||||
rsync -a --delete \
|
||||
--exclude '.git' \
|
||||
--exclude 'venv' \
|
||||
--exclude '__pycache__' \
|
||||
--exclude '*.pyc' \
|
||||
./ "${APP_DIR}/"
|
||||
|
||||
cd "${APP_DIR}"
|
||||
|
||||
"${PYTHON_BIN}" -m venv venv
|
||||
|
||||
venv/bin/pip install --upgrade pip wheel
|
||||
venv/bin/pip install -r requirements.txt
|
||||
|
||||
mkdir -p data instance logs
|
||||
|
||||
chown -R "${APP_USER}:${APP_USER}" "${APP_DIR}" "/var/lib/${APP_USER}"
|
||||
|
||||
|
||||
upsert_env_value() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
local file="${3:-.env}"
|
||||
touch "${file}"
|
||||
if grep -qE "^${key}=" "${file}"; then
|
||||
sed -i "s|^${key}=.*|${key}=${value}|" "${file}"
|
||||
else
|
||||
printf '%s=%s\n' "${key}" "${value}" >> "${file}"
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ ! -f .env && -f .env.example ]]; then
|
||||
cp .env.example .env
|
||||
chown "${APP_USER}:${APP_USER}" .env
|
||||
fi
|
||||
|
||||
# Keep systemd service config aligned with installer overrides.
|
||||
upsert_env_value "PYTORRENT_HOST" "${PYTORRENT_HOST_VALUE}" .env
|
||||
upsert_env_value "PYTORRENT_PORT" "${PYTORRENT_PORT_VALUE}" .env
|
||||
upsert_env_value "PYTORRENT_LOG_DIR" "${PYTORRENT_LOG_DIR_VALUE}" .env
|
||||
upsert_env_value "PYTORRENT_LOG_RETENTION_HOURS" "${PYTORRENT_LOG_RETENTION_HOURS_VALUE}" .env
|
||||
mkdir -p "${PYTORRENT_LOG_DIR_VALUE}"
|
||||
chown -R "${APP_USER}:${APP_USER}" "${PYTORRENT_LOG_DIR_VALUE}" || true
|
||||
chown "${APP_USER}:${APP_USER}" .env
|
||||
|
||||
if [[ -f scripts/download_frontend_libs.py ]]; then
|
||||
sudo -u "${APP_USER}" \
|
||||
"${APP_DIR}/venv/bin/python" \
|
||||
scripts/download_frontend_libs.py || true
|
||||
fi
|
||||
|
||||
if [[ -f scripts/download_geoip.sh ]]; then
|
||||
sudo -u "${APP_USER}" \
|
||||
bash scripts/download_geoip.sh || true
|
||||
fi
|
||||
|
||||
cat > "/etc/systemd/system/${SERVICE_NAME}.service" <<SERVICE
|
||||
[Unit]
|
||||
Description=pyTorrent Web UI
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${APP_USER}
|
||||
Group=${APP_USER}
|
||||
WorkingDirectory=${APP_DIR}
|
||||
Environment="PYTHONUNBUFFERED=1"
|
||||
EnvironmentFile=${APP_DIR}/.env
|
||||
ExecStart=${APP_DIR}/venv/bin/gunicorn -c ${APP_DIR}/gunicorn.conf.py --worker-class gthread --workers 1 --threads 32 --bind \${PYTORRENT_HOST}:\${PYTORRENT_PORT} --access-logfile - --error-logfile - wsgi:app
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
KillSignal=SIGINT
|
||||
TimeoutStopSec=20
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
SERVICE
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable "${SERVICE_NAME}"
|
||||
systemctl restart "${SERVICE_NAME}"
|
||||
systemctl status "${SERVICE_NAME}" --no-pager --lines=20 || true
|
||||
|
||||
echo "pyTorrent installed in ${APP_DIR}. Service: ${SERVICE_NAME}."
|
||||
163
scripts/install_stack.sh
Executable file
163
scripts/install_stack.sh
Executable file
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Bootstrap installer for pyTorrent + rTorrent.
|
||||
# Intended usage from a clean server:
|
||||
# curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh | sudo bash
|
||||
#
|
||||
# The script downloads the current pyTorrent repository, detects the OS family,
|
||||
# and runs the matching installer from scripts/stack_installers/.
|
||||
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
echo "Run as root, for example: curl -fsSL <url> | sudo bash" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REPO_URL="${PYTORRENT_REPO_URL:-https://git.linuxiarz.pl/gru/pyTorrent}"
|
||||
REPO_BRANCH="${PYTORRENT_REPO_BRANCH:-master}"
|
||||
WORK_DIR="${PYTORRENT_BOOTSTRAP_DIR:-/tmp/pytorrent-stack-installer}"
|
||||
KEEP_WORK_DIR="${PYTORRENT_KEEP_BOOTSTRAP_DIR:-0}"
|
||||
|
||||
RAW_BASE="${REPO_URL%/}/raw/branch/${REPO_BRANCH}"
|
||||
ARCHIVE_URL="${PYTORRENT_ARCHIVE_URL:-${REPO_URL%/}/archive/${REPO_BRANCH}.tar.gz}"
|
||||
PROJECT_DIR="${WORK_DIR}/src"
|
||||
ARCHIVE_PATH="${WORK_DIR}/pytorrent.tar.gz"
|
||||
|
||||
log() {
|
||||
printf '[pyTorrent stack] %s\n' "$*"
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf '[pyTorrent stack] ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
prepare_downloader() {
|
||||
# Bootstrap needs both a downloader and tar before repository extraction.
|
||||
if command_exists apt-get; then
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends ca-certificates tar curl gzip python3 sudo
|
||||
elif command_exists dnf; then
|
||||
dnf install -y ca-certificates tar curl gzip python3 sudo
|
||||
elif command_exists yum; then
|
||||
yum install -y ca-certificates tar curl gzip python3 sudo
|
||||
fi
|
||||
|
||||
if command_exists curl; then
|
||||
DOWNLOADER="curl"
|
||||
return
|
||||
fi
|
||||
if command_exists wget; then
|
||||
DOWNLOADER="wget"
|
||||
return
|
||||
fi
|
||||
|
||||
if command_exists apt-get; then
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends curl ca-certificates tar gzip python3 sudo
|
||||
DOWNLOADER="curl"
|
||||
return
|
||||
fi
|
||||
if command_exists dnf; then
|
||||
dnf install -y curl ca-certificates tar gzip python3 sudo
|
||||
DOWNLOADER="curl"
|
||||
return
|
||||
fi
|
||||
if command_exists yum; then
|
||||
yum install -y curl ca-certificates tar gzip python3 sudo
|
||||
DOWNLOADER="curl"
|
||||
return
|
||||
fi
|
||||
|
||||
fail "curl or wget is required and no supported package manager was found."
|
||||
}
|
||||
|
||||
download_file() {
|
||||
local url="$1"
|
||||
local destination="$2"
|
||||
if [[ "${DOWNLOADER}" == "curl" ]]; then
|
||||
curl -fL "${url}" -o "${destination}"
|
||||
else
|
||||
wget -O "${destination}" "${url}"
|
||||
fi
|
||||
}
|
||||
|
||||
detect_os_family() {
|
||||
if [[ ! -f /etc/os-release ]]; then
|
||||
fail "Cannot detect OS: /etc/os-release is missing."
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release
|
||||
local os_id="${ID:-}"
|
||||
local os_like="${ID_LIKE:-}"
|
||||
|
||||
case "${os_id} ${os_like}" in
|
||||
*debian*|*ubuntu*)
|
||||
echo "debian"
|
||||
;;
|
||||
*rhel*|*fedora*|*centos*|*rocky*|*almalinux*)
|
||||
echo "rhel"
|
||||
;;
|
||||
*)
|
||||
fail "Unsupported OS: ID=${ID:-unknown}, ID_LIKE=${ID_LIKE:-unknown}."
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if [[ "${KEEP_WORK_DIR}" != "1" ]]; then
|
||||
rm -rf "${WORK_DIR}"
|
||||
else
|
||||
log "Keeping bootstrap directory: ${WORK_DIR}"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
prepare_downloader
|
||||
rm -rf "${WORK_DIR}"
|
||||
mkdir -p "${WORK_DIR}"
|
||||
|
||||
log "Downloading pyTorrent from ${ARCHIVE_URL}"
|
||||
if ! download_file "${ARCHIVE_URL}" "${ARCHIVE_PATH}"; then
|
||||
log "Archive download failed, trying raw stack installer fallback."
|
||||
mkdir -p "${PROJECT_DIR}/scripts/stack_installers"
|
||||
for file in \
|
||||
install_stack_debian_ubuntu.sh \
|
||||
install_stack_rhel.sh \
|
||||
install_pytorrent_rhel.sh \
|
||||
install_rtorrent.py \
|
||||
install_rtorrent_rhel.py \
|
||||
configure_pytorrent_api.py \
|
||||
INSTALL_STACK.md
|
||||
do
|
||||
download_file "${RAW_BASE}/scripts/stack_installers/${file}" "${PROJECT_DIR}/scripts/stack_installers/${file}"
|
||||
done
|
||||
download_file "${RAW_BASE}/scripts/install_debian_ubuntu.sh" "${PROJECT_DIR}/scripts/install_debian_ubuntu.sh"
|
||||
else
|
||||
mkdir -p "${PROJECT_DIR}"
|
||||
tar -xzf "${ARCHIVE_PATH}" -C "${PROJECT_DIR}" --strip-components=1
|
||||
fi
|
||||
|
||||
[[ -d "${PROJECT_DIR}/scripts/stack_installers" ]] || fail "Missing scripts/stack_installers in downloaded repository."
|
||||
|
||||
OS_FAMILY="$(detect_os_family)"
|
||||
case "${OS_FAMILY}" in
|
||||
debian)
|
||||
INSTALLER="${PROJECT_DIR}/scripts/stack_installers/install_stack_debian_ubuntu.sh"
|
||||
;;
|
||||
rhel)
|
||||
INSTALLER="${PROJECT_DIR}/scripts/stack_installers/install_stack_rhel.sh"
|
||||
;;
|
||||
*)
|
||||
fail "Unsupported OS family: ${OS_FAMILY}."
|
||||
;;
|
||||
esac
|
||||
|
||||
chmod +x "${PROJECT_DIR}/scripts/stack_installers/"*.sh || true
|
||||
log "Running ${INSTALLER}"
|
||||
bash "${INSTALLER}"
|
||||
286
scripts/rtorrent_cli.py
Normal file
286
scripts/rtorrent_cli.py
Normal file
@@ -0,0 +1,286 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
rtorrent_cli.py - simple CLI for bulk rTorrent management over XML-RPC/SCGI.
|
||||
|
||||
Default endpoint:
|
||||
scgi://127.0.0.1:5000
|
||||
|
||||
Examples:
|
||||
python3 rtorrent_cli.py ping
|
||||
python3 rtorrent_cli.py list
|
||||
python3 rtorrent_cli.py list --only-stopped --only-complete
|
||||
python3 rtorrent_cli.py show HASH
|
||||
python3 rtorrent_cli.py start HASH
|
||||
python3 rtorrent_cli.py bulk-start --only-stopped --only-complete
|
||||
python3 rtorrent_cli.py bulk-stop --name-regex "ubuntu|debian"
|
||||
python3 rtorrent_cli.py bulk-announce --only-active
|
||||
python3 rtorrent_cli.py bulk-check-hash --only-stopped --name-regex "movie"
|
||||
python3 rtorrent_cli.py dump-methods
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import xmlrpc.client
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Any, Iterable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
DEFAULT_URL = "scgi://127.0.0.1:5000"
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# SCGI XML-RPC transport
|
||||
# ----------------------------
|
||||
|
||||
class SCGITransport(xmlrpc.client.Transport):
|
||||
def __init__(self, host: str, port: int, timeout: int = 15):
|
||||
super().__init__()
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.timeout = timeout
|
||||
|
||||
def request(self, host: str, handler: str, request_body: bytes, verbose: bool = False):
|
||||
body = request_body.encode("utf-8") if isinstance(request_body, str) else request_body
|
||||
|
||||
headers = {
|
||||
"CONTENT_LENGTH": str(len(body)),
|
||||
"SCGI": "1",
|
||||
"REQUEST_METHOD": "POST",
|
||||
"REQUEST_URI": handler or "/RPC2",
|
||||
}
|
||||
|
||||
header_bytes = b""
|
||||
for key, value in headers.items():
|
||||
header_bytes += key.encode("utf-8") + b"\x00" + value.encode("utf-8") + b"\x00"
|
||||
|
||||
packet = str(len(header_bytes)).encode("ascii") + b":" + header_bytes + b"," + body
|
||||
|
||||
with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock:
|
||||
sock.sendall(packet)
|
||||
response = b""
|
||||
while True:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
response += chunk
|
||||
|
||||
# rTorrent over SCGI usually returns raw XML body,
|
||||
# but some proxies may prepend HTTP headers.
|
||||
if b"\r\n\r\n" in response:
|
||||
response = response.split(b"\r\n\r\n", 1)[1]
|
||||
|
||||
return self.parse_response_bytes(response)
|
||||
|
||||
def parse_response_bytes(self, data: bytes):
|
||||
p, u = self.getparser()
|
||||
p.feed(data)
|
||||
p.close()
|
||||
return u.close()
|
||||
|
||||
|
||||
def make_rpc_client(url: str, timeout: int):
|
||||
parsed = urlparse(url)
|
||||
|
||||
if parsed.scheme == "scgi":
|
||||
if not parsed.hostname:
|
||||
raise ValueError("SCGI URL must include a host, e.g. scgi://127.0.0.1:5000")
|
||||
transport = SCGITransport(parsed.hostname, parsed.port or 5000, timeout=timeout)
|
||||
return xmlrpc.client.ServerProxy(
|
||||
"http://rtorrent/RPC2",
|
||||
transport=transport,
|
||||
allow_none=True,
|
||||
)
|
||||
|
||||
return xmlrpc.client.ServerProxy(url, allow_none=True)
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# Helpers
|
||||
# ----------------------------
|
||||
|
||||
@dataclass
|
||||
class Torrent:
|
||||
hash: str
|
||||
name: str
|
||||
state: int
|
||||
active: int
|
||||
complete: int
|
||||
size_bytes: int
|
||||
completed_bytes: int
|
||||
ratio: int
|
||||
down_rate: int
|
||||
up_rate: int
|
||||
message: str
|
||||
|
||||
@property
|
||||
def stopped(self) -> bool:
|
||||
return self.state == 0
|
||||
|
||||
@property
|
||||
def started_or_paused(self) -> bool:
|
||||
return self.state == 1
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return self.active == 1
|
||||
|
||||
@property
|
||||
def is_complete(self) -> bool:
|
||||
return self.complete == 1
|
||||
|
||||
|
||||
def rpc_error(exc: Exception, context: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
payload = {
|
||||
"ok": False,
|
||||
"error_type": exc.__class__.__name__,
|
||||
"error": str(exc),
|
||||
}
|
||||
if context:
|
||||
payload["context"] = context
|
||||
return payload
|
||||
|
||||
|
||||
def print_json(data: Any) -> None:
|
||||
print(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=False))
|
||||
|
||||
|
||||
def call_method(rpc, method: str, *args):
|
||||
return getattr(rpc, method)(*args)
|
||||
|
||||
|
||||
def safe_call(rpc, method: str, *args, context: dict[str, Any] | None = None):
|
||||
try:
|
||||
return True, call_method(rpc, method, *args)
|
||||
except Exception as exc:
|
||||
return False, rpc_error(exc, context=context or {"method": method, "args": args})
|
||||
|
||||
|
||||
def human_bytes(num: int) -> str:
|
||||
value = float(num)
|
||||
for unit in ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]:
|
||||
if abs(value) < 1024:
|
||||
return f"{value:.1f} {unit}"
|
||||
value /= 1024
|
||||
return f"{value:.1f} EiB"
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# rTorrent API
|
||||
# ----------------------------
|
||||
|
||||
def get_torrent_hashes(rpc, view: str = "main") -> list[str]:
|
||||
ok, result = safe_call(rpc, "d.multicall2", "", view, "d.hash=")
|
||||
if not ok:
|
||||
raise RuntimeError(json.dumps(result, ensure_ascii=False))
|
||||
|
||||
hashes: list[str] = []
|
||||
for row in result:
|
||||
if isinstance(row, list) and row:
|
||||
hashes.append(str(row[0]))
|
||||
elif isinstance(row, str):
|
||||
hashes.append(row)
|
||||
return hashes
|
||||
|
||||
|
||||
def list_torrents(rpc, view: str = "main") -> list[Torrent]:
|
||||
methods = [
|
||||
"d.hash=",
|
||||
"d.name=",
|
||||
"d.state=",
|
||||
"d.is_active=",
|
||||
"d.complete=",
|
||||
"d.size_bytes=",
|
||||
"d.completed_bytes=",
|
||||
"d.ratio=",
|
||||
"d.down.rate=",
|
||||
"d.up.rate=",
|
||||
"d.message=",
|
||||
]
|
||||
|
||||
ok, result = safe_call(rpc, "d.multicall2", "", view, *methods)
|
||||
if not ok:
|
||||
raise RuntimeError(json.dumps(result, ensure_ascii=False))
|
||||
|
||||
torrents: list[Torrent] = []
|
||||
for row in result:
|
||||
torrents.append(Torrent(
|
||||
hash=str(row[0]),
|
||||
name=str(row[1]),
|
||||
state=int(row[2]),
|
||||
active=int(row[3]),
|
||||
complete=int(row[4]),
|
||||
size_bytes=int(row[5]),
|
||||
completed_bytes=int(row[6]),
|
||||
ratio=int(row[7]),
|
||||
down_rate=int(row[8]),
|
||||
up_rate=int(row[9]),
|
||||
message=str(row[10]),
|
||||
))
|
||||
return torrents
|
||||
|
||||
|
||||
def get_torrent(rpc, hash_: str) -> Torrent:
|
||||
torrents = list_torrents(rpc)
|
||||
for torrent in torrents:
|
||||
if torrent.hash.lower() == hash_.lower():
|
||||
return torrent
|
||||
raise KeyError(f"Torrent not found: {hash_}")
|
||||
|
||||
|
||||
def filter_torrents(torrents: Iterable[Torrent], args) -> list[Torrent]:
|
||||
result = list(torrents)
|
||||
|
||||
if getattr(args, "only_stopped", False):
|
||||
result = [t for t in result if t.stopped]
|
||||
|
||||
if getattr(args, "only_started", False):
|
||||
result = [t for t in result if t.started_or_paused]
|
||||
|
||||
if getattr(args, "only_active", False):
|
||||
result = [t for t in result if t.is_active]
|
||||
|
||||
if getattr(args, "only_complete", False):
|
||||
result = [t for t in result if t.is_complete]
|
||||
|
||||
if getattr(args, "only_incomplete", False):
|
||||
result = [t for t in result if not t.is_complete]
|
||||
|
||||
if getattr(args, "name_regex", None):
|
||||
pattern = re.compile(args.name_regex, re.IGNORECASE)
|
||||
result = [t for t in result if pattern.search(t.name)]
|
||||
|
||||
if getattr(args, "hash_regex", None):
|
||||
pattern = re.compile(args.hash_regex, re.IGNORECASE)
|
||||
result = [t for t in result if pattern.search(t.hash)]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def torrent_to_dict(t: Torrent) -> dict[str, Any]:
|
||||
data = asdict(t)
|
||||
data["size"] = human_bytes(t.size_bytes)
|
||||
data["completed"] = human_bytes(t.completed_bytes)
|
||||
data["ratio_float"] = round(t.ratio / 1000, 3)
|
||||
return data
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# Commands
|
||||
# ----------------------------
|
||||
|
||||
def cmd_ping(rpc, args) -> int:
|
||||
ok, result = safe_call(rpc, "system.client_version")
|
||||
if ok:
|
||||
print_json({"ok": True, "client_version": result})
|
||||
return 0
|
||||
|
||||
# fallback for older builds
|
||||
ok, result = safe_call(rpc, "system.listMethods")
|
||||
print_json({"ok": ok, "result": result})
|
||||
return 0 if ok else 1
|
||||
198
scripts/stack_installers/INSTALL.md
Normal file
198
scripts/stack_installers/INSTALL.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# pyTorrent stack installer
|
||||
|
||||
This document describes the one-command installer for installing **rTorrent + pyTorrent** from a clean server.
|
||||
|
||||
The installer is split into two layers:
|
||||
|
||||
- `scripts/install_stack.sh` - public bootstrap script intended to be downloaded directly from Git.
|
||||
- `scripts/stack_installers/` - OS-specific installers and helper scripts used by the bootstrap script.
|
||||
|
||||
## Quick install
|
||||
|
||||
Run as root or through `sudo`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh | sudo bash
|
||||
```
|
||||
|
||||
The bootstrap script downloads the current pyTorrent repository, detects the operating system family, and runs the matching installer:
|
||||
|
||||
- Debian / Ubuntu: `scripts/stack_installers/install_stack_debian_ubuntu.sh`
|
||||
- RHEL-compatible systems: `scripts/stack_installers/install_stack_rhel.sh`
|
||||
|
||||
Supported RHEL-compatible systems include RHEL, Rocky Linux, AlmaLinux, CentOS Stream, and Fedora-like systems where `dnf` or `yum` is available.
|
||||
|
||||
## What gets installed
|
||||
|
||||
Default installation includes:
|
||||
|
||||
- rTorrent `v0.16.11`
|
||||
- libtorrent `v0.16.11`
|
||||
- minimal rTorrent build without c-ares/custom curl
|
||||
- rTorrent system user: `rtorrent`
|
||||
- rTorrent SCGI endpoint: `scgi://127.0.0.1:5000`
|
||||
- rTorrent incoming BitTorrent port: `51300`
|
||||
- pyTorrent application directory: `/opt/pytorrent`
|
||||
- pyTorrent HTTP port: `8090`
|
||||
- pyTorrent profile configured through the HTTP API
|
||||
|
||||
The installer creates or updates a pyTorrent rTorrent profile through API after both services are installed.
|
||||
|
||||
## Recommended usage with overrides
|
||||
|
||||
Environment variables must be passed to the `sudo bash` process.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
||||
| sudo PYTORRENT_PORT=8091 RTORRENT_SCGI_PORT=5001 bash
|
||||
```
|
||||
|
||||
Another example with a custom profile name:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
||||
| sudo PYTORRENT_PROFILE_NAME="Local rTorrent" PYTORRENT_PORT=8090 bash
|
||||
```
|
||||
|
||||
## Bootstrap parameters
|
||||
|
||||
These variables are used by `scripts/install_stack.sh`.
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `PYTORRENT_REPO_URL` | `https://git.linuxiarz.pl/gru/pyTorrent` | Git repository base URL. |
|
||||
| `PYTORRENT_REPO_BRANCH` | `master` | Branch used to download the repository archive. |
|
||||
| `PYTORRENT_ARCHIVE_URL` | derived from repo URL and branch | Custom repository archive URL. |
|
||||
| `PYTORRENT_BOOTSTRAP_DIR` | `/tmp/pytorrent-stack-installer` | Temporary directory used by the bootstrap script. |
|
||||
| `PYTORRENT_KEEP_BOOTSTRAP_DIR` | `0` | Set to `1` to keep the temporary directory after installation. |
|
||||
|
||||
Example using a different branch:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
||||
| sudo PYTORRENT_REPO_BRANCH=develop bash
|
||||
```
|
||||
|
||||
## rTorrent parameters
|
||||
|
||||
These variables are used by both stack installers.
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `RTORRENT_USER` | `rtorrent` | System user used to run rTorrent. |
|
||||
| `RTORRENT_HOME` | `/home/${RTORRENT_USER}` | Home directory for the rTorrent user. |
|
||||
| `RTORRENT_BASE_DIR` | `/opt/rtorrent_build` | Build and install directory for xmlrpc-c, libtorrent and rTorrent. |
|
||||
| `RTORRENT_SCGI_PORT` | `5000` | Local SCGI port for rTorrent XMLRPC/SCGI. |
|
||||
| `RTORRENT_TORRENT_PORT` | `51300` | Incoming BitTorrent listen port. |
|
||||
| `RTORRENT_REF` | `v0.16.11` | rTorrent Git tag, branch, or commit. |
|
||||
| `LIBTORRENT_REF` | `v0.16.11` | libtorrent Git tag, branch, or commit. |
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
||||
| sudo RTORRENT_USER=rtorrent RTORRENT_SCGI_PORT=5001 RTORRENT_TORRENT_PORT=51400 bash
|
||||
```
|
||||
|
||||
## pyTorrent parameters
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `PYTORRENT_APP_DIR` | `/opt/pytorrent` | pyTorrent installation directory. |
|
||||
| `PYTORRENT_PORT` | `8090` | HTTP port used by the pyTorrent service. |
|
||||
| `PYTORRENT_BASE_URL` | `http://127.0.0.1:${PYTORRENT_PORT}` | Base URL used by the API configurator. |
|
||||
| `PYTORRENT_PROFILE_NAME` | `Local rTorrent` | Name of the rTorrent profile created in pyTorrent. |
|
||||
| `PYTORRENT_API_TOKEN` | empty | Bearer token used when pyTorrent API authentication is enabled. |
|
||||
| `PYTORRENT_SERVICE_NAME` | `pytorrent` | systemd service name for pyTorrent. |
|
||||
| `PYTORRENT_RTORRENT_SCGI_URL` | `scgi://127.0.0.1:${RTORRENT_SCGI_PORT}` | SCGI URL saved in the pyTorrent rTorrent profile. |
|
||||
|
||||
Example with API token:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
||||
| sudo PYTORRENT_API_TOKEN="pt_xxx" bash
|
||||
```
|
||||
|
||||
## API configurator parameters
|
||||
|
||||
The API configurator can be run manually:
|
||||
|
||||
```bash
|
||||
/opt/pytorrent/venv/bin/python /opt/pytorrent/scripts/stack_installers/configure_pytorrent_api.py \
|
||||
--base-url http://127.0.0.1:8090 \
|
||||
--profile-name "Local rTorrent" \
|
||||
--scgi-url scgi://127.0.0.1:5000
|
||||
```
|
||||
|
||||
CLI options:
|
||||
|
||||
| Option | Environment variable | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `--base-url` | `PYTORRENT_BASE_URL` | `http://127.0.0.1:8090` | pyTorrent API base URL. |
|
||||
| `--api-token` | `PYTORRENT_API_TOKEN` | empty | Bearer token for authenticated API calls. |
|
||||
| `--profile-name` | `PYTORRENT_RTORRENT_PROFILE_NAME` | `Local rTorrent` | Profile name to create or update. |
|
||||
| `--scgi-url` | `PYTORRENT_RTORRENT_SCGI_URL` | `scgi://127.0.0.1:5000` | rTorrent SCGI URL. |
|
||||
| `--timeout` | `PYTORRENT_RTORRENT_TIMEOUT` | `10` | rTorrent request timeout in seconds. |
|
||||
| `--wait` | `PYTORRENT_API_WAIT_SECONDS` | `90` | Time to wait for the pyTorrent API to become available. |
|
||||
| `--remote` | `PYTORRENT_RTORRENT_REMOTE` | `0` | Mark profile as remote. Accepts `1`, `true`, `yes`, `on`. |
|
||||
|
||||
## Local installation without bootstrap
|
||||
|
||||
If the repository is already cloned:
|
||||
|
||||
Debian / Ubuntu:
|
||||
|
||||
```bash
|
||||
sudo bash scripts/stack_installers/install_stack_debian_ubuntu.sh
|
||||
```
|
||||
|
||||
RHEL-compatible systems:
|
||||
|
||||
```bash
|
||||
sudo bash scripts/stack_installers/install_stack_rhel.sh
|
||||
```
|
||||
|
||||
## Installed service hints
|
||||
|
||||
Check services:
|
||||
|
||||
```bash
|
||||
systemctl status pytorrent
|
||||
systemctl status rtorrent@rtorrent.service
|
||||
```
|
||||
|
||||
Check logs:
|
||||
|
||||
```bash
|
||||
tail -f /data/logs/app.log /data/logs/error.log
|
||||
journalctl -u pytorrent -f
|
||||
journalctl -u rtorrent@rtorrent.service -f
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The default rTorrent build is intentionally minimal.
|
||||
- c-ares and custom curl are not enabled by the stack installer defaults.
|
||||
- The rTorrent installer overwrites the generated `.rtorrent.rc` because the stack installer passes `--force-config`.
|
||||
- pyTorrent is configured through the HTTP API after the service starts.
|
||||
- If API authentication is enabled before profile configuration, pass `PYTORRENT_API_TOKEN`.
|
||||
|
||||
|
||||
## Build logs and troubleshooting
|
||||
|
||||
The stack installer writes quiet build output to `/var/log/pytorrent-installer` by default.
|
||||
Override it with:
|
||||
|
||||
```bash
|
||||
PYTORRENT_STACK_LOG_DIR=/tmp/pytorrent-build-logs
|
||||
```
|
||||
|
||||
For full command output during rTorrent/libtorrent compilation, run with:
|
||||
|
||||
```bash
|
||||
PYTORRENT_DEBUG_INSTALL=1
|
||||
```
|
||||
|
||||
On RHEL-compatible systems the installer also tries to enable CRB/PowerTools and installs `libcurl-devel`, `redhat-rpm-config`, `patch`, `diffutils`, `findutils`, `file`, and `libstdc++-devel`, because minimal Alma/Rocky images often do not include enough build tooling.
|
||||
106
scripts/stack_installers/configure_pytorrent_api.py
Executable file
106
scripts/stack_installers/configure_pytorrent_api.py
Executable file
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Configure pyTorrent through its HTTP API after rTorrent is installed."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def _request(base_url: str, method: str, path: str, payload: dict | None = None, token: str | None = None, timeout: int = 10) -> dict:
|
||||
url = base_url.rstrip("/") + path
|
||||
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
||||
headers = {"Accept": "application/json"}
|
||||
if payload is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
req = urllib.request.Request(url, data=data, method=method.upper(), headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8", "replace")
|
||||
return json.loads(raw or "{}")
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read().decode("utf-8", "replace")
|
||||
raise RuntimeError(f"API {method} {path} failed with HTTP {exc.code}: {raw}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise RuntimeError(f"API {method} {path} failed: {exc.reason}") from exc
|
||||
|
||||
|
||||
def _wait_for_api(base_url: str, token: str | None, seconds: int) -> None:
|
||||
deadline = time.time() + seconds
|
||||
last_error = None
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
_request(base_url, "GET", "/api/profiles", token=token, timeout=5)
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001 - installation helper should keep retrying.
|
||||
last_error = exc
|
||||
time.sleep(2)
|
||||
raise RuntimeError(f"pyTorrent API is not ready after {seconds}s at {base_url}: {last_error}. Check PYTORRENT_PORT in .env and systemctl status pytorrent.")
|
||||
|
||||
|
||||
def _find_profile(profiles: list[dict], name: str, scgi_url: str) -> dict | None:
|
||||
for profile in profiles:
|
||||
if str(profile.get("name") or "") == name:
|
||||
return profile
|
||||
for profile in profiles:
|
||||
if str(profile.get("scgi_url") or "") == scgi_url:
|
||||
return profile
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Create/update and activate a pyTorrent rTorrent profile through the HTTP API.")
|
||||
parser.add_argument("--base-url", default=os.getenv("PYTORRENT_BASE_URL", "http://127.0.0.1:8090"))
|
||||
parser.add_argument("--api-token", default=os.getenv("PYTORRENT_API_TOKEN", ""), help="Bearer token when pyTorrent auth is enabled.")
|
||||
parser.add_argument("--profile-name", default=os.getenv("PYTORRENT_RTORRENT_PROFILE_NAME", "Local rTorrent"))
|
||||
parser.add_argument("--scgi-url", default=os.getenv("PYTORRENT_RTORRENT_SCGI_URL", "scgi://127.0.0.1:5000"))
|
||||
parser.add_argument("--timeout", type=int, default=int(os.getenv("PYTORRENT_RTORRENT_TIMEOUT", "10")))
|
||||
parser.add_argument("--wait", type=int, default=int(os.getenv("PYTORRENT_API_WAIT_SECONDS", "90")))
|
||||
parser.add_argument("--remote", action="store_true", default=os.getenv("PYTORRENT_RTORRENT_REMOTE", "0").lower() in {"1", "true", "yes", "on"})
|
||||
args = parser.parse_args()
|
||||
|
||||
token = args.api_token.strip() or None
|
||||
_wait_for_api(args.base_url, token, args.wait)
|
||||
current = _request(args.base_url, "GET", "/api/profiles", token=token)
|
||||
profiles = current.get("profiles") or []
|
||||
payload = {
|
||||
"name": args.profile_name,
|
||||
"scgi_url": args.scgi_url,
|
||||
"is_default": True,
|
||||
"timeout_seconds": args.timeout,
|
||||
"max_parallel_jobs": 5,
|
||||
"light_parallel_jobs": 4,
|
||||
"light_job_timeout_seconds": 300,
|
||||
"heavy_job_timeout_seconds": 7200,
|
||||
"pending_job_timeout_seconds": 900,
|
||||
"is_remote": bool(args.remote),
|
||||
}
|
||||
existing = _find_profile(profiles, args.profile_name, args.scgi_url)
|
||||
if existing:
|
||||
profile_id = int(existing["id"])
|
||||
result = _request(args.base_url, "PUT", f"/api/profiles/{profile_id}", payload, token=token)
|
||||
action = "updated"
|
||||
else:
|
||||
result = _request(args.base_url, "POST", "/api/profiles", payload, token=token)
|
||||
profile_id = int((result.get("profile") or {}).get("id") or 0)
|
||||
action = "created"
|
||||
if not profile_id:
|
||||
raise RuntimeError(f"Profile {action}, but API response did not include an id: {result}")
|
||||
_request(args.base_url, "POST", f"/api/profiles/{profile_id}/activate", token=token)
|
||||
test = _request(args.base_url, "GET", f"/api/profiles/{profile_id}/diagnostics", token=token)
|
||||
print(json.dumps({"ok": True, "action": action, "profile_id": profile_id, "diagnostics": test.get("diagnostics")}, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as exc: # noqa: BLE001 - user-facing installer output.
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
135
scripts/stack_installers/install_pytorrent_rhel.sh
Executable file
135
scripts/stack_installers/install_pytorrent_rhel.sh
Executable file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
APP_USER="${PYTORRENT_USER:-pytorrent}"
|
||||
APP_DIR="${PYTORRENT_APP_DIR:-/opt/pytorrent}"
|
||||
SERVICE_NAME="${PYTORRENT_SERVICE_NAME:-pytorrent}"
|
||||
PYTHON_BIN="${PYTHON_BIN:-python3}"
|
||||
PYTORRENT_HOST_VALUE="${PYTORRENT_HOST:-0.0.0.0}"
|
||||
PYTORRENT_PORT_VALUE="${PYTORRENT_PORT:-8090}"
|
||||
PYTORRENT_LOG_DIR_VALUE="${PYTORRENT_LOG_DIR:-/data/logs}"
|
||||
PYTORRENT_LOG_RETENTION_HOURS_VALUE="${PYTORRENT_LOG_RETENTION_HOURS:-24}"
|
||||
PKG_MANAGER="$(command -v dnf || command -v yum || true)"
|
||||
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
echo "Run as root: sudo $0" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "${PKG_MANAGER}" ]]; then
|
||||
echo "dnf or yum is required." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"${PKG_MANAGER}" install -y \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
rsync \
|
||||
gcc \
|
||||
python3 \
|
||||
python3-devel \
|
||||
python3-pip
|
||||
|
||||
if ! id -u "${APP_USER}" >/dev/null 2>&1; then
|
||||
useradd \
|
||||
--system \
|
||||
--create-home \
|
||||
--home-dir "/var/lib/${APP_USER}" \
|
||||
--shell /sbin/nologin \
|
||||
"${APP_USER}"
|
||||
fi
|
||||
|
||||
mkdir -p "${APP_DIR}"
|
||||
|
||||
rsync -a --delete \
|
||||
--exclude '.git' \
|
||||
--exclude 'venv' \
|
||||
--exclude '__pycache__' \
|
||||
--exclude '*.pyc' \
|
||||
./ "${APP_DIR}/"
|
||||
|
||||
cd "${APP_DIR}"
|
||||
|
||||
"${PYTHON_BIN}" -m venv venv
|
||||
venv/bin/pip install --upgrade pip wheel
|
||||
venv/bin/pip install -r requirements.txt
|
||||
|
||||
mkdir -p data instance logs data/logs
|
||||
chown -R "${APP_USER}:${APP_USER}" "${APP_DIR}" "/var/lib/${APP_USER}"
|
||||
|
||||
|
||||
upsert_env_value() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
local file="${3:-.env}"
|
||||
touch "${file}"
|
||||
if grep -qE "^${key}=" "${file}"; then
|
||||
sed -i "s|^${key}=.*|${key}=${value}|" "${file}"
|
||||
else
|
||||
printf '%s=%s\n' "${key}" "${value}" >> "${file}"
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ ! -f .env && -f .env.example ]]; then
|
||||
cp .env.example .env
|
||||
python3 - .env <<'PY'
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import sys
|
||||
path = Path(sys.argv[1])
|
||||
text = path.read_text()
|
||||
if "PYTORRENT_SECRET_KEY=change-me" in text:
|
||||
text = text.replace("PYTORRENT_SECRET_KEY=change-me", "PYTORRENT_SECRET_KEY=" + secrets.token_urlsafe(48))
|
||||
path.write_text(text)
|
||||
PY
|
||||
chown "${APP_USER}:${APP_USER}" .env
|
||||
fi
|
||||
|
||||
# Keep systemd service config aligned with installer overrides.
|
||||
upsert_env_value "PYTORRENT_HOST" "${PYTORRENT_HOST_VALUE}" .env
|
||||
upsert_env_value "PYTORRENT_PORT" "${PYTORRENT_PORT_VALUE}" .env
|
||||
upsert_env_value "PYTORRENT_LOG_DIR" "${PYTORRENT_LOG_DIR_VALUE}" .env
|
||||
upsert_env_value "PYTORRENT_LOG_RETENTION_HOURS" "${PYTORRENT_LOG_RETENTION_HOURS_VALUE}" .env
|
||||
mkdir -p "${PYTORRENT_LOG_DIR_VALUE}"
|
||||
chown -R "${APP_USER}:${APP_USER}" "${PYTORRENT_LOG_DIR_VALUE}" || true
|
||||
chown "${APP_USER}:${APP_USER}" .env
|
||||
|
||||
if [[ -f scripts/download_frontend_libs.py ]]; then
|
||||
sudo -u "${APP_USER}" "${APP_DIR}/venv/bin/python" scripts/download_frontend_libs.py || true
|
||||
fi
|
||||
|
||||
if [[ -f scripts/download_geoip.sh ]]; then
|
||||
sudo -u "${APP_USER}" bash scripts/download_geoip.sh || true
|
||||
fi
|
||||
|
||||
cat > "/etc/systemd/system/${SERVICE_NAME}.service" <<SERVICE
|
||||
[Unit]
|
||||
Description=pyTorrent Web UI
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${APP_USER}
|
||||
Group=${APP_USER}
|
||||
WorkingDirectory=${APP_DIR}
|
||||
Environment="PYTHONUNBUFFERED=1"
|
||||
EnvironmentFile=${APP_DIR}/.env
|
||||
ExecStart=${APP_DIR}/venv/bin/gunicorn -c ${APP_DIR}/gunicorn.conf.py --worker-class gthread --workers 1 --threads 32 --bind \${PYTORRENT_HOST}:\${PYTORRENT_PORT} --access-logfile - --error-logfile - wsgi:app
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
KillSignal=SIGINT
|
||||
TimeoutStopSec=20
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
SERVICE
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable "${SERVICE_NAME}"
|
||||
systemctl restart "${SERVICE_NAME}"
|
||||
systemctl status "${SERVICE_NAME}" --no-pager --lines=20 || true
|
||||
|
||||
echo "pyTorrent installed in ${APP_DIR}. Service: ${SERVICE_NAME}."
|
||||
891
scripts/stack_installers/install_rtorrent.py
Executable file
891
scripts/stack_installers/install_rtorrent.py
Executable file
@@ -0,0 +1,891 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import itertools
|
||||
import os
|
||||
import pwd
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_USER = "rtorrent"
|
||||
DEFAULT_GROUP = "rtorrent"
|
||||
DEFAULT_HOME = "/home/rtorrent"
|
||||
DEFAULT_BASE_DIR = "/opt/rtorrent_build"
|
||||
DEFAULT_LIBTORRENT_REF = "v0.16.11"
|
||||
DEFAULT_RTORRENT_REF = "v0.16.11"
|
||||
DEFAULT_XMLRPC_REF = "latest-stable"
|
||||
DEFAULT_CARES_REF = "1.34.6"
|
||||
DEFAULT_CURL_REF = "8.19.0"
|
||||
DEFAULT_SERVICE_PATH = "/etc/systemd/system/rtorrent@.service"
|
||||
DEFAULT_SCGI_PORT = 5000
|
||||
DEFAULT_TORRENT_PORT = 51300
|
||||
|
||||
|
||||
class InstallError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Spinner:
|
||||
FRAMES = ["|", "/", "-", "\\"]
|
||||
|
||||
def __init__(self, message, enabled=True):
|
||||
self.message = message
|
||||
self.enabled = enabled and sys.stdout.isatty()
|
||||
self._stop = threading.Event()
|
||||
self._thread = None
|
||||
self._start = None
|
||||
|
||||
def _run(self):
|
||||
for frame in itertools.cycle(self.FRAMES):
|
||||
if self._stop.is_set():
|
||||
break
|
||||
elapsed = time.time() - self._start
|
||||
sys.stdout.write(f"\r[ {frame} ] {self.message} ({elapsed:.1f}s)")
|
||||
sys.stdout.flush()
|
||||
time.sleep(0.12)
|
||||
|
||||
def __enter__(self):
|
||||
self._start = time.time()
|
||||
if self.enabled:
|
||||
self._thread = threading.Thread(target=self._run, daemon=True)
|
||||
self._thread.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
elapsed = time.time() - self._start
|
||||
if self.enabled:
|
||||
self._stop.set()
|
||||
self._thread.join(timeout=0.5)
|
||||
status = "ERR" if exc else "OK "
|
||||
sys.stdout.write(f"\r[ {status} ] {self.message} ({elapsed:.1f}s)\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def build_log_dir():
|
||||
path = Path(os.environ.get("PYTORRENT_STACK_LOG_DIR", "/var/log/pytorrent-installer"))
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def tail_file(path, lines=80):
|
||||
try:
|
||||
data = Path(path).read_text(errors="replace").splitlines()
|
||||
except OSError:
|
||||
return ""
|
||||
return "\n".join(data[-lines:])
|
||||
|
||||
|
||||
def run(cmd, *, cwd=None, env=None, check=True, debug=False, capture_output=False, log_name=None):
|
||||
if debug:
|
||||
print(f"\n>>> {' '.join(cmd)}")
|
||||
log_path = None
|
||||
log_handle = None
|
||||
if log_name and not capture_output and not debug:
|
||||
safe_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", log_name).strip("_") or "command"
|
||||
log_path = build_log_dir() / f"{safe_name}.log"
|
||||
log_handle = open(log_path, "a", encoding="utf-8")
|
||||
log_handle.write(f"\n>>> {' '.join(cmd)}\n")
|
||||
log_handle.flush()
|
||||
try:
|
||||
stdout = subprocess.PIPE if capture_output else (None if debug else (log_handle or subprocess.DEVNULL))
|
||||
stderr = subprocess.PIPE if capture_output else (None if debug else (subprocess.STDOUT if log_handle else subprocess.DEVNULL))
|
||||
result = subprocess.run(cmd, cwd=cwd, env=env, check=False, text=True, stdout=stdout, stderr=stderr)
|
||||
finally:
|
||||
if log_handle:
|
||||
log_handle.close()
|
||||
if check and result.returncode != 0:
|
||||
stderr_text = ""
|
||||
if capture_output and result.stderr:
|
||||
stderr_text = f"\n{result.stderr.strip()}"
|
||||
if log_path:
|
||||
stderr_text += f"\nBuild log: {log_path}\n--- last log lines ---\n{tail_file(log_path)}"
|
||||
raise InstallError(f"Command failed with exit code {result.returncode}: {' '.join(cmd)}{stderr_text}")
|
||||
return result
|
||||
|
||||
|
||||
def capture(cmd, **kwargs):
|
||||
result = run(cmd, capture_output=True, **kwargs)
|
||||
out = (result.stdout or "").strip()
|
||||
err = (result.stderr or "").strip()
|
||||
return out if out else err
|
||||
|
||||
|
||||
def require_root():
|
||||
if os.geteuid() != 0:
|
||||
raise InstallError("This script must be run as root (use sudo).")
|
||||
|
||||
|
||||
def read_os_release():
|
||||
os_release = Path("/etc/os-release")
|
||||
if not os_release.exists():
|
||||
raise InstallError("Cannot detect operating system: /etc/os-release is missing.")
|
||||
|
||||
data = {}
|
||||
for line in os_release.read_text().splitlines():
|
||||
if "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
data[k] = v.strip().strip('"')
|
||||
return data
|
||||
|
||||
|
||||
def is_ubuntu_2604():
|
||||
data = read_os_release()
|
||||
return data.get("ID", "").lower() == "ubuntu" and data.get("VERSION_ID", "") == "26.04"
|
||||
|
||||
|
||||
def detect_debian():
|
||||
data = read_os_release()
|
||||
|
||||
distro_id = data.get("ID", "").lower()
|
||||
distro_like = data.get("ID_LIKE", "").lower()
|
||||
if distro_id != "debian" and "debian" not in distro_like:
|
||||
raise InstallError(
|
||||
f"Unsupported distribution: ID={data.get('ID', 'unknown')}, "
|
||||
f"ID_LIKE={data.get('ID_LIKE', 'unknown')}. This installer currently supports Debian only."
|
||||
)
|
||||
|
||||
print(f"Detected Debian-compatible system: {data.get('PRETTY_NAME', distro_id)}")
|
||||
|
||||
|
||||
def prompt_yes_no(question, default=True, assume_yes=False):
|
||||
if assume_yes:
|
||||
print(f"{question} [{'Y/n' if default else 'y/N'}] -> auto-yes")
|
||||
return True
|
||||
|
||||
suffix = "[Y/n]" if default else "[y/N]"
|
||||
while True:
|
||||
reply = input(f"{question} {suffix} ").strip().lower()
|
||||
if not reply:
|
||||
return default
|
||||
if reply in {"y", "yes"}:
|
||||
return True
|
||||
if reply in {"n", "no"}:
|
||||
return False
|
||||
print("Please answer yes or no.")
|
||||
|
||||
|
||||
def parse_version(version):
|
||||
parts = [int(x) for x in re.findall(r"\d+", version)]
|
||||
return tuple(parts[:3]) if parts else (0,)
|
||||
|
||||
|
||||
def ensure_packages(packages, *, debug=False):
|
||||
print("Updating APT metadata...")
|
||||
run(["apt-get", "update"], debug=debug)
|
||||
print("Installing build and runtime dependencies...")
|
||||
run(["apt-get", "install", "-y", *packages], debug=debug, log_name="apt_install_rtorrent_deps")
|
||||
|
||||
|
||||
def ensure_dir(path, owner=None, group=None, mode=None):
|
||||
Path(path).mkdir(parents=True, exist_ok=True)
|
||||
if owner is not None or group is not None:
|
||||
shutil.chown(path, user=owner, group=group)
|
||||
if mode is not None:
|
||||
os.chmod(path, mode)
|
||||
|
||||
|
||||
def create_system_user(user, group, home, assume_yes=False, debug=False):
|
||||
try:
|
||||
pwd.getpwnam(user)
|
||||
print(f"User '{user}' already exists.")
|
||||
except KeyError:
|
||||
if not prompt_yes_no(f"Create system user '{user}' with home '{home}'?", default=True, assume_yes=assume_yes):
|
||||
raise InstallError("User creation declined.")
|
||||
run(["groupadd", "--system", group], check=False, debug=debug)
|
||||
run([
|
||||
"useradd",
|
||||
"--system",
|
||||
"--home-dir", home,
|
||||
"--create-home",
|
||||
"--shell", "/usr/sbin/nologin",
|
||||
"--gid", group,
|
||||
user,
|
||||
], debug=debug)
|
||||
|
||||
|
||||
def clone_or_update_repo(repo_url, repo_dir, ref, *, debug=False):
|
||||
repo_dir = Path(repo_dir)
|
||||
if not repo_dir.exists():
|
||||
with Spinner(f"Cloning {repo_dir.name}", enabled=not debug):
|
||||
run(["git", "clone", repo_url, str(repo_dir)], debug=debug)
|
||||
else:
|
||||
print(f"Repository already exists: {repo_dir}")
|
||||
with Spinner(f"Checking out {repo_dir.name} -> {ref}", enabled=not debug):
|
||||
run(["git", "fetch", "--all", "--tags"], cwd=str(repo_dir), debug=debug)
|
||||
run(["git", "checkout", ref], cwd=str(repo_dir), debug=debug)
|
||||
run(["git", "pull", "--ff-only"], cwd=str(repo_dir), check=False, debug=debug)
|
||||
|
||||
|
||||
def download_file(url, destination, *, debug=False):
|
||||
run(["curl", "-fL", url, "-o", str(destination)], debug=debug)
|
||||
|
||||
|
||||
def extract_tarball(tarball, destination, *, debug=False):
|
||||
if destination.exists():
|
||||
shutil.rmtree(destination)
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
run(["tar", "-xzf", str(tarball), "-C", str(destination), "--strip-components=1"], debug=debug)
|
||||
|
||||
|
||||
def find_xmlrpc_config(base_dir, preferred_install=None):
|
||||
candidates = []
|
||||
|
||||
if preferred_install is not None:
|
||||
preferred = Path(preferred_install) / "bin" / "xmlrpc-c-config"
|
||||
if preferred.exists():
|
||||
candidates.append(preferred.resolve())
|
||||
|
||||
root = Path(base_dir)
|
||||
if root.exists():
|
||||
for match in root.rglob("xmlrpc-c-config"):
|
||||
if match.is_file():
|
||||
candidates.append(match.resolve())
|
||||
|
||||
unique = []
|
||||
seen = set()
|
||||
for candidate in candidates:
|
||||
if candidate not in seen:
|
||||
seen.add(candidate)
|
||||
unique.append(candidate)
|
||||
|
||||
if preferred_install is not None:
|
||||
preferred_prefix = str(Path(preferred_install).resolve())
|
||||
for candidate in unique:
|
||||
if str(candidate).startswith(preferred_prefix):
|
||||
return candidate
|
||||
|
||||
return unique[0] if unique else None
|
||||
|
||||
|
||||
def verify_xmlrpc_environment(xmlrpc_config_path, *, debug=False):
|
||||
tool = Path(xmlrpc_config_path)
|
||||
if not tool.exists():
|
||||
raise InstallError(f"xmlrpc-c-config was not found: {tool}")
|
||||
version = capture([str(tool), "--version"], check=True, debug=debug)
|
||||
if parse_version(version) < (1, 11):
|
||||
raise InstallError(f"xmlrpc-c version is too old: {version}. Version 1.11 or newer is required.")
|
||||
print(f"Detected xmlrpc-c version: {version} ({tool})")
|
||||
return version
|
||||
|
||||
|
||||
def build_env(*prefixes, extra_env=None):
|
||||
env = os.environ.copy()
|
||||
include_dirs = []
|
||||
lib_dirs = []
|
||||
pkg_dirs = []
|
||||
bin_dirs = []
|
||||
|
||||
for prefix in prefixes:
|
||||
if not prefix:
|
||||
continue
|
||||
prefix = str(prefix)
|
||||
include_dirs.append(f"-I{prefix}/include")
|
||||
lib_dirs.append(f"-L{prefix}/lib")
|
||||
pkg_dirs.append(f"{prefix}/lib/pkgconfig")
|
||||
bin_dirs.append(f"{prefix}/bin")
|
||||
|
||||
if include_dirs:
|
||||
env["CPPFLAGS"] = " ".join(include_dirs + [env.get("CPPFLAGS", "")]).strip()
|
||||
env["CFLAGS"] = " ".join(include_dirs + [env.get("CFLAGS", "")]).strip()
|
||||
|
||||
if lib_dirs:
|
||||
rpaths = [f"-Wl,-rpath,{d[2:]}" for d in lib_dirs]
|
||||
env["LDFLAGS"] = " ".join(lib_dirs + rpaths + [env.get("LDFLAGS", "")]).strip()
|
||||
|
||||
if pkg_dirs:
|
||||
env["PKG_CONFIG_PATH"] = ":".join(pkg_dirs + ([env.get("PKG_CONFIG_PATH")] if env.get("PKG_CONFIG_PATH") else []))
|
||||
|
||||
if bin_dirs:
|
||||
env["PATH"] = ":".join(bin_dirs + [env.get("PATH", "")])
|
||||
|
||||
if extra_env:
|
||||
env.update(extra_env)
|
||||
|
||||
return env
|
||||
|
||||
|
||||
def build_xmlrpc_c(base_dir, xmlrpc_ref, *, debug=False):
|
||||
source_root = Path(base_dir) / "xmlrpc-c-src"
|
||||
install_dir = Path(base_dir) / "xmlrpc-c_install"
|
||||
build_root = Path(base_dir) / "_sources"
|
||||
tarball = build_root / "xmlrpc-c.tar.gz"
|
||||
|
||||
existing_config = find_xmlrpc_config(base_dir, install_dir)
|
||||
if existing_config and str(existing_config).startswith(str(install_dir.resolve())):
|
||||
print(f"Reusing existing xmlrpc-c installation: {existing_config}")
|
||||
version = verify_xmlrpc_environment(existing_config, debug=debug)
|
||||
return install_dir, version
|
||||
|
||||
ensure_dir(build_root)
|
||||
|
||||
if xmlrpc_ref == "latest-stable":
|
||||
url = "https://sourceforge.net/projects/xmlrpc-c/files/latest/download"
|
||||
elif re.match(r"^\d+\.\d+\.\d+$", xmlrpc_ref):
|
||||
url = (
|
||||
"https://downloads.sourceforge.net/project/xmlrpc-c/Xmlrpc-c%20Super%20Stable/"
|
||||
f"{xmlrpc_ref}/xmlrpc-c-{xmlrpc_ref}.tgz"
|
||||
)
|
||||
else:
|
||||
url = xmlrpc_ref
|
||||
|
||||
with Spinner("Downloading xmlrpc-c", enabled=not debug):
|
||||
download_file(url, tarball, debug=debug)
|
||||
extract_tarball(tarball, source_root, debug=debug)
|
||||
|
||||
xmlrpc_env = os.environ.copy()
|
||||
# Ubuntu 26.04 defaults to a newer C standard where bool/true/false are keywords.
|
||||
# Older xmlrpc-c releases still define them manually, so pin only this build to GNU17.
|
||||
if is_ubuntu_2604():
|
||||
xmlrpc_env["CFLAGS"] = f"-std=gnu17 {xmlrpc_env.get('CFLAGS', '')}".strip()
|
||||
print("Detected Ubuntu 26.04; using CFLAGS=-std=gnu17 for xmlrpc-c only.")
|
||||
|
||||
with Spinner("Configuring xmlrpc-c", enabled=not debug):
|
||||
run(["./configure", f"--prefix={install_dir}"], cwd=str(source_root), env=xmlrpc_env, debug=debug)
|
||||
with Spinner("Building xmlrpc-c", enabled=not debug):
|
||||
run(["make", "-j", str(os.cpu_count() or 1)], cwd=str(source_root), env=xmlrpc_env, debug=debug, log_name=f"make_{Path(source_root).name}")
|
||||
with Spinner("Installing xmlrpc-c", enabled=not debug):
|
||||
run(["make", "install"], cwd=str(source_root), env=xmlrpc_env, debug=debug, log_name=f"make_install_{Path(source_root).name}")
|
||||
|
||||
xmlrpc_config = find_xmlrpc_config(base_dir, install_dir)
|
||||
if not xmlrpc_config or not str(xmlrpc_config).startswith(str(install_dir.resolve())):
|
||||
raise InstallError(f"Custom xmlrpc-c build finished, but xmlrpc-c-config was not found under {install_dir}.")
|
||||
version = verify_xmlrpc_environment(xmlrpc_config, debug=debug)
|
||||
return install_dir, version
|
||||
|
||||
|
||||
def build_cares(base_dir, cares_version, *, debug=False):
|
||||
source_root = Path(base_dir) / "c-ares-src"
|
||||
install_dir = Path(base_dir) / "c-ares_install"
|
||||
build_root = Path(base_dir) / "_sources"
|
||||
tarball = build_root / f"c-ares-{cares_version}.tar.gz"
|
||||
url = f"https://github.com/c-ares/c-ares/releases/download/v{cares_version}/c-ares-{cares_version}.tar.gz"
|
||||
|
||||
ensure_dir(build_root)
|
||||
with Spinner("Downloading c-ares", enabled=not debug):
|
||||
download_file(url, tarball, debug=debug)
|
||||
extract_tarball(tarball, source_root, debug=debug)
|
||||
with Spinner("Configuring c-ares", enabled=not debug):
|
||||
run([
|
||||
"cmake",
|
||||
"-S", str(source_root),
|
||||
"-B", str(source_root / "build"),
|
||||
f"-DCMAKE_INSTALL_PREFIX={install_dir}",
|
||||
"-DCARES_SHARED=ON",
|
||||
"-DCARES_STATIC=OFF",
|
||||
"-DCMAKE_BUILD_TYPE=Release",
|
||||
], debug=debug)
|
||||
with Spinner("Building c-ares", enabled=not debug):
|
||||
run(["cmake", "--build", str(source_root / "build"), "--parallel", str(os.cpu_count() or 1)], debug=debug)
|
||||
with Spinner("Installing c-ares", enabled=not debug):
|
||||
run(["cmake", "--install", str(source_root / "build")], debug=debug)
|
||||
return install_dir, cares_version
|
||||
|
||||
|
||||
def build_curl(base_dir, curl_version, cares_install, *, debug=False):
|
||||
source_root = Path(base_dir) / "curl-src"
|
||||
install_dir = Path(base_dir) / "curl_install"
|
||||
build_root = Path(base_dir) / "_sources"
|
||||
tarball = build_root / f"curl-{curl_version}.tar.gz"
|
||||
url = f"https://curl.se/download/curl-{curl_version}.tar.gz"
|
||||
|
||||
ensure_dir(build_root)
|
||||
with Spinner("Downloading curl", enabled=not debug):
|
||||
download_file(url, tarball, debug=debug)
|
||||
extract_tarball(tarball, source_root, debug=debug)
|
||||
|
||||
env = build_env(cares_install)
|
||||
buildconf_script = source_root / "buildconf"
|
||||
with Spinner("Preparing curl build system", enabled=not debug):
|
||||
if buildconf_script.exists():
|
||||
run(["./buildconf"], cwd=str(source_root), env=env, debug=debug)
|
||||
run(["make", "distclean"], cwd=str(source_root), env=env, check=False, debug=debug)
|
||||
with Spinner("Configuring curl with c-ares", enabled=not debug):
|
||||
run([
|
||||
"./configure",
|
||||
f"--prefix={install_dir}",
|
||||
"--with-openssl",
|
||||
f"--enable-ares={cares_install}",
|
||||
"--disable-static",
|
||||
"--enable-shared",
|
||||
], cwd=str(source_root), env=env, debug=debug)
|
||||
with Spinner("Building curl", enabled=not debug):
|
||||
run(["make", "-j", str(os.cpu_count() or 1)], cwd=str(source_root), env=env, debug=debug, log_name=f"make_{Path(source_root).name}")
|
||||
with Spinner("Installing curl", enabled=not debug):
|
||||
run(["make", "install"], cwd=str(source_root), env=env, debug=debug, log_name=f"make_install_{Path(source_root).name}")
|
||||
|
||||
version = capture([str(install_dir / "bin" / "curl"), "--version"], env=build_env(install_dir, cares_install), debug=debug)
|
||||
return install_dir, version
|
||||
|
||||
|
||||
def build_libtorrent(base_dir, libtorrent_ref, curl_install=None, cares_install=None, *, debug=False):
|
||||
source_dir = Path(base_dir) / "libtorrent"
|
||||
install_dir = Path(base_dir) / "libtorrent_install"
|
||||
clone_or_update_repo("https://github.com/rakshasa/libtorrent.git", source_dir, libtorrent_ref, debug=debug)
|
||||
|
||||
prefixes = []
|
||||
if curl_install:
|
||||
prefixes.append(curl_install)
|
||||
if cares_install:
|
||||
prefixes.append(cares_install)
|
||||
env = build_env(*prefixes)
|
||||
configure_cmd = ["./configure", f"--prefix={install_dir}"]
|
||||
|
||||
if curl_install:
|
||||
curl_config = str(Path(curl_install) / "bin" / "curl-config")
|
||||
env["CURL_CONFIG"] = curl_config
|
||||
if Path(curl_config).exists():
|
||||
configure_cmd.append(f"--with-curl={curl_config}")
|
||||
env["LIBS"] = f"-L{Path(curl_install) / 'lib'} -lcurl " + env.get("LIBS", "")
|
||||
if cares_install:
|
||||
env["LIBS"] = f"-L{Path(cares_install) / 'lib'} -lcares " + env.get("LIBS", "")
|
||||
|
||||
with Spinner("Preparing libtorrent build system", enabled=not debug):
|
||||
run(["autoreconf", "-i"], cwd=str(source_dir), env=env, debug=debug)
|
||||
run(["make", "distclean"], cwd=str(source_dir), env=env, check=False, debug=debug)
|
||||
with Spinner("Configuring libtorrent", enabled=not debug):
|
||||
run(configure_cmd, cwd=str(source_dir), env=env, debug=debug)
|
||||
with Spinner("Building libtorrent", enabled=not debug):
|
||||
run(["make", "-j", str(os.cpu_count() or 1)], cwd=str(source_dir), env=env, debug=debug, log_name=f"make_{Path(source_dir).name}")
|
||||
with Spinner("Installing libtorrent", enabled=not debug):
|
||||
run(["make", "install"], cwd=str(source_dir), env=env, debug=debug, log_name=f"make_install_{Path(source_dir).name}")
|
||||
|
||||
version = capture(["git", "describe", "--tags", "--always"], cwd=str(source_dir), debug=debug)
|
||||
return install_dir, version
|
||||
|
||||
|
||||
def build_rtorrent(base_dir, rtorrent_ref, libtorrent_install, xmlrpc_install, curl_install=None, cares_install=None, *, debug=False):
|
||||
source_dir = Path(base_dir) / "rtorrent"
|
||||
install_dir = Path(base_dir) / "rtorrent_install"
|
||||
|
||||
clone_or_update_repo("https://github.com/rakshasa/rtorrent.git", source_dir, rtorrent_ref, debug=debug)
|
||||
|
||||
xmlrpc_config = find_xmlrpc_config(base_dir, xmlrpc_install)
|
||||
if not xmlrpc_config:
|
||||
raise InstallError(f"Could not find custom xmlrpc-c-config under {base_dir}.")
|
||||
if not str(xmlrpc_config).startswith(str(Path(xmlrpc_install).resolve())):
|
||||
raise InstallError(f"Wrong xmlrpc-c-config selected: {xmlrpc_config}. Expected one under: {xmlrpc_install}")
|
||||
|
||||
verify_xmlrpc_environment(xmlrpc_config, debug=debug)
|
||||
|
||||
prefixes = [libtorrent_install, xmlrpc_install]
|
||||
if curl_install:
|
||||
prefixes.append(curl_install)
|
||||
if cares_install:
|
||||
prefixes.append(cares_install)
|
||||
env = build_env(*prefixes)
|
||||
env["PATH"] = f"{xmlrpc_config.parent}:" + env.get("PATH", "")
|
||||
env["XMLRPC_C_CONFIG"] = str(xmlrpc_config)
|
||||
|
||||
with Spinner("Preparing rTorrent build system", enabled=not debug):
|
||||
run(["autoreconf", "-i"], cwd=str(source_dir), env=env, debug=debug)
|
||||
run(["make", "distclean"], cwd=str(source_dir), env=env, check=False, debug=debug)
|
||||
|
||||
configure_cmd = ["./configure", f"--prefix={install_dir}", "--with-xmlrpc-c"]
|
||||
with Spinner("Configuring rTorrent", enabled=not debug):
|
||||
run(configure_cmd, cwd=str(source_dir), env=env, debug=debug)
|
||||
with Spinner("Building rTorrent", enabled=not debug):
|
||||
run(["make", "-j", str(os.cpu_count() or 1)], cwd=str(source_dir), env=env, debug=debug, log_name=f"make_{Path(source_dir).name}")
|
||||
with Spinner("Installing rTorrent", enabled=not debug):
|
||||
run(["make", "install"], cwd=str(source_dir), env=env, debug=debug, log_name=f"make_install_{Path(source_dir).name}")
|
||||
|
||||
runtime_prefixes = [libtorrent_install, xmlrpc_install]
|
||||
if curl_install:
|
||||
runtime_prefixes.append(curl_install)
|
||||
if cares_install:
|
||||
runtime_prefixes.append(cares_install)
|
||||
runtime_env = build_env(*runtime_prefixes)
|
||||
runtime_env["LD_LIBRARY_PATH"] = ":".join([f"{p}/lib" for p in runtime_prefixes])
|
||||
version = capture([str(install_dir / "bin" / "rtorrent"), "-h"], env=runtime_env, check=False, debug=debug)
|
||||
return install_dir, version
|
||||
|
||||
|
||||
def install_symlinks(rtorrent_install, libtorrent_install, xmlrpc_install, curl_install=None, cares_install=None, *, debug=False):
|
||||
rtorrent_bin = Path(rtorrent_install) / "bin" / "rtorrent"
|
||||
if not rtorrent_bin.exists():
|
||||
raise InstallError(f"Compiled rtorrent binary not found: {rtorrent_bin}")
|
||||
|
||||
usr_local_bin = Path("/usr/local/bin/rtorrent")
|
||||
if usr_local_bin.exists() or usr_local_bin.is_symlink():
|
||||
usr_local_bin.unlink()
|
||||
usr_local_bin.symlink_to(rtorrent_bin)
|
||||
print(f"Symlinked {usr_local_bin} -> {rtorrent_bin}")
|
||||
|
||||
lib_dirs = [f"{libtorrent_install}/lib", f"{xmlrpc_install}/lib"]
|
||||
if curl_install:
|
||||
lib_dirs.append(f"{curl_install}/lib")
|
||||
if cares_install:
|
||||
lib_dirs.append(f"{cares_install}/lib")
|
||||
ld_conf = Path("/etc/ld.so.conf.d/rtorrent-custom-libs.conf")
|
||||
ld_conf.write_text("\n".join(lib_dirs) + "\n")
|
||||
run(["ldconfig"], debug=debug)
|
||||
|
||||
|
||||
def write_service(service_path, binary_path, runtime_lib_dirs):
|
||||
service_content = f"""[Unit]
|
||||
Description=rTorrent for %I | https://git.linuxiarz.pl/gru/tools_scripts/_edit/master/install_rtorrent.py
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=%I
|
||||
Group=%I
|
||||
KillMode=process
|
||||
WorkingDirectory=/home/%I
|
||||
ExecStartPre=-/bin/rm -f /home/%I/.session/rtorrent.lock
|
||||
ExecStart={binary_path} -o system.daemon.set=true -n -o import=/home/%I/.rtorrent.rc
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=300
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
LimitNOFILE=1048576
|
||||
Environment=LD_LIBRARY_PATH={runtime_lib_dirs}
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
"""
|
||||
Path(service_path).write_text(service_content)
|
||||
print(f"Wrote systemd unit: {service_path}")
|
||||
run(["systemctl", "daemon-reload"])
|
||||
|
||||
|
||||
def extract_version_tuple(text):
|
||||
if not text:
|
||||
return None
|
||||
match = re.search(r"(?:^|[^0-9])(\d+)\.(\d+)\.(\d+)(?:[^0-9]|$)", str(text))
|
||||
if not match:
|
||||
return None
|
||||
return tuple(int(part) for part in match.groups())
|
||||
|
||||
|
||||
def rtorrent_bind_address_directive(rtorrent_ref, rtorrent_version=None):
|
||||
version = extract_version_tuple(rtorrent_ref) or extract_version_tuple(rtorrent_version)
|
||||
if version and version < (0, 16, 0):
|
||||
return "network.bind_address.set"
|
||||
return "network.bind_address.ipv4.set"
|
||||
|
||||
|
||||
def build_rtorrent_config_content(username, scgi_port, torrent_port, bind_address_directive):
|
||||
return f"""
|
||||
## https://git.linuxiarz.pl/gru/tools_scripts/_edit/master/install_rtorrent.py
|
||||
# Generated by install_rtorrent.py
|
||||
|
||||
directory.default.set = /home/{username}/downloads
|
||||
session.path.set = /home/{username}/.session
|
||||
encoding.add = UTF-8
|
||||
|
||||
network.scgi.open_port = 127.0.0.1:{scgi_port}
|
||||
network.port_range.set = {torrent_port}-{torrent_port}
|
||||
network.port_random.set = no
|
||||
{bind_address_directive} = 0.0.0.0
|
||||
|
||||
system.file.allocate.set = 0
|
||||
system.umask.set = 0022
|
||||
|
||||
dht.mode.set = disable
|
||||
protocol.pex.set = no
|
||||
trackers.use_udp.set = no
|
||||
protocol.encryption.set = allow_incoming,enable_retry,prefer_plaintext
|
||||
|
||||
#schedule2 = tied_directory,6,5,start_tied=
|
||||
#schedule2 = untied_directory,7,5,stop_untied=
|
||||
schedule2 = session_save,300,300,((session.save))
|
||||
schedule2 = watch_directory,60,60,load.normal=/home/{username}/watch/*.torrent
|
||||
|
||||
ratio.max.set = -1
|
||||
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()
|
||||
|
||||
|
||||
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_content = build_rtorrent_config_content(username, scgi_port, torrent_port, bind_address_directive)
|
||||
|
||||
if config_path.exists() and not force_config:
|
||||
print(f"Config already exists: {config_path}")
|
||||
print("Not overwriting existing config. Proposed generated config would be:")
|
||||
print("--- BEGIN PROPOSED .rtorrent.rc ---")
|
||||
print(config_content, end="")
|
||||
print("--- END PROPOSED .rtorrent.rc ---")
|
||||
print("Use --force-config to overwrite the existing config.")
|
||||
return False
|
||||
|
||||
config_path.write_text(config_content)
|
||||
shutil.chown(config_path, user=username, group=username)
|
||||
print(f"Wrote config: {config_path}")
|
||||
return True
|
||||
|
||||
|
||||
def prepare_user_dirs(user_home, username):
|
||||
for d in [Path(user_home) / "downloads", Path(user_home) / ".session", Path(user_home) / "watch"]:
|
||||
ensure_dir(d, owner=username, group=username, mode=0o755)
|
||||
shutil.chown(Path(user_home), user=username, group=username)
|
||||
|
||||
|
||||
def enable_service(user, *, debug=False):
|
||||
unit_name = f"rtorrent@{user}.service"
|
||||
run(["systemctl", "enable", "--now", unit_name], debug=debug)
|
||||
print(f"Enabled and started {unit_name}")
|
||||
|
||||
|
||||
def print_link_lines(title, lines):
|
||||
print(title)
|
||||
for line in lines:
|
||||
print(line)
|
||||
|
||||
|
||||
def print_optional_libs_explanation():
|
||||
print("Optional libraries:")
|
||||
print(" - c-ares: asynchronous DNS resolver. It helps avoid blocking DNS lookups and can improve tracker/DHT-heavy workloads when curl is built with AsynchDNS support.")
|
||||
print(" - curl: HTTP/HTTPS transfer library used by libtorrent for tracker/web requests. Building a fresh curl can provide newer TLS/HTTP fixes and c-ares based async DNS.")
|
||||
print(" - minimal build: builds only xmlrpc-c, libtorrent and rTorrent; it uses the system libraries already available on Debian.")
|
||||
|
||||
|
||||
def resolve_optional_build_mode(args):
|
||||
requested = [name for name, enabled in [
|
||||
("--minimal", args.minimal),
|
||||
("--with-cares", args.with_cares),
|
||||
("--with-curl", args.with_curl),
|
||||
("--no-cares", args.no_cares),
|
||||
("--no-curl", args.no_curl),
|
||||
] if enabled]
|
||||
|
||||
if args.minimal and (args.with_cares or args.with_curl):
|
||||
raise InstallError("Conflicting options: --minimal cannot be used with --with-cares or --with-curl.")
|
||||
if args.no_curl and args.with_curl:
|
||||
raise InstallError("Conflicting options: --no-curl cannot be used with --with-curl.")
|
||||
if args.no_cares and (args.with_cares or args.with_curl):
|
||||
raise InstallError("Conflicting options: --no-cares cannot be used with --with-cares or --with-curl.")
|
||||
|
||||
if args.minimal or args.no_curl:
|
||||
return False
|
||||
if args.with_curl or args.with_cares:
|
||||
return True
|
||||
if args.no_cares:
|
||||
return False
|
||||
|
||||
if args.yes:
|
||||
return False
|
||||
|
||||
print_optional_libs_explanation()
|
||||
return prompt_yes_no(
|
||||
"Build additional c-ares and newest custom curl?",
|
||||
default=False,
|
||||
assume_yes=False,
|
||||
)
|
||||
|
||||
|
||||
def verify_libtorrent_curl_integration(base_dir, libtorrent_install, curl_install, cares_install, *, debug=False):
|
||||
libtorrent_so = next((p for p in sorted((Path(libtorrent_install) / "lib").glob("libtorrent.so*")) if p.is_file() and not p.is_symlink()), None)
|
||||
if not libtorrent_so:
|
||||
raise InstallError("Could not find compiled libtorrent shared object for verification.")
|
||||
|
||||
libtorrent_linked = capture(["ldd", str(libtorrent_so)], check=True, debug=debug)
|
||||
curl_lines = [line for line in libtorrent_linked.splitlines() if "libcurl" in line.lower()]
|
||||
print_link_lines("Linked libcurl lines (from libtorrent):", curl_lines)
|
||||
|
||||
expected_curl = str(Path(curl_install) / "lib")
|
||||
if curl_lines:
|
||||
if not any(expected_curl in line for line in curl_lines):
|
||||
raise InstallError(f"libtorrent does not appear to be linked against the compiled libcurl from {expected_curl}.")
|
||||
else:
|
||||
config_log = Path(base_dir) / "libtorrent" / "config.log"
|
||||
config_text = config_log.read_text(errors="ignore") if config_log.exists() else ""
|
||||
curl_config = str(Path(curl_install) / "bin" / "curl-config")
|
||||
if curl_config not in config_text and expected_curl not in config_text:
|
||||
raise InstallError(
|
||||
"libtorrent does not expose libcurl in ldd, and config.log does not show the custom curl path either. "
|
||||
"The build likely used the system curl or no curl integration."
|
||||
)
|
||||
print("libtorrent does not show libcurl in ldd; accepting config.log evidence of custom curl usage.")
|
||||
|
||||
custom_curl = Path(curl_install) / "bin" / "curl"
|
||||
curl_version = capture([str(custom_curl), "--version"], env=build_env(curl_install, cares_install), check=True, debug=debug)
|
||||
print("Custom curl version:")
|
||||
print(curl_version.splitlines()[0])
|
||||
lower = curl_version.lower()
|
||||
if "asynchdns" not in lower:
|
||||
raise InstallError("Custom curl does not report AsynchDNS support.")
|
||||
if "c-ares" not in lower and "ares" not in lower:
|
||||
print("Warning: curl --version does not explicitly show c-ares. Continuing because AsynchDNS is present.")
|
||||
|
||||
if cares_install:
|
||||
cares_lines = [line for line in libtorrent_linked.splitlines() if "cares" in line.lower()]
|
||||
print_link_lines("Linked c-ares lines (from libtorrent):", cares_lines)
|
||||
if not cares_lines:
|
||||
print("c-ares is not visible in libtorrent ldd; this can still be valid when libcurl is resolved differently.")
|
||||
|
||||
|
||||
def verify_install(base_dir, rtorrent_install, libtorrent_install, xmlrpc_install, curl_install=None, cares_install=None, *, debug=False):
|
||||
rtorrent_bin = Path(rtorrent_install) / "bin" / "rtorrent"
|
||||
which_rtorrent = capture(["which", "rtorrent"], check=False, debug=debug) or "not found in PATH"
|
||||
print(f"Resolved rtorrent from PATH: {which_rtorrent}")
|
||||
|
||||
linked = capture(["ldd", str(rtorrent_bin)], check=True, debug=debug)
|
||||
for libname, expected in [("libtorrent", str(Path(libtorrent_install) / "lib")), ("xmlrpc", str(Path(xmlrpc_install) / "lib"))]:
|
||||
lines = [line for line in linked.splitlines() if libname in line]
|
||||
print_link_lines(f"Linked {libname} lines:", lines)
|
||||
if not any(expected in line for line in lines):
|
||||
raise InstallError(f"rtorrent does not appear to be linked against the compiled {libname} from {expected}.")
|
||||
|
||||
if curl_install:
|
||||
verify_libtorrent_curl_integration(base_dir, libtorrent_install, curl_install, cares_install, debug=debug)
|
||||
|
||||
env = build_env(libtorrent_install, xmlrpc_install, curl_install, cares_install)
|
||||
env["LANG"] = "C"
|
||||
env["LC_ALL"] = "C"
|
||||
env["TERM"] = env.get("TERM", "xterm")
|
||||
ld_paths = [str(Path(libtorrent_install) / "lib"), str(Path(xmlrpc_install) / "lib")]
|
||||
if curl_install:
|
||||
ld_paths.append(str(Path(curl_install) / "lib"))
|
||||
if cares_install:
|
||||
ld_paths.append(str(Path(cares_install) / "lib"))
|
||||
env["LD_LIBRARY_PATH"] = ":".join(ld_paths)
|
||||
|
||||
probe = subprocess.run([str(rtorrent_bin), "-h"], env=env, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
help_output = ((probe.stdout or "") + "\n" + (probe.stderr or "")).lower()
|
||||
if "xmlrpc-c" in help_output and "i8" in help_output:
|
||||
raise InstallError(
|
||||
"rTorrent was built against an xmlrpc-c library without i8 support. "
|
||||
"Make sure the custom xmlrpc-c build is used and that no older local installation shadows it."
|
||||
)
|
||||
|
||||
|
||||
def build_parser():
|
||||
parser = argparse.ArgumentParser(description="Debian installer for xmlrpc-c + libtorrent + rTorrent under /opt with optional c-ares/custom curl support.")
|
||||
parser.add_argument("--base-dir", default=DEFAULT_BASE_DIR, help=f"Base build/install directory (default: {DEFAULT_BASE_DIR})")
|
||||
parser.add_argument("--libtorrent-ref", default=DEFAULT_LIBTORRENT_REF, help=f"Git branch, tag or commit for libtorrent (default: {DEFAULT_LIBTORRENT_REF})")
|
||||
parser.add_argument("--rtorrent-ref", default=DEFAULT_RTORRENT_REF, help=f"Git branch, tag or commit for rtorrent (default: {DEFAULT_RTORRENT_REF})")
|
||||
parser.add_argument("--xmlrpc-ref", default=DEFAULT_XMLRPC_REF, help="xmlrpc-c source version or URL (default: latest-stable)")
|
||||
parser.add_argument("--cares-ref", default=DEFAULT_CARES_REF, help=f"c-ares release version (default: {DEFAULT_CARES_REF})")
|
||||
parser.add_argument("--curl-ref", default=DEFAULT_CURL_REF, help=f"curl release version (default: {DEFAULT_CURL_REF})")
|
||||
parser.add_argument("--user", default=DEFAULT_USER, help=f"System user for the service (default: {DEFAULT_USER})")
|
||||
parser.add_argument("--group", default=DEFAULT_GROUP, help=f"System group for the service (default: {DEFAULT_GROUP})")
|
||||
parser.add_argument("--home", default=DEFAULT_HOME, help=f"Home directory for the service user (default: {DEFAULT_HOME})")
|
||||
parser.add_argument("--scgi-port", type=int, default=DEFAULT_SCGI_PORT, help=f"SCGI listen port for rTorrent XMLRPC/SCGI (default: {DEFAULT_SCGI_PORT})")
|
||||
parser.add_argument("--torrent-port", type=int, default=DEFAULT_TORRENT_PORT, help=f"Incoming BitTorrent listen port (default: {DEFAULT_TORRENT_PORT})")
|
||||
parser.add_argument("--force-config", action="store_true", help="Overwrite existing ~/.rtorrent.rc. By default, existing config is left unchanged and the proposed changes are printed.")
|
||||
parser.add_argument("--only-build", action="store_true", help="Only build and install libtorrent/rTorrent under /opt. Skip user, config and systemd.")
|
||||
parser.add_argument("--yes", action="store_true", help="Assume yes for interactive prompts; optional c-ares/curl remain disabled unless --with-curl or --with-cares is used.")
|
||||
parser.add_argument("--debug", action="store_true", help="Show full command output during build steps.")
|
||||
parser.add_argument("--minimal", "--core-only", action="store_true", help="Build only xmlrpc-c, libtorrent and rTorrent. Do not build c-ares or custom curl.")
|
||||
parser.add_argument("--no-cares", "--without-cares", dest="no_cares", action="store_true", help="Do not build c-ares. This also disables custom curl integration.")
|
||||
parser.add_argument("--no-curl", "--without-curl", dest="no_curl", action="store_true", help="Do not build custom curl. Implies no c-ares integration for libtorrent.")
|
||||
parser.add_argument("--with-cares", action="store_true", help="Build c-ares and custom curl with asynchronous DNS support.")
|
||||
parser.add_argument("--with-curl", action="store_true", help="Build newest custom curl; c-ares is enabled unless --no-cares is used.")
|
||||
return parser
|
||||
|
||||
|
||||
def main():
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
args.use_cares = resolve_optional_build_mode(args)
|
||||
|
||||
require_root()
|
||||
detect_debian()
|
||||
|
||||
packages = [
|
||||
"build-essential", "pkg-config", "libtool", "autoconf", "automake", "git", "ca-certificates",
|
||||
"libssl-dev", "libncurses-dev", "libncurses5-dev", "libncursesw5-dev", "libexpat1-dev",
|
||||
"libcurl4-openssl-dev", "libxml2-dev", "libreadline-dev", "curl", "tar", "gzip", "xz-utils",
|
||||
"zlib1g-dev", "bison", "flex", "m4", "gettext", "texinfo", "patch", "diffutils", "file", "procps"
|
||||
]
|
||||
if args.use_cares:
|
||||
packages.extend(["cmake", "libpsl-dev", "libbrotli-dev", "libzstd-dev"])
|
||||
|
||||
print("This script will:")
|
||||
print(f" - build xmlrpc-c from '{args.xmlrpc_ref}'")
|
||||
print(f" - build libtorrent from '{args.libtorrent_ref}'")
|
||||
print(f" - build rtorrent from '{args.rtorrent_ref}'")
|
||||
if args.use_cares:
|
||||
print(f" - build c-ares from '{args.cares_ref}'")
|
||||
print(f" - build curl from '{args.curl_ref}' with c-ares")
|
||||
print(" - benefit: async DNS via c-ares and newer curl for HTTP/HTTPS tracker requests")
|
||||
else:
|
||||
print(" - minimal build: skip c-ares/custom curl")
|
||||
print(" - build only xmlrpc-c, libtorrent and rTorrent; use Debian system libraries")
|
||||
print(f" - install everything under '{args.base_dir}'")
|
||||
if args.only_build:
|
||||
print(" - skip service user, config and systemd setup")
|
||||
else:
|
||||
print(f" - configure systemd service for user '{args.user}'")
|
||||
print(f" - use SCGI port {args.scgi_port} and torrent port {args.torrent_port}")
|
||||
|
||||
if not prompt_yes_no("Continue?", default=True, assume_yes=args.yes):
|
||||
print("Aborted by user.")
|
||||
return 1
|
||||
|
||||
ensure_packages(packages, debug=args.debug)
|
||||
ensure_dir(args.base_dir)
|
||||
|
||||
xmlrpc_install, xmlrpc_version = build_xmlrpc_c(args.base_dir, args.xmlrpc_ref, debug=args.debug)
|
||||
|
||||
cares_install = None
|
||||
cares_version = None
|
||||
curl_install = None
|
||||
curl_version = None
|
||||
|
||||
if args.use_cares:
|
||||
cares_install, cares_version = build_cares(args.base_dir, args.cares_ref, debug=args.debug)
|
||||
curl_install, curl_version = build_curl(args.base_dir, args.curl_ref, cares_install, debug=args.debug)
|
||||
|
||||
libtorrent_install, libtorrent_version = build_libtorrent(
|
||||
args.base_dir, args.libtorrent_ref, curl_install=curl_install, cares_install=cares_install, debug=args.debug
|
||||
)
|
||||
rtorrent_install, rtorrent_version = build_rtorrent(
|
||||
args.base_dir, args.rtorrent_ref, libtorrent_install, xmlrpc_install, curl_install=curl_install,
|
||||
cares_install=cares_install, debug=args.debug
|
||||
)
|
||||
|
||||
install_symlinks(rtorrent_install, libtorrent_install, xmlrpc_install, curl_install=curl_install, cares_install=cares_install, debug=args.debug)
|
||||
verify_install(args.base_dir, rtorrent_install, libtorrent_install, xmlrpc_install, curl_install=curl_install, cares_install=cares_install, debug=args.debug)
|
||||
|
||||
if not args.only_build:
|
||||
create_system_user(args.user, args.group, args.home, assume_yes=args.yes, debug=args.debug)
|
||||
prepare_user_dirs(args.home, args.user)
|
||||
bind_address_directive = rtorrent_bind_address_directive(args.rtorrent_ref, rtorrent_version)
|
||||
print(f"Using rTorrent bind address directive: {bind_address_directive}")
|
||||
write_rtorrent_config(args.home, args.user, args.scgi_port, args.torrent_port, bind_address_directive, force_config=args.force_config)
|
||||
runtime_lib_dirs = [f"{libtorrent_install}/lib", f"{xmlrpc_install}/lib"]
|
||||
if curl_install:
|
||||
runtime_lib_dirs.append(f"{curl_install}/lib")
|
||||
if cares_install:
|
||||
runtime_lib_dirs.append(f"{cares_install}/lib")
|
||||
write_service(DEFAULT_SERVICE_PATH, "/usr/local/bin/rtorrent", ":".join(runtime_lib_dirs))
|
||||
enable_service(args.user, debug=args.debug)
|
||||
print(f"\nService status hint: systemctl status rtorrent@{args.user}.service")
|
||||
|
||||
print("\nBuild summary")
|
||||
print("-------------")
|
||||
print(f"xmlrpc-c: {xmlrpc_version}")
|
||||
print(f"libtorrent: {libtorrent_version}")
|
||||
print(f"rtorrent: {rtorrent_version.splitlines()[0] if rtorrent_version else args.rtorrent_ref}")
|
||||
if args.use_cares:
|
||||
print(f"c-ares: {cares_version}")
|
||||
print(f"curl: {curl_version.splitlines()[0] if curl_version else args.curl_ref}")
|
||||
else:
|
||||
print("c-ares: disabled")
|
||||
print("curl: system")
|
||||
print("binary: /usr/local/bin/rtorrent")
|
||||
print(f"base dir: {args.base_dir}")
|
||||
print("\nDone.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted.")
|
||||
sys.exit(130)
|
||||
except InstallError as exc:
|
||||
print(f"\nERROR: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user