commit 9dcd0abd7db41cdc839cf4243d88d8d7b3a7787f Author: root Date: Tue May 19 13:43:37 2026 +0000 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..69d2750 --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f41eb9 --- /dev/null +++ b/.gitignore @@ -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/* \ No newline at end of file diff --git a/INSTALL.md b/INSTALL.md new file mode 120000 index 0000000..6e7a5fb --- /dev/null +++ b/INSTALL.md @@ -0,0 +1 @@ +scripts/INSTALL.md \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2789310 --- /dev/null +++ b/README.md @@ -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. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..4ca7d63 --- /dev/null +++ b/TODO.md @@ -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. + diff --git a/app.py b/app.py new file mode 100644 index 0000000..0b502f7 --- /dev/null +++ b/app.py @@ -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, + ) diff --git a/data/logs/README.md b/data/logs/README.md new file mode 100644 index 0000000..cd3d225 --- /dev/null +++ b/data/logs/README.md @@ -0,0 +1 @@ +logs \ No newline at end of file diff --git a/data/tracker_favicons/README.md b/data/tracker_favicons/README.md new file mode 100644 index 0000000..dcc2c74 --- /dev/null +++ b/data/tracker_favicons/README.md @@ -0,0 +1 @@ +tracker_favicons \ No newline at end of file diff --git a/deploy/pytorrent.service b/deploy/pytorrent.service new file mode 100644 index 0000000..2cffdfd --- /dev/null +++ b/deploy/pytorrent.service @@ -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 \ No newline at end of file diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 0000000..8004871 --- /dev/null +++ b/gunicorn.conf.py @@ -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") diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..b4303e5 --- /dev/null +++ b/install.sh @@ -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" diff --git a/make_zip.py b/make_zip.py new file mode 100644 index 0000000..d4d275f --- /dev/null +++ b/make_zip.py @@ -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() diff --git a/pytorrent/__init__.py b/pytorrent/__init__.py new file mode 100644 index 0000000..337f165 --- /dev/null +++ b/pytorrent/__init__.py @@ -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 diff --git a/pytorrent/cli.py b/pytorrent/cli.py new file mode 100644 index 0000000..cc56c2b --- /dev/null +++ b/pytorrent/cli.py @@ -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()) diff --git a/pytorrent/config.py b/pytorrent/config.py new file mode 100644 index 0000000..2cc8457 --- /dev/null +++ b/pytorrent/config.py @@ -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") diff --git a/pytorrent/db.py b/pytorrent/db.py new file mode 100644 index 0000000..839662b --- /dev/null +++ b/pytorrent/db.py @@ -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 diff --git a/pytorrent/logging_config.py b/pytorrent/logging_config.py new file mode 100644 index 0000000..77a1c0c --- /dev/null +++ b/pytorrent/logging_config.py @@ -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] diff --git a/pytorrent/openapi/openapi.json b/pytorrent/openapi/openapi.json new file mode 100644 index 0000000..888197a --- /dev/null +++ b/pytorrent/openapi/openapi.json @@ -0,0 +1,5994 @@ +{ + "components": { + "schemas": { + "ApiError": { + "properties": { + "error": { + "type": "string" + }, + "ok": { + "enum": [ + false + ], + "type": "boolean" + } + }, + "required": [ + "ok", + "error" + ], + "type": "object" + }, + "ApiOk": { + "properties": { + "ok": { + "enum": [ + true + ], + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "type": "object" + }, + "ApiToken": { + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "last_used_at": { + "nullable": true, + "type": "string" + }, + "name": { + "type": "string" + }, + "revoked_at": { + "nullable": true, + "type": "string" + }, + "token_prefix": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + }, + "required": [ + "id", + "user_id", + "name", + "token_prefix", + "created_at" + ], + "type": "object" + }, + "AppStatus": { + "properties": { + "api_ms": { + "format": "float", + "type": "number" + }, + "cleanup": { + "$ref": "#/components/schemas/CleanupSummary" + }, + "port_check": { + "$ref": "#/components/schemas/PortCheckStatus" + }, + "profile": { + "$ref": "#/components/schemas/Profile" + }, + "pytorrent": { + "additionalProperties": true, + "type": "object" + }, + "scgi": { + "additionalProperties": true, + "nullable": true, + "type": "object" + } + }, + "required": [ + "pytorrent", + "cleanup", + "scgi", + "port_check", + "api_ms" + ], + "type": "object" + }, + "AppStatusResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "status": { + "$ref": "#/components/schemas/AppStatus" + } + }, + "required": [ + "status" + ], + "type": "object" + } + ] + }, + "AuthUser": { + "additionalProperties": true, + "properties": { + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "role": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "type": "object" + }, + "AutomationExportResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "count": { + "type": "integer" + }, + "export": { + "additionalProperties": true, + "type": "object" + } + }, + "type": "object" + } + ] + }, + "AutomationImportResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "imported": { + "type": "integer" + }, + "rules": { + "items": { + "$ref": "#/components/schemas/AutomationRule" + }, + "type": "array" + } + }, + "type": "object" + } + ] + }, + "AutomationListResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "history": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + "rules": { + "items": { + "$ref": "#/components/schemas/AutomationRule" + }, + "type": "array" + } + }, + "type": "object" + } + ] + }, + "AutomationRule": { + "additionalProperties": true, + "type": "object" + }, + "AutomationRunResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "history": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + "result": { + "additionalProperties": true, + "type": "object" + }, + "rules": { + "items": { + "$ref": "#/components/schemas/AutomationRule" + }, + "type": "array" + } + }, + "type": "object" + } + ] + }, + "Backup": { + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "tables": { + "additionalProperties": { + "type": "integer" + }, + "type": "object" + } + }, + "required": [ + "id", + "name", + "created_at" + ], + "type": "object" + }, + "BackupCreateInput": { + "properties": { + "name": { + "type": "string" + } + }, + "type": "object" + }, + "BackupListResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "backups": { + "items": { + "$ref": "#/components/schemas/Backup" + }, + "type": "array" + } + }, + "type": "object" + } + ] + }, + "CleanupCacheSummary": { + "properties": { + "profile_id": { + "type": "integer" + }, + "profile_rows": { + "type": "integer" + }, + "runtime_items": { + "type": "integer" + }, + "torrent_stats_rows": { + "type": "integer" + }, + "tracker_rows": { + "type": "integer" + } + }, + "required": [ + "profile_id", + "profile_rows", + "runtime_items" + ], + "type": "object" + }, + "CleanupResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "cleanup": { + "$ref": "#/components/schemas/CleanupSummary" + }, + "deleted": { + "oneOf": [ + { + "type": "integer" + }, + { + "additionalProperties": true, + "type": "object" + } + ] + } + }, + "type": "object" + } + ] + }, + "CleanupSummary": { + "properties": { + "automation_history_total": { + "type": "integer" + }, + "cache": { + "$ref": "#/components/schemas/CleanupCacheSummary" + }, + "database": { + "properties": { + "error": { + "type": "string" + }, + "path": { + "type": "string" + }, + "size": { + "format": "int64", + "type": "integer" + }, + "size_h": { + "type": "string" + } + }, + "type": "object" + }, + "jobs_clearable": { + "type": "integer" + }, + "jobs_total": { + "type": "integer" + }, + "planner_history_total": { + "type": "integer" + }, + "retention_days": { + "properties": { + "automation_history": { + "type": "integer" + }, + "jobs": { + "type": "integer" + }, + "planner_history": { + "type": "integer" + }, + "smart_queue_history": { + "type": "integer" + } + }, + "type": "object" + }, + "smart_queue_history_total": { + "type": "integer" + } + }, + "required": [ + "jobs_total", + "jobs_clearable", + "smart_queue_history_total", + "retention_days", + "database", + "cache", + "planner_history_total" + ], + "type": "object" + }, + "FilePriorityRequest": { + "properties": { + "files": { + "items": { + "additionalProperties": true, + "properties": { + "index": { + "type": "integer" + }, + "priority": { + "type": "integer" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "files" + ], + "type": "object" + }, + "FilePriorityResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "errors": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + "updated": { + "type": "integer" + } + }, + "type": "object" + } + ] + }, + "HealthResponse": { + "additionalProperties": true, + "properties": { + "checks": { + "additionalProperties": true, + "type": "object" + }, + "ok": { + "type": "boolean" + }, + "status": { + "type": "string" + } + }, + "type": "object" + }, + "Job": { + "additionalProperties": true, + "type": "object" + }, + "JobQueuedResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "bulk": { + "type": "boolean" + }, + "bulk_parts": { + "type": "integer" + }, + "chunk_size": { + "type": "integer" + }, + "hash_count": { + "type": "integer" + }, + "job_id": { + "type": "string" + }, + "job_ids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "jobs": { + "items": { + "$ref": "#/components/schemas/Job" + }, + "type": "array" + } + }, + "type": "object" + } + ] + }, + "Label": { + "additionalProperties": true, + "properties": { + "color": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "type": "object" + }, + "OpenApiResponse": { + "additionalProperties": true, + "type": "object" + }, + "PathBrowseResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "dirs": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + "parent": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "type": "object" + } + ] + }, + "PathDefaultResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "path": { + "type": "string" + } + }, + "type": "object" + } + ] + }, + "PlannerHistoryEntry": { + "additionalProperties": true, + "properties": { + "at": { + "format": "date-time", + "type": "string" + }, + "count": { + "type": "integer" + }, + "down": { + "type": "integer" + }, + "dry_run": { + "type": "boolean" + }, + "event": { + "type": "string" + }, + "pause_reason": { + "type": "string" + }, + "paused": { + "type": "integer" + }, + "reasons": { + "items": { + "type": "string" + }, + "type": "array" + }, + "resumed": { + "type": "integer" + }, + "up": { + "type": "integer" + } + }, + "type": "object" + }, + "PlannerPreviewResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "history": { + "items": { + "$ref": "#/components/schemas/PlannerHistoryEntry" + }, + "type": "array" + }, + "history_total": { + "type": "integer" + }, + "preview": { + "additionalProperties": true, + "type": "object" + } + }, + "type": "object" + } + ] + }, + "PortCheckStatus": { + "additionalProperties": true, + "properties": { + "cached": { + "type": "boolean" + }, + "checked_at": { + "format": "date-time", + "type": "string" + }, + "checked_at_epoch": { + "format": "double", + "type": "number" + }, + "enabled": { + "type": "boolean" + }, + "error": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "ports": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "public_ip": { + "type": "string" + }, + "source": { + "type": "string" + }, + "status": { + "enum": [ + "open", + "closed", + "unknown", + "disabled", + "error" + ], + "type": "string" + } + }, + "type": "object" + }, + "Preferences": { + "additionalProperties": true, + "properties": { + "bootstrap_theme": { + "enum": [ + "default", + "flatly", + "litera", + "lumen", + "minty", + "sketchy", + "solar", + "spacelab", + "united", + "zephyr" + ], + "type": "string" + }, + "disk_monitor_mode": { + "type": "string" + }, + "disk_monitor_paths_json": { + "type": "string" + }, + "disk_monitor_selected_path": { + "type": "string" + }, + "font_family": { + "enum": [ + "default", + "adwaita-mono", + "inter", + "system-ui", + "source-sans-3", + "jetbrains-mono" + ], + "type": "string" + }, + "peers_refresh_seconds": { + "enum": [ + 0, + 10, + 15, + 30, + 60 + ], + "type": "integer" + }, + "port_check_enabled": { + "type": "boolean" + }, + "table_columns_json": { + "description": "JSON-encoded TableColumnsPreference stored in user preferences.", + "type": "string" + }, + "theme": { + "enum": [ + "light", + "dark" + ], + "type": "string" + }, + "tracker_favicons_enabled": { + "type": "boolean" + } + }, + "type": "object" + }, + "Profile": { + "additionalProperties": true, + "properties": { + "id": { + "type": "integer" + }, + "is_remote": { + "type": "boolean" + }, + "max_parallel_jobs": { + "default": 5, + "type": "integer" + }, + "name": { + "type": "string" + }, + "scgi_url": { + "type": "string" + }, + "timeout_seconds": { + "type": "integer" + } + }, + "type": "object" + }, + "ProfileInput": { + "properties": { + "is_remote": { + "description": "When true, CPU/RAM host usage is hidden; public IP checks try remote rTorrent commands when supported.", + "type": "boolean" + }, + "max_parallel_jobs": { + "default": 5, + "description": "Maximum queued jobs that may run at once for this rTorrent. Move/remove jobs keep request order.", + "type": "integer" + }, + "name": { + "type": "string" + }, + "scgi_url": { + "type": "string" + }, + "timeout_seconds": { + "type": "integer" + } + }, + "type": "object" + }, + "RatioGroup": { + "additionalProperties": true, + "type": "object" + }, + "RssConfigResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "feeds": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + "rules": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + } + ] + }, + "RtorrentConfigGenerateResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "config_text": { + "type": "string" + } + }, + "type": "object" + } + ] + }, + "RtorrentConfigResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "config": { + "additionalProperties": true, + "type": "object" + } + }, + "type": "object" + } + ] + }, + "RtorrentConfigSaveResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "result": { + "additionalProperties": true, + "type": "object" + } + }, + "type": "object" + } + ] + }, + "SmartQueueCheckResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "result": { + "additionalProperties": true, + "type": "object" + }, + "torrent_patch": { + "additionalProperties": true, + "type": "object" + } + }, + "type": "object" + } + ] + }, + "SmartQueueResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "cooldown_remaining_seconds": { + "type": "integer" + }, + "exclusions": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + "history": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + "history_total": { + "type": "integer" + }, + "refill_remaining_seconds": { + "type": "integer" + }, + "settings": { + "$ref": "#/components/schemas/SmartQueueSettings" + } + }, + "type": "object" + } + ] + }, + "SmartQueueSettings": { + "additionalProperties": true, + "properties": { + "auto_stop_idle": { + "type": "boolean" + }, + "cooldown_minutes": { + "minimum": 1, + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "ignore_seed_peer": { + "description": "When enabled, seed and peer counts are ignored by the stalled timer.", + "type": "boolean" + }, + "ignore_speed": { + "type": "boolean" + }, + "max_active_downloads": { + "minimum": 1, + "type": "integer" + }, + "min_peers": { + "minimum": 0, + "type": "integer" + }, + "min_seeds": { + "minimum": 0, + "type": "integer" + }, + "min_speed_bytes": { + "minimum": 0, + "type": "integer" + }, + "protect_active_below_cap": { + "type": "boolean" + }, + "refill_enabled": { + "type": "boolean" + }, + "refill_interval_minutes": { + "minimum": 0, + "type": "integer" + }, + "stalled_seconds": { + "minimum": 30, + "type": "integer" + }, + "start_grace_seconds": { + "minimum": 0, + "type": "integer" + }, + "stop_batch_size": { + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, + "SpeedLimitRequest": { + "properties": { + "down": { + "description": "Bytes per second, 0 unlimited", + "type": "integer" + }, + "up": { + "description": "Bytes per second, 0 unlimited", + "type": "integer" + } + }, + "type": "object" + }, + "SystemDiskResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "disk": { + "additionalProperties": true, + "type": "object" + } + }, + "type": "object" + } + ] + }, + "SystemStatusResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "status": { + "additionalProperties": true, + "type": "object" + } + }, + "type": "object" + } + ] + }, + "TableColumnWidths": { + "additionalProperties": { + "type": "integer" + }, + "description": "Column widths in pixels keyed by column id.", + "type": "object" + }, + "TableColumnsPreference": { + "additionalProperties": true, + "description": "Torrent table column visibility and width preferences for desktop and mobile.", + "properties": { + "hidden": { + "items": { + "type": "string" + }, + "type": "array" + }, + "mobile": { + "additionalProperties": { + "type": "boolean" + }, + "type": "object" + }, + "mobileSmartFiltersEnabled": { + "type": "boolean" + }, + "shown": { + "items": { + "type": "string" + }, + "type": "array" + }, + "widths": { + "$ref": "#/components/schemas/TableColumnWidths" + } + }, + "type": "object" + }, + "Torrent": { + "additionalProperties": true, + "properties": { + "active": { + "type": "boolean" + }, + "completed_bytes": { + "format": "int64", + "type": "integer" + }, + "down_rate": { + "type": "integer" + }, + "hash": { + "type": "string" + }, + "hashing": { + "type": "integer" + }, + "label": { + "type": "string" + }, + "message": { + "type": "string" + }, + "name": { + "type": "string" + }, + "paused": { + "type": "boolean" + }, + "ratio_group": { + "type": "string" + }, + "size_bytes": { + "format": "int64", + "type": "integer" + }, + "state": { + "type": "string" + }, + "up_rate": { + "type": "integer" + } + }, + "type": "object" + }, + "TorrentFilesResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "files": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "files" + ], + "type": "object" + } + ] + }, + "TorrentFilterSummary": { + "properties": { + "completed_bytes": { + "description": "Completed bytes reported by rTorrent.", + "format": "int64", + "type": "integer" + }, + "count": { + "description": "Number of torrents in this filter.", + "type": "integer" + }, + "disk_bytes": { + "description": "Completed bytes reported by rTorrent; used as the displayed Data value.", + "format": "int64", + "type": "integer" + }, + "down_total": { + "deprecated": true, + "description": "Backward compatibility field; not used by the filters UI.", + "format": "int64", + "type": "integer" + }, + "progress_percent": { + "description": "Completed percentage for this filter.", + "format": "float", + "type": "number" + }, + "remaining_bytes": { + "description": "size - completed_bytes, never below zero.", + "format": "int64", + "type": "integer" + }, + "remaining_percent": { + "description": "Remaining percentage for this filter.", + "format": "float", + "type": "number" + }, + "size": { + "description": "Total torrent payload size in bytes.", + "format": "int64", + "type": "integer" + }, + "up_total": { + "deprecated": true, + "description": "Backward compatibility field; not used by the filters UI.", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "count", + "size", + "disk_bytes", + "completed_bytes", + "remaining_bytes", + "progress_percent", + "remaining_percent" + ], + "type": "object" + }, + "TorrentListResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "error": { + "nullable": true, + "type": "string" + }, + "profile_id": { + "type": "integer" + }, + "summary": { + "$ref": "#/components/schemas/TorrentSummary" + }, + "torrents": { + "items": { + "$ref": "#/components/schemas/Torrent" + }, + "type": "array" + } + }, + "required": [ + "torrents", + "summary" + ], + "type": "object" + } + ] + }, + "TorrentPeersResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "peers": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "peers" + ], + "type": "object" + } + ] + }, + "TorrentSummary": { + "properties": { + "cache_ttl_seconds": { + "description": "Summary cache TTL in seconds.", + "type": "integer" + }, + "cached": { + "description": "True when returned from cache.", + "type": "boolean" + }, + "filters": { + "$ref": "#/components/schemas/TorrentSummaryFilters" + }, + "generated_at_epoch": { + "description": "Unix timestamp when summary was generated.", + "format": "double", + "type": "number" + } + }, + "required": [ + "filters", + "cache_ttl_seconds", + "generated_at_epoch", + "cached" + ], + "type": "object" + }, + "TorrentSummaryFilters": { + "properties": { + "all": { + "$ref": "#/components/schemas/TorrentFilterSummary" + }, + "checking": { + "$ref": "#/components/schemas/TorrentFilterSummary" + }, + "downloading": { + "$ref": "#/components/schemas/TorrentFilterSummary" + }, + "error": { + "$ref": "#/components/schemas/TorrentFilterSummary" + }, + "paused": { + "$ref": "#/components/schemas/TorrentFilterSummary" + }, + "seeding": { + "$ref": "#/components/schemas/TorrentFilterSummary" + }, + "stopped": { + "$ref": "#/components/schemas/TorrentFilterSummary" + } + }, + "required": [ + "all", + "downloading", + "seeding", + "paused", + "checking", + "error", + "stopped" + ], + "type": "object" + }, + "TorrentTrackersResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "trackers": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "trackers" + ], + "type": "object" + } + ] + }, + "TrackerActionResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "message": { + "type": "string" + }, + "result": { + "additionalProperties": true, + "type": "object" + } + }, + "type": "object" + } + ] + }, + "TrackerSummary": { + "additionalProperties": true, + "properties": { + "errors": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + "hashes": { + "additionalProperties": true, + "type": "object" + }, + "pending": { + "type": "integer" + }, + "scanned": { + "type": "integer" + }, + "trackers": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + "warming": { + "additionalProperties": true, + "type": "object" + } + }, + "type": "object" + }, + "TrackerSummaryResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "error": { + "type": "string" + }, + "summary": { + "$ref": "#/components/schemas/TrackerSummary" + } + }, + "required": [ + "summary" + ], + "type": "object" + } + ] + }, + "TrafficHistoryResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "history": { + "additionalProperties": true, + "type": "object" + } + }, + "type": "object" + } + ] + } + }, + "securitySchemes": { + "apiKeyAuth": { + "description": "Optional per-user API token. Tokens are accepted only when PYTORRENT_AUTH_ENABLE=1. When auth is disabled, API endpoints do not require credentials.", + "in": "header", + "name": "X-API-Key", + "type": "apiKey" + }, + "bearerApiToken": { + "description": "Optional per-user API token in Authorization: Bearer . Tokens are accepted only when PYTORRENT_AUTH_ENABLE=1.", + "scheme": "bearer", + "type": "http" + }, + "sessionCookie": { + "description": "Optional Flask session cookie. Required only when PYTORRENT_AUTH_ENABLE=1 and no API token is used.", + "in": "cookie", + "name": "session", + "type": "apiKey" + } + } + }, + "info": { + "title": "pyTorrent API", + "version": "0.0.1" + }, + "openapi": "3.0.3", + "paths": { + "/api/app/status": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppStatusResponse" + } + } + }, + "description": "Application status" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "pyTorrent application status" + } + }, + "/api/auth/login": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "password": { + "format": "password", + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "username", + "password" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "auth_enabled": { + "type": "boolean" + }, + "user": { + "$ref": "#/components/schemas/AuthUser" + } + }, + "required": [ + "user", + "auth_enabled" + ], + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Invalid credentials" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Authentication disabled" + } + }, + "summary": "Log in with username and password" + } + }, + "/api/auth/logout": { + "post": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiOk" + } + } + }, + "description": "OK" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Authentication disabled" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Log out current user" + } + }, + "/api/auth/me": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "auth_enabled": { + "type": "boolean" + }, + "user": { + "$ref": "#/components/schemas/AuthUser" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Authentication disabled" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Get current user" + } + }, + "/api/auth/users": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "users": { + "items": { + "$ref": "#/components/schemas/AuthUser" + }, + "type": "array" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Authentication disabled" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "List users" + }, + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "user": { + "$ref": "#/components/schemas/AuthUser" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Create user" + } + }, + "/api/auth/users/{user_id}": { + "delete": { + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "users": { + "items": { + "$ref": "#/components/schemas/AuthUser" + }, + "type": "array" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Delete user" + }, + "put": { + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "user": { + "$ref": "#/components/schemas/AuthUser" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Update user" + } + }, + "/api/auth/users/{user_id}/tokens": { + "get": { + "description": "Available only when application authentication is enabled. Admins can list any user tokens; regular users can list their own tokens. Token secrets are never returned.", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + }, + "tokens": { + "items": { + "$ref": "#/components/schemas/ApiToken" + }, + "type": "array" + } + }, + "type": "object" + } + } + }, + "description": "Token list" + } + }, + "security": [ + { + "sessionCookie": [] + }, + { + "apiKeyAuth": [] + }, + { + "bearerApiToken": [] + } + ], + "summary": "List API tokens for a user" + }, + "post": { + "description": "Available only when application authentication is enabled. The returned token is shown once and must be copied immediately.", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "name": { + "example": "automation", + "type": "string" + } + }, + "type": "object" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + }, + "token": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiToken" + }, + { + "properties": { + "token": { + "type": "string" + } + }, + "type": "object" + } + ] + } + }, + "type": "object" + } + } + }, + "description": "Generated token" + } + }, + "security": [ + { + "sessionCookie": [] + }, + { + "apiKeyAuth": [] + }, + { + "bearerApiToken": [] + } + ], + "summary": "Generate API token for a user" + } + }, + "/api/auth/users/{user_id}/tokens/{token_id}": { + "delete": { + "description": "Available only when application authentication is enabled. Revoked tokens stop working immediately.", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "in": "path", + "name": "token_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + }, + "tokens": { + "items": { + "$ref": "#/components/schemas/ApiToken" + }, + "type": "array" + } + }, + "type": "object" + } + } + }, + "description": "Updated token list" + } + }, + "security": [ + { + "sessionCookie": [] + }, + { + "apiKeyAuth": [] + }, + { + "bearerApiToken": [] + } + ], + "summary": "Revoke API token" + } + }, + "/api/automations": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutomationListResponse" + } + } + }, + "description": "OK" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "List automation rules and history" + }, + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutomationRule" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutomationListResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Create or update automation rule" + } + }, + "/api/automations/check": { + "post": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutomationRunResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Run automation check immediately" + } + }, + "/api/automations/export": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutomationExportResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Export automation rules" + } + }, + "/api/automations/history": { + "delete": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "cleanup": { + "$ref": "#/components/schemas/CleanupSummary" + }, + "deleted": { + "type": "integer" + }, + "history": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Clear automation execution history" + } + }, + "/api/automations/import": { + "post": { + "parameters": [ + { + "in": "query", + "name": "replace", + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutomationImportResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Import automation rules" + } + }, + "/api/automations/{rule_id}": { + "delete": { + "parameters": [ + { + "in": "path", + "name": "rule_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutomationListResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Delete automation rule" + } + }, + "/api/automations/{rule_id}/run": { + "post": { + "parameters": [ + { + "in": "path", + "name": "rule_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutomationRunResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Run one automation rule immediately" + } + }, + "/api/backup": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackupListResponse" + } + } + }, + "description": "OK" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "List backups" + }, + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackupCreateInput" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "backup": { + "$ref": "#/components/schemas/Backup" + }, + "backups": { + "items": { + "$ref": "#/components/schemas/Backup" + }, + "type": "array" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Create backup" + } + }, + "/api/backup/{backup_id}": { + "delete": { + "parameters": [ + { + "in": "path", + "name": "backup_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "result": { + "properties": { + "deleted": { + "type": "integer" + } + }, + "type": "object" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Delete backup" + } + }, + "/api/backup/{backup_id}/download": { + "get": { + "parameters": [ + { + "in": "path", + "name": "backup_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "Backup JSON" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Download backup" + } + }, + "/api/backup/{backup_id}/restore": { + "post": { + "parameters": [ + { + "in": "path", + "name": "backup_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "result": { + "additionalProperties": true, + "type": "object" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Restore backup" + } + }, + "/api/cleanup/all": { + "post": { + "description": "Clears finished job logs, Smart Queue history, Planner action history and automation history. Pending/running jobs, saved rules, settings and torrents are preserved.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CleanupResponse" + } + } + }, + "description": "Cleanup result" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Clear all cleanup-supported history" + } + }, + "/api/cleanup/automations": { + "post": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CleanupResponse" + } + } + }, + "description": "Cleanup result" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Clear automation execution history" + } + }, + "/api/cleanup/cache": { + "post": { + "description": "Clears active profile runtime and database-backed caches without deleting torrents, settings, rules or logs.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CleanupResponse" + } + } + }, + "description": "Cleanup result" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "No active profile" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Clear active profile cache" + } + }, + "/api/cleanup/jobs": { + "post": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CleanupResponse" + } + } + }, + "description": "Cleanup result" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Clear finished job history" + } + }, + "/api/cleanup/planner": { + "post": { + "description": "Deletes Download Planner action history for the active profile. Saved Planner settings are preserved.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CleanupResponse" + } + } + }, + "description": "Cleanup result" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "No active profile" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Clear Planner action history" + } + }, + "/api/cleanup/smart-queue": { + "post": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CleanupResponse" + } + } + }, + "description": "Cleanup result" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Clear Smart Queue history" + } + }, + "/api/cleanup/summary": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CleanupResponse" + } + } + }, + "description": "Cleanup summary" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Cleanup summary" + } + }, + "/api/download-planner": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Manage download planner settings" + }, + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Manage download planner settings" + } + }, + "/api/download-planner/check": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Run download planner check" + } + }, + "/api/download-planner/history": { + "delete": { + "description": "Deletes Download Planner action history for the active profile. Saved Planner settings are preserved.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "deleted": { + "type": "integer" + }, + "history": { + "items": { + "$ref": "#/components/schemas/PlannerHistoryEntry" + }, + "type": "array" + }, + "history_total": { + "type": "integer" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "History cleared" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "No active profile" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Clear Planner action history" + } + }, + "/api/download-planner/override": { + "post": { + "description": "Set a temporary manual planner override.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "down_limit": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "minutes": { + "type": "integer" + }, + "up_limit": { + "type": "integer" + } + }, + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Set download planner override" + } + }, + "/api/download-planner/preview": { + "get": { + "description": "Preview matching planner rule and next action.", + "parameters": [ + { + "description": "Number of Planner history rows to return.", + "in": "query", + "name": "history_limit", + "required": false, + "schema": { + "maximum": 200, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlannerPreviewResponse" + } + } + }, + "description": "Planner preview" + } + }, + "summary": "Preview download planner" + } + }, + "/api/health": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + }, + "description": "OK" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + }, + "description": "Unhealthy" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Application health check" + } + }, + "/api/health/nagios": { + "get": { + "responses": { + "200": { + "description": "Plain-text OK" + }, + "503": { + "description": "Plain-text CRITICAL" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Nagios-compatible health check" + } + }, + "/api/jobs": { + "get": { + "parameters": [ + { + "in": "query", + "name": "limit", + "schema": { + "default": 50, + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "schema": { + "default": 0, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "jobs": { + "items": { + "$ref": "#/components/schemas/Job" + }, + "type": "array" + }, + "total": { + "type": "integer" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "List job queue history" + } + }, + "/api/jobs/clear": { + "post": { + "description": "Deletes jobs that are not pending or running.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "deleted": { + "type": "integer" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Clear finished job history" + } + }, + "/api/jobs/{job_id}/cancel": { + "post": { + "parameters": [ + { + "in": "path", + "name": "job_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiOk" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Cancel pending or failed job" + } + }, + "/api/jobs/{job_id}/force": { + "post": { + "parameters": [ + { + "in": "path", + "name": "job_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiOk" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Force pending job to run next" + } + }, + "/api/jobs/{job_id}/retry": { + "post": { + "parameters": [ + { + "in": "path", + "name": "job_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiOk" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Retry failed or cancelled job" + } + }, + "/api/labels": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "labels": { + "items": { + "$ref": "#/components/schemas/Label" + }, + "type": "array" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "List labels" + }, + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Label" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "labels": { + "items": { + "$ref": "#/components/schemas/Label" + }, + "type": "array" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Create or update label" + } + }, + "/api/labels/{label_id}": { + "delete": { + "parameters": [ + { + "in": "path", + "name": "label_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "labels": { + "items": { + "$ref": "#/components/schemas/Label" + }, + "type": "array" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Delete label" + } + }, + "/api/openapi.json": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenApiResponse" + } + } + }, + "description": "OpenAPI document" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "OpenAPI schema" + } + }, + "/api/path/browse": { + "get": { + "parameters": [ + { + "in": "query", + "name": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PathBrowseResponse" + } + } + }, + "description": "OK" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Browse server directories" + } + }, + "/api/path/default": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PathDefaultResponse" + } + } + }, + "description": "OK" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Get active default download path" + } + }, + "/api/poller/settings": { + "get": { + "description": "Get poller settings", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "Get poller settings" + } + }, + "summary": "Get poller settings" + }, + "post": { + "description": "Save poller settings", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "Save poller settings" + } + }, + "summary": "Save poller settings" + } + }, + "/api/port-check": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PortCheckStatus" + } + } + }, + "description": "OK" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Read cached incoming port check status" + }, + "post": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PortCheckStatus" + } + } + }, + "description": "OK" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Run incoming port check immediately, bypassing cache" + } + }, + "/api/preferences": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "preferences": { + "$ref": "#/components/schemas/Preferences" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Get preferences" + }, + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Preferences" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "preferences": { + "$ref": "#/components/schemas/Preferences" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Save preferences" + } + }, + "/api/preferences/table-columns/recommended": { + "post": { + "description": "Applies the backend-owned recommended torrent table layout, including desktop visibility, mobile visibility, smart mobile filters and column widths.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "type": "object" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "preferences": { + "$ref": "#/components/schemas/Preferences" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Apply recommended table columns" + } + }, + "/api/profiles": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "active_profile": { + "$ref": "#/components/schemas/Profile" + }, + "profiles": { + "items": { + "$ref": "#/components/schemas/Profile" + }, + "type": "array" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "List rTorrent profiles" + }, + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "profile": { + "$ref": "#/components/schemas/Profile" + }, + "profiles": { + "items": { + "$ref": "#/components/schemas/Profile" + }, + "type": "array" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Create rTorrent profile" + } + }, + "/api/profiles/diagnostics": { + "get": { + "description": "Run diagnostics for the active rTorrent profile.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "Diagnostics result" + } + }, + "summary": "Active profile diagnostics" + } + }, + "/api/profiles/export": { + "get": { + "description": "Export rTorrent profiles.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "Profiles export" + } + }, + "summary": "Export profiles" + } + }, + "/api/profiles/import": { + "post": { + "description": "Import rTorrent profiles from exported JSON.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "profiles": { + "items": { + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Import profiles" + } + }, + "/api/profiles/test": { + "post": { + "description": "Test a submitted SCGI profile payload without saving it.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "is_remote": { + "type": "boolean" + }, + "max_parallel_jobs": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "scgi_url": { + "type": "string" + }, + "timeout_seconds": { + "type": "number" + } + }, + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Test rTorrent profile" + } + }, + "/api/profiles/{profile_id}": { + "delete": { + "parameters": [ + { + "in": "path", + "name": "profile_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "profiles": { + "items": { + "$ref": "#/components/schemas/Profile" + }, + "type": "array" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Delete rTorrent profile" + }, + "put": { + "parameters": [ + { + "in": "path", + "name": "profile_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "profile": { + "$ref": "#/components/schemas/Profile" + }, + "profiles": { + "items": { + "$ref": "#/components/schemas/Profile" + }, + "type": "array" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Update rTorrent profile" + } + }, + "/api/profiles/{profile_id}/activate": { + "post": { + "parameters": [ + { + "in": "path", + "name": "profile_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "active_profile": { + "$ref": "#/components/schemas/Profile" + }, + "profiles": { + "items": { + "$ref": "#/components/schemas/Profile" + }, + "type": "array" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Activate profile" + } + }, + "/api/profiles/{profile_id}/diagnostics": { + "get": { + "description": "Run diagnostics for a saved rTorrent profile.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "Diagnostics result" + } + }, + "summary": "Profile diagnostics" + } + }, + "/api/ratio-groups": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "groups": { + "items": { + "$ref": "#/components/schemas/RatioGroup" + }, + "type": "array" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "List ratio groups" + }, + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RatioGroup" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "groups": { + "items": { + "$ref": "#/components/schemas/RatioGroup" + }, + "type": "array" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Create or update ratio group" + } + }, + "/api/ratio-groups/check": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Run ratio groups check" + } + }, + "/api/rss": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RssConfigResponse" + } + } + }, + "description": "OK" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "List RSS feeds and rules" + } + }, + "/api/rss/check": { + "post": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "job_ids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "matches": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Manually check RSS feeds" + } + }, + "/api/rss/feeds": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RssConfigResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Add or update RSS feed" + } + }, + "/api/rss/feeds/{feed_id}": { + "delete": { + "parameters": [ + { + "in": "path", + "name": "feed_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Delete RSS feed" + } + }, + "/api/rss/rules": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RssConfigResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Add or update RSS rule" + } + }, + "/api/rss/rules/test": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Test RSS rule" + } + }, + "/api/rss/rules/{rule_id}": { + "delete": { + "parameters": [ + { + "in": "path", + "name": "rule_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Delete RSS rule" + } + }, + "/api/rtorrent-config": { + "get": { + "description": "Get startup rTorrent config", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "Get startup rTorrent config" + } + }, + "summary": "Get startup rTorrent config" + }, + "post": { + "description": "Save startup rTorrent config", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "Save startup rTorrent config" + } + }, + "summary": "Save startup rTorrent config" + } + }, + "/api/rtorrent-config/generate": { + "post": { + "description": "Generate startup rTorrent config", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "Generate startup rTorrent config" + } + }, + "summary": "Generate startup rTorrent config" + } + }, + "/api/smart-queue": { + "get": { + "parameters": [ + { + "in": "query", + "name": "history_limit", + "schema": { + "default": 10, + "maximum": 100, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SmartQueueResponse" + } + } + }, + "description": "OK" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Get Smart Queue settings, exceptions and history" + }, + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SmartQueueSettings" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SmartQueueResponse" + } + } + }, + "description": "OK" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Save Smart Queue settings" + } + }, + "/api/smart-queue/check": { + "post": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SmartQueueCheckResponse" + } + } + }, + "description": "OK" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Run Smart Queue check immediately" + } + }, + "/api/smart-queue/exclusion": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "excluded": { + "type": "boolean" + }, + "hash": { + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "hash" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "exclusions": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Set Smart Queue exclusion" + } + }, + "/api/smart-queue/history": { + "delete": { + "description": "Deletes Smart Queue audit history for the active profile only. Settings, exclusions and queue state are preserved.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "history": { + "items": { + "type": "object" + }, + "type": "array" + }, + "history_total": { + "type": "integer" + }, + "removed": { + "type": "integer" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "History cleared" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "No active profile" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Clear Smart Queue history for active profile" + } + }, + "/api/speed/limits": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SpeedLimitRequest" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobQueuedResponse" + } + } + }, + "description": "Job queued" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Queue global speed limit change" + } + }, + "/api/system/disk": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SystemDiskResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Disk usage for monitored paths" + } + }, + "/api/system/status": { + "get": { + "description": "For remote profiles CPU/RAM host usage is not returned and usage_available is false.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SystemStatusResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "rTorrent/system status" + } + }, + "/api/torrent-stats": { + "get": { + "parameters": [ + { + "in": "query", + "name": "force", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "stats": { + "additionalProperties": true, + "type": "object" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Get torrent statistics" + } + }, + "/api/torrents": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TorrentListResponse" + } + } + }, + "description": "Torrent list with cached filter summary" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Get cached torrent snapshot" + } + }, + "/api/torrents/add": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "directory": { + "type": "string" + }, + "label": { + "type": "string" + }, + "start": { + "type": "boolean" + }, + "uris": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + } + }, + "type": "object" + } + }, + "multipart/form-data": { + "schema": { + "properties": { + "directory": { + "type": "string" + }, + "files": { + "items": { + "format": "binary", + "type": "string" + }, + "type": "array" + }, + "label": { + "type": "string" + }, + "start": { + "type": "boolean" + }, + "uris": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobQueuedResponse" + } + } + }, + "description": "Jobs queued" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Add magnet links or torrent files" + } + }, + "/api/torrents/create": { + "post": { + "description": "Create a .torrent file from a source path on the active rTorrent host and optionally add/share it.", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "properties": { + "comment": { + "type": "string" + }, + "label": { + "type": "string" + }, + "piece_size_kib": { + "type": "integer" + }, + "private": { + "enum": [ + "0", + "1" + ], + "type": "string" + }, + "share": { + "enum": [ + "0", + "1" + ], + "type": "string" + }, + "source": { + "type": "string" + }, + "source_path": { + "type": "string" + }, + "trackers": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/x-bittorrent": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "Created torrent file" + } + }, + "summary": "Create torrent file" + } + }, + "/api/torrents/preview": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Preview torrent files" + } + }, + "/api/torrents/torrent-files.zip": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Export selected torrent files as ZIP" + } + }, + "/api/torrents/{action_name}": { + "post": { + "description": "For move, path is the target directory; move_data=true physically moves data on the rTorrent host using a detached shell move with status polling. Large move/remove selections are split into ordered bulk parts of up to 100 hashes.", + "parameters": [ + { + "in": "path", + "name": "action_name", + "required": true, + "schema": { + "enum": [ + "start", + "pause", + "unpause", + "stop", + "resume", + "recheck", + "reannounce", + "remove", + "move", + "set_label", + "set_ratio_group" + ], + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "hashes": { + "items": { + "type": "string" + }, + "type": "array" + }, + "label": { + "type": "string" + }, + "move_data": { + "type": "boolean" + }, + "path": { + "type": "string" + }, + "ratio_group": { + "type": "string" + }, + "recheck": { + "type": "boolean" + }, + "remove_data": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobQueuedResponse" + } + } + }, + "description": "Job queued" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Queue torrent action" + } + }, + "/api/torrents/{torrent_hash}/files": { + "get": { + "parameters": [ + { + "in": "path", + "name": "torrent_hash", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TorrentFilesResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Torrent files" + } + }, + "/api/torrents/{torrent_hash}/files/download.zip": { + "post": { + "parameters": [ + { + "in": "path", + "name": "torrent_hash", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Download selected files as ZIP" + } + }, + "/api/torrents/{torrent_hash}/files/folder-priority": { + "post": { + "parameters": [ + { + "in": "path", + "name": "torrent_hash", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Set folder file priority" + } + }, + "/api/torrents/{torrent_hash}/files/priority": { + "post": { + "parameters": [ + { + "in": "path", + "name": "torrent_hash", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FilePriorityRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FilePriorityResponse" + } + } + }, + "description": "OK" + }, + "207": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FilePriorityResponse" + } + } + }, + "description": "Partial success" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Set torrent file priorities" + } + }, + "/api/torrents/{torrent_hash}/files/tree": { + "get": { + "parameters": [ + { + "in": "path", + "name": "torrent_hash", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Get torrent file tree" + } + }, + "/api/torrents/{torrent_hash}/files/{file_index}/download": { + "get": { + "parameters": [ + { + "in": "path", + "name": "torrent_hash", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "file_index", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Download torrent file" + } + }, + "/api/torrents/{torrent_hash}/peers": { + "get": { + "parameters": [ + { + "in": "path", + "name": "torrent_hash", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TorrentPeersResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Torrent peers with GeoIP" + } + }, + "/api/torrents/{torrent_hash}/torrent-file": { + "get": { + "parameters": [ + { + "in": "path", + "name": "torrent_hash", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Export torrent file" + } + }, + "/api/torrents/{torrent_hash}/trackers": { + "get": { + "parameters": [ + { + "in": "path", + "name": "torrent_hash", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TorrentTrackersResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Torrent trackers" + } + }, + "/api/torrents/{torrent_hash}/trackers/{action_name}": { + "post": { + "parameters": [ + { + "in": "path", + "name": "torrent_hash", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "action_name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TrackerActionResponse" + } + } + }, + "description": "Tracker action result" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Run tracker action" + } + }, + "/api/tracker-favicon/{domain}": { + "get": { + "parameters": [ + { + "in": "path", + "name": "domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "refresh", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "302": { + "description": "Redirect to cached favicon" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Favicon not found" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Backward-compatible tracker favicon alias" + } + }, + "/api/trackers/favicon": { + "get": { + "parameters": [ + { + "description": "Tracker domain or URL", + "in": "query", + "name": "domain", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "refresh", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "302": { + "description": "Redirect to cached favicon" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Missing domain" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Favicon not found" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Resolve and cache tracker favicon by query parameter" + } + }, + "/api/trackers/favicon/{domain}": { + "get": { + "parameters": [ + { + "in": "path", + "name": "domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "refresh", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "302": { + "description": "Redirect to cached favicon" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Favicon not found" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Resolve and cache tracker favicon by path" + } + }, + "/api/trackers/summary": { + "get": { + "parameters": [ + { + "in": "query", + "name": "scan_limit", + "schema": { + "maximum": 250, + "minimum": 0, + "type": "integer" + } + }, + { + "in": "query", + "name": "bg_limit", + "schema": { + "maximum": 250, + "minimum": 1, + "type": "integer" + } + }, + { + "in": "query", + "name": "warm", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TrackerSummaryResponse" + } + } + }, + "description": "OK" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Get tracker summary for cached torrents" + } + }, + "/api/traffic/history": { + "get": { + "description": "Traffic history", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "Traffic history" + } + }, + "summary": "Traffic history" + } + } + } +} diff --git a/pytorrent/routes/_shared.py b/pytorrent/routes/_shared.py new file mode 100644 index 0000000..ded9599 --- /dev/null +++ b/pytorrent/routes/_shared.py @@ -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('__')] diff --git a/pytorrent/routes/api.py b/pytorrent/routes/api.py new file mode 100644 index 0000000..ba01ce7 --- /dev/null +++ b/pytorrent/routes/api.py @@ -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"] diff --git a/pytorrent/routes/auth_api.py b/pytorrent/routes/auth_api.py new file mode 100644 index 0000000..0395c5c --- /dev/null +++ b/pytorrent/routes/auth_api.py @@ -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/") + 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/") + 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//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//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//tokens/") + 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 + diff --git a/pytorrent/routes/automations.py b/pytorrent/routes/automations.py new file mode 100644 index 0000000..dc26d3c --- /dev/null +++ b/pytorrent/routes/automations.py @@ -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/') +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//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 diff --git a/pytorrent/routes/backup.py b/pytorrent/routes/backup.py new file mode 100644 index 0000000..c0807b9 --- /dev/null +++ b/pytorrent/routes/backup.py @@ -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//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//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/") +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//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 + + diff --git a/pytorrent/routes/main.py b/pytorrent/routes/main.py new file mode 100644 index 0000000..aeb6398 --- /dev/null +++ b/pytorrent/routes/main.py @@ -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"""pyTorrent API Docs
""" + 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 diff --git a/pytorrent/routes/planner.py b/pytorrent/routes/planner.py new file mode 100644 index 0000000..1b2cb19 --- /dev/null +++ b/pytorrent/routes/planner.py @@ -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 diff --git a/pytorrent/routes/profiles.py b/pytorrent/routes/profiles.py new file mode 100644 index 0000000..41a7571 --- /dev/null +++ b/pytorrent/routes/profiles.py @@ -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/") +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/") +def profiles_delete(profile_id: int): + preferences.delete_profile(profile_id) + return ok({"profiles": preferences.list_profiles(), "active": preferences.active_profile()}) + + + +@bp.post("/profiles//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//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/") +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())}) + + diff --git a/pytorrent/routes/rss.py b/pytorrent/routes/rss.py new file mode 100644 index 0000000..494077c --- /dev/null +++ b/pytorrent/routes/rss.py @@ -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/") +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/") +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)) + + diff --git a/pytorrent/routes/smart_queue.py b/pytorrent/routes/smart_queue.py new file mode 100644 index 0000000..bfad5fa --- /dev/null +++ b/pytorrent/routes/smart_queue.py @@ -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 + diff --git a/pytorrent/routes/system.py b/pytorrent/routes/system.py new file mode 100644 index 0000000..100e314 --- /dev/null +++ b/pytorrent/routes/system.py @@ -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//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//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//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': []}}) + diff --git a/pytorrent/routes/torrents.py b/pytorrent/routes/torrents.py new file mode 100644 index 0000000..c1a5504 --- /dev/null +++ b/pytorrent/routes/torrents.py @@ -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/") + +@bp.get("/tracker-favicon/") +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//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//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//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//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//files//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//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-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//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//chunks/") +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//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//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//trackers/") +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/") +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 "", + ) + + diff --git a/pytorrent/services/auth.py b/pytorrent/services/auth.py new file mode 100644 index 0000000..1e3810a --- /dev/null +++ b/pytorrent/services/auth.py @@ -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 diff --git a/pytorrent/services/automation_rules.py b/pytorrent/services/automation_rules.py new file mode 100644 index 0000000..e9aeb8c --- /dev/null +++ b/pytorrent/services/automation_rules.py @@ -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} diff --git a/pytorrent/services/backup.py b/pytorrent/services/backup.py new file mode 100644 index 0000000..cbb4e4a --- /dev/null +++ b/pytorrent/services/backup.py @@ -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 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() diff --git a/pytorrent/services/disk_guard.py b/pytorrent/services/disk_guard.py new file mode 100644 index 0000000..b997307 --- /dev/null +++ b/pytorrent/services/disk_guard.py @@ -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')})" + ) diff --git a/pytorrent/services/download_planner.py b/pytorrent/services/download_planner.py new file mode 100644 index 0000000..fa36486 --- /dev/null +++ b/pytorrent/services/download_planner.py @@ -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) diff --git a/pytorrent/services/frontend_assets.py b/pytorrent/services/frontend_assets.py new file mode 100644 index 0000000..4a3fc51 --- /dev/null +++ b/pytorrent/services/frontend_assets.py @@ -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}" + ) diff --git a/pytorrent/services/geoip.py b/pytorrent/services/geoip.py new file mode 100644 index 0000000..6cd9342 --- /dev/null +++ b/pytorrent/services/geoip.py @@ -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": ""} diff --git a/pytorrent/services/poller_control.py b/pytorrent/services/poller_control.py new file mode 100644 index 0000000..8df4b42 --- /dev/null +++ b/pytorrent/services/poller_control.py @@ -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}) diff --git a/pytorrent/services/preferences.py b/pytorrent/services/preferences.py new file mode 100644 index 0000000..eb16ea6 --- /dev/null +++ b/pytorrent/services/preferences.py @@ -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) diff --git a/pytorrent/services/ratio_rules.py b/pytorrent/services/ratio_rules.py new file mode 100644 index 0000000..e3daae7 --- /dev/null +++ b/pytorrent/services/ratio_rules.py @@ -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() diff --git a/pytorrent/services/retention.py b/pytorrent/services/retention.py new file mode 100644 index 0000000..db9c707 --- /dev/null +++ b/pytorrent/services/retention.py @@ -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 diff --git a/pytorrent/services/rss.py b/pytorrent/services/rss.py new file mode 100644 index 0000000..5e861db --- /dev/null +++ b/pytorrent/services/rss.py @@ -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() diff --git a/pytorrent/services/rtorrent/README.md b/pytorrent/services/rtorrent/README.md new file mode 100644 index 0000000..c276e1f --- /dev/null +++ b/pytorrent/services/rtorrent/README.md @@ -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. diff --git a/pytorrent/services/rtorrent/__init__.py b/pytorrent/services/rtorrent/__init__.py new file mode 100644 index 0000000..143fe1d --- /dev/null +++ b/pytorrent/services/rtorrent/__init__.py @@ -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 * diff --git a/pytorrent/services/rtorrent/chunks.py b/pytorrent/services/rtorrent/chunks.py new file mode 100644 index 0000000..da8c8a3 --- /dev/null +++ b/pytorrent/services/rtorrent/chunks.py @@ -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"} +] diff --git a/pytorrent/services/rtorrent/client.py b/pytorrent/services/rtorrent/client.py new file mode 100644 index 0000000..0069c3c --- /dev/null +++ b/pytorrent/services/rtorrent/client.py @@ -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('__')] diff --git a/pytorrent/services/rtorrent/config.py b/pytorrent/services/rtorrent/config.py new file mode 100644 index 0000000..4494bd3 --- /dev/null +++ b/pytorrent/services/rtorrent/config.py @@ -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"} +] diff --git a/pytorrent/services/rtorrent/diagnostics.py b/pytorrent/services/rtorrent/diagnostics.py new file mode 100644 index 0000000..f49cf0e --- /dev/null +++ b/pytorrent/services/rtorrent/diagnostics.py @@ -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"} +] diff --git a/pytorrent/services/rtorrent/files.py b/pytorrent/services/rtorrent/files.py new file mode 100644 index 0000000..900f2d8 --- /dev/null +++ b/pytorrent/services/rtorrent/files.py @@ -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"} +] diff --git a/pytorrent/services/rtorrent/shared.py b/pytorrent/services/rtorrent/shared.py new file mode 100644 index 0000000..4a7729e --- /dev/null +++ b/pytorrent/services/rtorrent/shared.py @@ -0,0 +1,4 @@ +from __future__ import annotations + +# Note: Backward-compatible internal alias for modules created during refactor. +from .client import * diff --git a/pytorrent/services/rtorrent/system.py b/pytorrent/services/rtorrent/system.py new file mode 100644 index 0000000..9a04e0b --- /dev/null +++ b/pytorrent/services/rtorrent/system.py @@ -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"} +] diff --git a/pytorrent/services/rtorrent/torrents.py b/pytorrent/services/rtorrent/torrents.py new file mode 100644 index 0000000..8d4d177 --- /dev/null +++ b/pytorrent/services/rtorrent/torrents.py @@ -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"} +] diff --git a/pytorrent/services/rtorrent_original_TO_DELETE b/pytorrent/services/rtorrent_original_TO_DELETE new file mode 100644 index 0000000..7d9ca71 --- /dev/null +++ b/pytorrent/services/rtorrent_original_TO_DELETE @@ -0,0 +1,1993 @@ +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") + + +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} + + +def browse_path(profile: dict, path: str | None = None) -> dict: + """List directories through rTorrent execute.capture to avoid pyTorrent FS permissions.""" + c = client_for(profile) + base = _remote_clean_path(path or default_download_path(profile)) + script = ( + 'base=$1; ' + '[ -d "$base" ] || exit 2; ' + 'for p in "$base"/* "$base"/.[!.]* "$base"/..?*; do ' + '[ -d "$p" ] || continue; ' + 'name=${p##*/}; ' + 'printf "%s\t%s\n" "$name" "$p"; ' + 'done' + ) + output = _rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-browse", base) + dirs = [] + for line in str(output or "").splitlines(): + if "\t" not in line: + continue + name, full_path = line.split("\t", 1) + if name not in {".", ".."}: + dirs.append({"name": name, "path": full_path}) + dirs.sort(key=lambda x: x["name"].lower()) + parent = posixpath.dirname(base.rstrip("/")) or "/" + if parent == base: + parent = base + return {"path": base, "parent": parent, "dirs": dirs[:300], "source": "rtorrent"} + + +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 _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" + 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), + "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] + + +_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 + + +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 + + +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_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) -> dict: + c = client_for(profile) + 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 + rows = list_torrents(profile) + # Note: ruTorrent-style footer metrics. Missing XMLRPC methods are shown as unavailable instead of breaking polling. + open_sockets = _safe_rtorrent_int(lambda: c.network.open_sockets()) + max_open_sockets = _safe_rtorrent_int(lambda: c.network.max_open_sockets()) + rtorrent_time = _safe_rtorrent_time(c) + checking_count = sum(1 for t in rows if t.get("status") == "Checking" or int(t.get("hashing") or 0) > 0) + return { + "ok": True, + "version": 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": 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 "∞", + "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": open_sockets, + "max_open_sockets": max_open_sockets, + "rtorrent_time": rtorrent_time, + "disk": disk_usage_for_default_path(profile), + } + + +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 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_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 + +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}") + + +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": "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 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) + + +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) -> dict: + payload = payload or {} + 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() + for h in torrent_hashes: + c.call("d.custom1.set", h, label) + return {"ok": True, "count": len(torrent_hashes), "label": label} + if name == "set_ratio_group": + group = str(payload.get("ratio_group") or "").strip() + for h in torrent_hashes: + c.call("d.custom.set", h, "py_ratio_group", group) + return {"ok": True, "count": len(torrent_hashes), "ratio_group": group} + 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 = [] + if move_data: + _rt_execute_allow_timeout(c, "execute.throw", "mkdir", "-p", path) + for h in torrent_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: + 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) + 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 = [pause_hash(c, h) for h in torrent_hashes] + 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 = [resume_paused_hash(c, h) for h in torrent_hashes] + 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 = [start_or_resume_hash(c, h) for h in torrent_hashes] + 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 = [] + for h in torrent_hashes: + if remove_data: + results.append(_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) + 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} diff --git a/pytorrent/services/smart_queue.py b/pytorrent/services/smart_queue.py new file mode 100644 index 0000000..2c57714 --- /dev/null +++ b/pytorrent/services/smart_queue.py @@ -0,0 +1,1438 @@ +from __future__ import annotations + +from collections import Counter +from datetime import datetime, timezone +from typing import Any +import json +import os +import time + +from ..config import BASE_DIR, SMART_QUEUE_LABEL, SMART_QUEUE_STALLED_LABEL +from ..db import connect, default_user_id, utcnow +from . import rtorrent +from .preferences import active_profile, get_profile + + +SMART_QUEUE_START_BATCH_SIZE = 40 +SMART_QUEUE_START_BATCH_PAUSE_SECONDS = 0.75 +SMART_QUEUE_START_VERIFY_ATTEMPTS = 30 +SMART_QUEUE_START_VERIFY_DELAY_SECONDS = 2.0 +SMART_QUEUE_DIAGNOSTICS_LOG = BASE_DIR / 'data' / 'smart_queue.log' +SMART_QUEUE_DIAGNOSTICS_MAX_ITEMS = 200 + + +def _diagnostics_mode() -> str: + raw = os.getenv('PYTORRENT_SMART_QUEUE_DIAGNOSTICS', 'none').strip().lower() + aliases = { + '': 'none', + '0': 'none', + 'false': 'none', + 'off': 'none', + 'disabled': 'none', + 'debbug': 'debug', + 'full': 'debug', + '1': 'debug', + 'true': 'debug', + 'yes': 'debug', + 'on': 'debug', + } + mode = aliases.get(raw, raw) + return mode if mode in {'none', 'short', 'debug'} else 'none' + + +def _diagnostics_max_items() -> int: + try: + return max(1, int(os.getenv('PYTORRENT_SMART_QUEUE_DIAGNOSTICS_MAX_ITEMS', str(SMART_QUEUE_DIAGNOSTICS_MAX_ITEMS)))) + except (TypeError, ValueError): + return SMART_QUEUE_DIAGNOSTICS_MAX_ITEMS + + +def _diagnostics_sample(items: list[Any] | tuple[Any, ...] | set[Any], limit: int | None = None) -> list[Any]: + max_items = _diagnostics_max_items() if limit is None else max(1, int(limit)) + return list(items)[:max_items] + + +def _diagnostics_torrent(t: dict[str, Any] | None) -> dict[str, Any]: + if not t: + return {} + return { + 'hash': str(t.get('hash') or ''), + 'name': str(t.get('name') or ''), + 'state': int(t.get('state') or 0), + 'active': int(t.get('active') or 0), + 'complete': int(t.get('complete') or 0), + 'status': str(t.get('status') or ''), + 'paused': bool(t.get('paused')), + 'hashing': int(t.get('hashing') or 0), + 'priority': int(t.get('priority') or 0), + 'down_rate': int(t.get('down_rate') or 0), + 'peers': int(t.get('peers') or 0), + 'seeds': int(t.get('seeds') or 0), + 'label': str(t.get('label') or ''), + 'message': str(t.get('message') or ''), + } + + +def _diagnostics_torrents(torrents: list[dict[str, Any]], limit: int | None = None) -> list[dict[str, Any]]: + return [_diagnostics_torrent(t) for t in _diagnostics_sample(torrents, limit)] + + +def _pending_reason_counts(items: list[dict[str, Any]]) -> dict[str, int]: + return dict(Counter(str(item.get('pending_reason') or 'unknown') for item in items)) + + +def _hash_sample(values: list[str] | set[str], limit: int = 100) -> list[str]: + """Return a bounded hash list for UI logs without storing oversized diagnostics.""" + return [str(v) for v in list(values)[:max(1, int(limit))] if str(v)] + + +def _decision_text(stopped: int, started: int, stalled_detected: int, stalled_stopped: int, protected_stalled: int) -> str: + """Build a compact Smart Queue decision label for the history table.""" + parts = [f"stopped {stopped}", f"started {started}"] + if stalled_detected: + stalled_part = f"stalled {stalled_stopped}/{stalled_detected} stopped" + if protected_stalled: + stalled_part += f", {protected_stalled} protected" + parts.append(stalled_part) + return "; ".join(parts) + + +def _diagnostics_write(event: str, summary: dict[str, Any], debug: dict[str, Any] | None = None) -> None: + mode = _diagnostics_mode() + if mode == 'none': + return + payload: dict[str, Any] = { + 'timestamp': utcnow(), + 'event': event, + 'mode': mode, + **summary, + } + if mode == 'debug' and debug: + payload['debug'] = debug + try: + SMART_QUEUE_DIAGNOSTICS_LOG.parent.mkdir(parents=True, exist_ok=True) + with SMART_QUEUE_DIAGNOSTICS_LOG.open('a', encoding='utf-8') as handle: + handle.write(json.dumps(payload, ensure_ascii=False, default=str, sort_keys=True) + '\n') + except Exception: + # Diagnostics must never break Smart Queue execution. + return + + +def _ts(value: str | None) -> float: + if not value: + return 0.0 + try: + return datetime.fromisoformat(value.replace('Z', '+00:00')).timestamp() + except Exception: + return 0.0 + + +def _int_setting(data: dict[str, Any], current: dict[str, Any], key: str, default: int, minimum: int = 0) -> int: + raw = data.get(key) if key in data else current.get(key) + try: + return max(minimum, int(raw if raw is not None and raw != '' else default)) + except (TypeError, ValueError): + return max(minimum, int(default)) + + +def _default_settings(user_id: int, profile_id: int) -> dict[str, Any]: + return { + 'user_id': user_id, + 'profile_id': profile_id, + 'enabled': 0, + 'max_active_downloads': 5, + 'stalled_seconds': 300, + 'min_speed_bytes': 1024, + 'min_seeds': 1, + 'min_peers': 0, + 'ignore_seed_peer': 0, + 'ignore_speed': 0, + 'manage_stopped': 1, + 'cooldown_minutes': 10, + 'last_run_at': None, + 'refill_enabled': 1, + 'refill_interval_minutes': 0, + 'last_refill_at': None, + 'stop_batch_size': 50, + 'start_grace_seconds': 900, + 'protect_active_below_cap': 1, + 'auto_stop_idle': 0, + 'updated_at': utcnow(), + } + + +def get_settings(profile_id: int, user_id: int | None = None) -> dict[str, Any]: + user_id = user_id or default_user_id() + with connect() as conn: + row = conn.execute( + 'SELECT * FROM smart_queue_settings WHERE user_id=? AND profile_id=?', + (user_id, profile_id), + ).fetchone() + settings = dict(row or _default_settings(user_id, profile_id)) + return settings + + +def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = None) -> dict[str, Any]: + user_id = user_id or default_user_id() + current = get_settings(profile_id, user_id) + settings = { + 'enabled': 1 if data.get('enabled', current.get('enabled')) else 0, + 'max_active_downloads': _int_setting(data, current, 'max_active_downloads', 5, 1), + 'stalled_seconds': _int_setting(data, current, 'stalled_seconds', 300, 30), + 'min_speed_bytes': _int_setting(data, current, 'min_speed_bytes', 0, 0), + 'min_seeds': _int_setting(data, current, 'min_seeds', 0, 0), + # Note: Min peers is optional; when set, stalled detection requires low speed, low seeds and low peers. + 'min_peers': _int_setting(data, current, 'min_peers', 0, 0), + # Note: Ignore seed/peer removes source counts from stalled detection; start attempts do not rely on stale source counts. + 'ignore_seed_peer': 1 if data.get('ignore_seed_peer', current.get('ignore_seed_peer')) else 0, + # Note: Ignore speed removes low transfer rate from stalled detection; with both ignores enabled only the stalled timer matters. + 'ignore_speed': 1 if data.get('ignore_speed', current.get('ignore_speed')) else 0, + # Note: Compatibility field retained; enabled Smart Queue always manages stopped torrents and never manages user-paused torrents. + 'manage_stopped': 1, + # Note: User-visible cooldown limits noisy Smart Queue runs while manual checks can still force execution. + 'cooldown_minutes': _int_setting(data, current, 'cooldown_minutes', 10, 1), + # Note: Limits one Smart Queue pass from stopping too many stalled items at once. + 'stop_batch_size': _int_setting(data, current, 'stop_batch_size', 50, 1), + # Note: Newly queue-started torrents are protected from stalled detection while rTorrent and trackers settle. + 'start_grace_seconds': _int_setting(data, current, 'start_grace_seconds', 900, 0), + # Note: When below the target cap, prefer refilling first instead of reducing active slots by stopping stalled downloads. + 'protect_active_below_cap': 1 if data.get('protect_active_below_cap', current.get('protect_active_below_cap', 1)) else 0, + # Note: Optional safety valve that disables Smart Queue when there are no active or waiting downloads to manage. + 'auto_stop_idle': 1 if data.get('auto_stop_idle', current.get('auto_stop_idle', 0)) else 0, + } + refill_mode = str(data.get('refill_mode') or '').strip().lower() + if refill_mode not in {'auto', 'custom', 'off'}: + if not int(current.get('refill_enabled') or 0): + refill_mode = 'off' + elif int(current.get('refill_interval_minutes') or 0) > 0: + refill_mode = 'custom' + else: + refill_mode = 'auto' + # Note: Refill can be disabled, use the existing poller cadence, or run on a user-defined minute interval. + settings['refill_enabled'] = 0 if refill_mode == 'off' else 1 + settings['refill_interval_minutes'] = _int_setting(data, current, 'refill_interval_minutes', 5, 1) if refill_mode == 'custom' else 0 + now = utcnow() + with connect() as conn: + conn.execute( + '''INSERT INTO smart_queue_settings(user_id,profile_id,enabled,max_active_downloads,stalled_seconds,min_speed_bytes,min_seeds,min_peers,ignore_seed_peer,ignore_speed,manage_stopped,cooldown_minutes,stop_batch_size,start_grace_seconds,protect_active_below_cap,auto_stop_idle,refill_enabled,refill_interval_minutes,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(user_id, profile_id) DO UPDATE SET + enabled=excluded.enabled, + max_active_downloads=excluded.max_active_downloads, + stalled_seconds=excluded.stalled_seconds, + min_speed_bytes=excluded.min_speed_bytes, + min_seeds=excluded.min_seeds, + min_peers=excluded.min_peers, + ignore_seed_peer=excluded.ignore_seed_peer, + ignore_speed=excluded.ignore_speed, + manage_stopped=excluded.manage_stopped, + cooldown_minutes=excluded.cooldown_minutes, + stop_batch_size=excluded.stop_batch_size, + start_grace_seconds=excluded.start_grace_seconds, + protect_active_below_cap=excluded.protect_active_below_cap, + auto_stop_idle=excluded.auto_stop_idle, + refill_enabled=excluded.refill_enabled, + refill_interval_minutes=excluded.refill_interval_minutes, + updated_at=excluded.updated_at''', + (user_id, profile_id, settings['enabled'], settings['max_active_downloads'], settings['stalled_seconds'], settings['min_speed_bytes'], settings['min_seeds'], settings['min_peers'], settings['ignore_seed_peer'], settings['ignore_speed'], settings['manage_stopped'], settings['cooldown_minutes'], settings['stop_batch_size'], settings['start_grace_seconds'], settings['protect_active_below_cap'], settings['auto_stop_idle'], settings['refill_enabled'], settings['refill_interval_minutes'], now), + ) + return get_settings(profile_id, user_id) + + +def list_exclusions(profile_id: int, user_id: int | None = None) -> list[dict[str, Any]]: + user_id = user_id or default_user_id() + with connect() as conn: + return conn.execute( + 'SELECT * FROM smart_queue_exclusions WHERE user_id=? AND profile_id=? ORDER BY created_at DESC', + (user_id, profile_id), + ).fetchall() + + +def set_exclusion(profile_id: int, torrent_hash: str, excluded: bool, reason: str = '', user_id: int | None = None) -> None: + user_id = user_id or default_user_id() + now = utcnow() + with connect() as conn: + if excluded: + conn.execute( + 'INSERT OR REPLACE INTO smart_queue_exclusions(user_id,profile_id,torrent_hash,reason,created_at) VALUES(?,?,?,?,?)', + (user_id, profile_id, torrent_hash, reason, now), + ) + else: + conn.execute( + 'DELETE FROM smart_queue_exclusions WHERE user_id=? AND profile_id=? AND torrent_hash=?', + (user_id, profile_id, torrent_hash), + ) + + + +def add_history(profile_id: int, event: str, paused: list[str] | None = None, resumed: list[str] | None = None, checked: int = 0, details: dict[str, Any] | None = None, user_id: int | None = None) -> None: + user_id = user_id or default_user_id() + paused = paused or [] + resumed = resumed or [] + details = details or {} + with connect() as conn: + conn.execute( + 'INSERT INTO smart_queue_history(user_id,profile_id,event,paused_count,resumed_count,checked_count,details_json,created_at) VALUES(?,?,?,?,?,?,?,?)', + (user_id, profile_id, event, len(paused), len(resumed), int(checked or 0), json.dumps({**details, 'paused': paused, 'resumed': resumed}), utcnow()), + ) + +def list_history(profile_id: int, user_id: int | None = None, limit: int = 30) -> list[dict[str, Any]]: + user_id = user_id or default_user_id() + with connect() as conn: + return conn.execute( + 'SELECT * FROM smart_queue_history WHERE user_id=? AND profile_id=? ORDER BY created_at DESC LIMIT ?', + (user_id, profile_id, max(1, min(int(limit or 30), 100))), + ).fetchall() + + +def clear_history(profile_id: int, user_id: int | None = None) -> int: + """Delete Smart Queue history rows for the current profile and return the removed count.""" + # Note: Manual cleanup only removes audit history; settings, exclusions and pending queue state stay untouched. + user_id = user_id or default_user_id() + with connect() as conn: + row = conn.execute( + 'SELECT COUNT(*) AS count FROM smart_queue_history WHERE user_id=? AND profile_id=?', + (user_id, profile_id), + ).fetchone() + count = int((row or {}).get('count') or 0) + conn.execute( + 'DELETE FROM smart_queue_history WHERE user_id=? AND profile_id=?', + (user_id, profile_id), + ) + return count + + +def count_history(profile_id: int, user_id: int | None = None) -> int: + user_id = user_id or default_user_id() + with connect() as conn: + row = conn.execute( + 'SELECT COUNT(*) AS count FROM smart_queue_history WHERE user_id=? AND profile_id=?', + (user_id, profile_id), + ).fetchone() + return int((row or {}).get('count') or 0) + +def _excluded_hashes(profile_id: int, user_id: int) -> set[str]: + return {r['torrent_hash'] for r in list_exclusions(profile_id, user_id)} + + + +def _label_names(value: str | None) -> 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: + output: list[str] = [] + for label in labels: + item = str(label or '').strip() + if item and item not in output: + output.append(item) + return ', '.join(output) + + +def _has_smart_queue_label(value: str | None) -> bool: + return SMART_QUEUE_LABEL in _label_names(value) + + +def _without_smart_queue_label(value: str | None) -> str: + return _label_value([label for label in _label_names(value) if label != SMART_QUEUE_LABEL]) + + +def _smart_queue_label_cleanup_value(live_label: str | None, previous_label: str | None = None) -> str: + """Return label value with only the Smart Queue technical marker removed. + + User labels present in rTorrent are preserved. The previous-label fallback is used only + when the live value contains no user label after removing the technical marker, which + protects torrents that were labeled by older builds that overwrote custom1. + """ + live_user_labels = [label for label in _label_names(live_label) if label != SMART_QUEUE_LABEL] + if live_user_labels: + return _label_value(live_user_labels) + previous_user_labels = [label for label in _label_names(previous_label) if label != SMART_QUEUE_LABEL] + return _label_value(previous_user_labels) + + +def _has_stalled_label(value: str | None) -> bool: + # Note: Stalled is treated case-insensitively so manually edited labels still block Smart Queue. + target = SMART_QUEUE_STALLED_LABEL.casefold() + return any(label.casefold() == target for label in _label_names(value)) + + +def _without_queue_technical_labels(value: str | None) -> str: + return _label_value([label for label in _label_names(value) if label != SMART_QUEUE_LABEL]) + + +def _ensure_stalled_label(client: Any, torrent_hash: str, current_label: str = '') -> bool: + labels = [label for label in _label_names(current_label) if label != SMART_QUEUE_LABEL] + changed = False + if not any(label.casefold() == SMART_QUEUE_STALLED_LABEL.casefold() for label in labels): + labels.append(SMART_QUEUE_STALLED_LABEL) + changed = True + if SMART_QUEUE_LABEL in _label_names(current_label): + changed = True + if not changed: + return True + try: + # Note: Stalled marking is idempotent; it adds Stalled and removes only the Smart Queue technical marker. + client.call('d.custom1.set', torrent_hash, _label_value(labels)) + return True + except Exception: + return False + +def _remember_auto_label(profile_id: int, torrent_hash: str, previous_label: str) -> None: + now = utcnow() + with connect() as conn: + row = conn.execute( + 'SELECT previous_label FROM smart_queue_auto_labels WHERE profile_id=? AND torrent_hash=?', + (profile_id, torrent_hash), + ).fetchone() + if row: + conn.execute( + 'UPDATE smart_queue_auto_labels SET updated_at=? WHERE profile_id=? AND torrent_hash=?', + (now, profile_id, torrent_hash), + ) + else: + conn.execute( + 'INSERT INTO smart_queue_auto_labels(profile_id,torrent_hash,previous_label,created_at,updated_at) VALUES(?,?,?,?,?)', + (profile_id, torrent_hash, previous_label, now, now), + ) + + +def _read_label(client: Any, torrent_hash: str, fallback: str = '') -> str: + try: + return str(client.call('d.custom1', torrent_hash) or '') + except Exception: + return fallback + + +def _restore_auto_label(client: Any, profile_id: int, torrent_hash: str, current_label: str | None = None) -> bool: + """Remove only Smart Queue's technical marker while preserving user labels.""" + with connect() as conn: + row = conn.execute( + 'SELECT previous_label FROM smart_queue_auto_labels WHERE profile_id=? AND torrent_hash=?', + (profile_id, torrent_hash), + ).fetchone() + previous_label = str((row or {}).get('previous_label') or '') + live_label = _read_label(client, torrent_hash, current_label or '') + if not row and not _has_smart_queue_label(live_label): + return False + try: + if _has_smart_queue_label(live_label) or row: + # Note: Remove Smart Queue only. Never clear unrelated labels when a torrent enters downloading. + client.call('d.custom1.set', torrent_hash, _smart_queue_label_cleanup_value(live_label, previous_label)) + if row: + conn.execute('DELETE FROM smart_queue_auto_labels WHERE profile_id=? AND torrent_hash=?', (profile_id, torrent_hash)) + return True + except Exception: + return False + + + + + +def _call_rtorrent_setter(client: Any, method: str, value: int) -> bool: + """Set a scalar rTorrent setting while tolerating XMLRPC signature differences.""" + for args in ((int(value),), ('', int(value))): + try: + client.call(method, *args) + return True + except Exception: + continue + return False + + +def _ensure_rtorrent_download_cap(client: Any, max_active: int) -> dict[str, Any]: + """Raise rTorrent download caps that can silently limit Smart Queue to one item.""" + result: dict[str, Any] = {'checked': False, 'updated': False, 'items': []} + # Note: rTorrent may have separate global and per-throttle limits. When div=1, + # starts can effectively stop at one active torrent even when the target is 100. + for key in ('throttle.max_downloads.global', 'throttle.max_downloads.div'): + item: dict[str, Any] = {'key': key, 'checked': False, 'updated': False} + try: + current = int(client.call(key) or 0) + item.update({'checked': True, 'current': current, 'target': int(max_active)}) + result['checked'] = True + # Note: 0 means unlimited; raise only positive limits lower than the target. + if 0 < current < max_active: + ok = _call_rtorrent_setter(client, f'{key}.set', int(max_active)) + item['updated'] = ok + if ok: + result['updated'] = True + item['new'] = int(max_active) + result.setdefault('current', current) + result['new'] = int(max_active) + except Exception as exc: + item.update({'error': str(exc)}) + result['items'].append(item) + return result + + +def _start_download(client: Any, profile_id: int, torrent: dict[str, Any]) -> dict[str, Any]: + """Start only stopped Smart Queue candidates; paused torrents are a user decision.""" + h = str(torrent.get('hash') or '') + if not h: + return {'hash': h, 'ok': False, 'error': 'missing hash'} + if _is_user_paused(torrent): + # Note: Smart Queue never unpauses user-paused torrents; it manages only stopped items. + return {'hash': h, 'ok': False, 'skipped': 'user_paused'} + # Note: Remove Smart Queue's technical hold before d.open/d.start. Some rTorrent/ruTorrent setups + # attach behavior to labels, so the queue must start the same item state the manual Start sees. + label_cleanup = _restore_auto_label(client, profile_id, h, str(torrent.get('label') or '')) + # Note: Smart Queue selected this candidate as stopped, so force the real start path. + # A live state=1/active=0 after auto-check is not necessarily a user pause. + result = rtorrent.start_or_resume_hash(client, h, prefer_start=True) + result['label_cleanup'] = bool(label_cleanup) + return result + + +def _verify_started_downloads( + client: Any, + hashes: list[str], + attempts: int = SMART_QUEUE_START_VERIFY_ATTEMPTS, + delay: float = SMART_QUEUE_START_VERIFY_DELAY_SECONDS, +) -> tuple[list[str], list[dict[str, Any]]]: + """Verify started torrents with a slower, lightweight confirmation loop. + + rTorrent can accept a large batch of d.start commands immediately but expose + d.state/d.is_active gradually. The verifier therefore waits longer than the + old five-second window and polls only cheap state fields during the loop. + Detailed diagnostics are read only for torrents that still did not confirm. + """ + pending = [h for h in hashes if h] + seen_started: set[str] = set() + checks = max(1, int(attempts or 1)) + wait = max(0.1, float(delay or 0.1)) + + for attempt in range(checks): + if attempt: + time.sleep(wait) + for h in list(pending): + if _read_live_started_flag(client, h): + seen_started.add(h) + pending.remove(h) + if not pending: + break + + started = [h for h in hashes if h in seen_started] + no_effect: list[dict[str, Any]] = [] + for h in hashes: + if h and h not in seen_started: + live = _read_live_start_state(client, h) + live['verify_attempts'] = checks + live['verify_delay_seconds'] = wait + no_effect.append(live) + return started, no_effect + + +def _read_live_started_flag(client: Any, torrent_hash: str) -> bool: + """Return True when rTorrent reports that a download has left the stopped state.""" + for method in ('d.state', 'd.is_active'): + try: + if int(client.call(method, torrent_hash) or 0): + return True + except Exception: + continue + return False + + +def _start_and_verify_downloads(client: Any, profile_id: int, torrents: list[dict[str, Any]]) -> dict[str, Any]: + """Start Smart Queue candidates in moderate batches and verify them after rTorrent catches up.""" + start_failed: list[dict[str, str]] = [] + start_requested: list[str] = [] + start_results: list[dict[str, Any]] = [] + batch_size = max(1, int(SMART_QUEUE_START_BATCH_SIZE)) + pause = max(0.0, float(SMART_QUEUE_START_BATCH_PAUSE_SECONDS)) + + for offset in range(0, len(torrents), batch_size): + batch = torrents[offset:offset + batch_size] + for t in batch: + h = str(t.get('hash') or '') + if not h: + continue + try: + result = _start_download(client, profile_id, t) + start_results.append(result) + if result.get('ok', True): + start_requested.append(h) + else: + start_failed.append({'hash': h, 'error': str(result.get('error') or result.get('skipped') or 'start rejected')}) + except Exception as exc: + start_failed.append({'hash': h, 'error': str(exc)}) + if offset + batch_size < len(torrents) and pause: + time.sleep(pause) + + active_verified, start_pending_confirmation = _verify_started_downloads( + client, + start_requested, + SMART_QUEUE_START_VERIFY_ATTEMPTS, + SMART_QUEUE_START_VERIFY_DELAY_SECONDS, + ) + # Note: A successful d.start/d.resume RPC is the queue outcome. rTorrent may keep the item idle/queued + # for longer than the verification window, so unconfirmed accepted starts are pending confirmation, + # not a failed/no-effect start. + return { + 'active_verified': active_verified, + 'start_failed': start_failed, + 'start_no_effect': [], + 'start_pending_confirmation': start_pending_confirmation, + 'start_requested': start_requested, + 'start_results': start_results, + 'start_batch_size': batch_size, + 'start_batch_pause_seconds': pause, + 'start_verify_attempts': SMART_QUEUE_START_VERIFY_ATTEMPTS, + 'start_verify_delay_seconds': SMART_QUEUE_START_VERIFY_DELAY_SECONDS, + } + + +def _read_live_start_state(client: Any, torrent_hash: str) -> dict[str, Any]: + result: dict[str, Any] = {'hash': torrent_hash} + fields = ( + ('name', 'd.name'), + ('state', 'd.state'), + ('active', 'd.is_active'), + ('open', 'd.is_open'), + ('complete', 'd.complete'), + ('hashing', 'd.hashing'), + ('priority', 'd.priority'), + ('down_rate', 'd.down.rate'), + ('peers', 'd.peers_connected'), + ('seeds', 'd.peers_complete'), + ('message', 'd.message'), + ('label', 'd.custom1'), + ) + for key, method in fields: + try: + value = client.call(method, torrent_hash) + result[key] = int(value or 0) if key in {'state', 'active', 'open', 'complete', 'hashing', 'priority', 'down_rate', 'peers', 'seeds'} else str(value or '') + except Exception as exc: + result[f'{key}_error'] = str(exc) + # Note: Manual Start in rTorrent is successful when d.state becomes 1. + # d.is_active can stay 0 for queued/idle downloads, so it must not be used as the only success check. + result['started'] = bool(int(result.get('state') or 0) or int(result.get('active') or 0)) + result['pending_reason'] = _classify_pending_start_state(result) + return result + + +def _classify_pending_start_state(state: dict[str, Any]) -> str: + if any(str(key).endswith('_error') for key in state): + return 'rpc_error' + if int(state.get('hashing') or 0): + return 'checking' + if int(state.get('complete') or 0): + return 'complete' + if int(state.get('priority') or 0) <= 0: + return 'priority_off' + if not int(state.get('state') or 0): + return 'stopped' if int(state.get('open') or 0) else 'closed' + if int(state.get('seeds') or 0) <= 0 and int(state.get('peers') or 0) <= 0: + return 'no_sources' + if str(state.get('message') or '').strip(): + return 'message' + if not int(state.get('active') or 0): + return 'inactive' + return 'unknown' + + +def _is_user_paused(torrent: dict[str, Any]) -> bool: + """Return True for torrents paused by the user; Smart Queue must not touch them.""" + status = str(torrent.get('status') or '').lower() + return bool(torrent.get('paused')) or status == 'paused' + +def _set_smart_queue_label(client: Any, torrent_hash: str, current_label: str = '', attempts: int = 3) -> bool: + for attempt in range(max(1, attempts)): + try: + # Always merge with the live rTorrent label. The snapshot passed by Smart Queue can be + # stale when a user labels a newly added torrent around the same time as auto-check/refill. + live_label = _read_label(client, torrent_hash, current_label or '') + labels = _label_names(live_label) + if SMART_QUEUE_LABEL in labels: + return True + labels.append(SMART_QUEUE_LABEL) + client.call('d.custom1.set', torrent_hash, _label_value(labels)) + return True + except Exception: + if attempt < attempts - 1: + time.sleep(0.05) + return False + + +def _mark_auto_stopped(client: Any, profile_id: int, torrent: dict[str, Any]) -> bool: + torrent_hash = str(torrent.get('hash') or '') + if not torrent_hash: + return False + previous = _read_label(client, torrent_hash, str(torrent.get('label') or '')) + if not _has_smart_queue_label(previous): + _remember_auto_label(profile_id, torrent_hash, previous) + return _set_smart_queue_label(client, torrent_hash, previous) + + + +def _record_start_grace(profile_id: int, hashes: list[str]) -> None: + """Remember queue-started torrents so stalled detection gives them a warm-up window.""" + clean = [str(h or '').strip() for h in hashes if str(h or '').strip()] + if not clean: + return + now = utcnow() + with connect() as conn: + for torrent_hash in clean: + conn.execute( + 'INSERT OR REPLACE INTO smart_queue_start_grace(profile_id,torrent_hash,started_at,updated_at) VALUES(?,?,?,?)', + (profile_id, torrent_hash, now, now), + ) + + +def _load_active_start_grace(profile_id: int, grace_seconds: int, now_ts: float) -> set[str]: + """Return hashes still inside the post-start warm-up window and purge expired rows.""" + grace = max(0, int(grace_seconds or 0)) + if grace <= 0: + with connect() as conn: + conn.execute('DELETE FROM smart_queue_start_grace WHERE profile_id=?', (profile_id,)) + return set() + active: set[str] = set() + expired: list[str] = [] + with connect() as conn: + rows = conn.execute('SELECT torrent_hash, started_at FROM smart_queue_start_grace WHERE profile_id=?', (profile_id,)).fetchall() + for row in rows: + torrent_hash = str(row.get('torrent_hash') or '') + if not torrent_hash: + continue + if now_ts - _ts(row.get('started_at')) < grace: + active.add(torrent_hash) + else: + expired.append(torrent_hash) + for torrent_hash in expired: + conn.execute('DELETE FROM smart_queue_start_grace WHERE profile_id=? AND torrent_hash=?', (profile_id, torrent_hash)) + return active + + +def _is_started_download_slot(torrent: dict[str, Any] | None) -> bool: + """Return True for incomplete torrents already started in rTorrent, including manual starts.""" + if not torrent or int(torrent.get('complete') or 0): + return False + status = str(torrent.get('status') or '').lower() + if status == 'checking': + return False + # Note: Manual Start changes d.state first; d.is_active may stay 0 while rTorrent is queued or idle. + return bool(int(torrent.get('state') or 0) or int(torrent.get('active') or 0)) + + +def _is_smart_queue_hold(torrent: dict[str, Any] | None, manage_stopped: bool = True) -> bool: + if not torrent or int(torrent.get('complete') or 0): + return False + if _is_started_download_slot(torrent): + # Note: A manual start can leave the Smart Queue label behind; started items are active slots, not holds. + return False + if _has_stalled_label(str(torrent.get('label') or '')): + return False + if _is_user_paused(torrent): + # Note: Paused torrents are always treated as user-controlled and are not Smart Queue holds. + return False + if _has_smart_queue_label(str(torrent.get('label') or '')): + return True + # Note: Smart Queue manages stopped torrents by default; the old manage_stopped flag is ignored for compatibility. + return not int(torrent.get('state') or 0) + + +def _clear_untracked_smart_queue_label(client: Any, torrent_hash: str, current_label: str) -> bool: + if not _has_smart_queue_label(current_label): + return False + try: + # Note: Clear only the orphaned Smart Queue marker and keep unrelated labels intact. + client.call('d.custom1.set', torrent_hash, _smart_queue_label_cleanup_value(current_label)) + return True + except Exception: + return False + + +def _cleanup_auto_labels(client: Any, profile_id: int, torrents: list[dict[str, Any]], keep_hashes: set[str], manage_stopped: bool = True) -> list[str]: + by_hash = {str(t.get('hash') or ''): t for t in torrents} + restored: list[str] = [] + with connect() as conn: + rows = conn.execute('SELECT torrent_hash FROM smart_queue_auto_labels WHERE profile_id=?', (profile_id,)).fetchall() + tracked_hashes = {str(row.get('torrent_hash') or '') for row in rows if row.get('torrent_hash')} + + for row in rows: + h = str(row.get('torrent_hash') or '') + t = by_hash.get(h) + if not h or h in keep_hashes: + continue + current_label = '' if t is None else str(t.get('label') or '') + if not _is_smart_queue_hold(t, manage_stopped): + if _restore_auto_label(client, profile_id, h, None if t is None else current_label): + restored.append(h) + continue + if not _has_smart_queue_label(current_label): + _set_smart_queue_label(client, h, current_label) + + for h, t in by_hash.items(): + if not h or h in keep_hashes or h in tracked_hashes or _is_smart_queue_hold(t, manage_stopped): + continue + if _clear_untracked_smart_queue_label(client, h, str(t.get('label') or '')): + restored.append(h) + return restored + + +def _is_running_download_slot(t: dict[str, Any]) -> bool: + """Return True for incomplete torrents that already occupy a Smart Queue slot.""" + # Note: Do not exclude Smart Queue/Stalled labels here. Manual Start can leave old labels, + # and those torrents still must count toward the global Smart Queue limit. + return _is_started_download_slot(t) + + +def _is_stalled_download(t: dict[str, Any], min_speed: int, min_seeds: int, min_peers: int, ignore_seed_peer: bool, ignore_speed: bool) -> bool: + """Return True when a started torrent should begin or continue the stalled timer.""" + # Note: Each ignore switch removes only its own criterion; the stalled timer still applies after criteria match. + speed_ok = True if ignore_speed else int(t.get('down_rate') or 0) <= max(0, int(min_speed or 0)) + source_ok = True if ignore_seed_peer else int(t.get('seeds') or 0) <= max(0, int(min_seeds or 0)) and (min_peers <= 0 or int(t.get('peers') or 0) <= min_peers) + return speed_ok and source_ok + + +def _stalled_timer_key(min_speed: int, min_seeds: int, min_peers: int, stalled_seconds: int, ignore_seed_peer: bool, ignore_speed: bool) -> str: + """Return a stable key for the stalled rules that started the current timer.""" + # Note: Changing ignore switches or thresholds restarts existing stalled timers instead of reusing old rows. + return f"v4|speed={int(min_speed or 0)}|seeds={int(min_seeds or 0)}|peers={int(min_peers or 0)}|seconds={int(stalled_seconds or 0)}|ignore_sources={int(bool(ignore_seed_peer))}|ignore_speed={int(bool(ignore_speed))}" + + +def _is_low_activity_download(t: dict[str, Any], min_speed: int, min_seeds: int, min_peers: int, ignore_seed_peer: bool = False, ignore_speed: bool = False) -> bool: + """Return True when a started torrent is weak and should be stopped first.""" + # Note: Stop priority uses only criteria that are not ignored, so disabled criteria cannot stop torrents earlier. + low_speed = False if ignore_speed else int(t.get('down_rate') or 0) <= max(0, int(min_speed or 0)) + low_seeds = False if ignore_seed_peer else int(t.get('seeds') or 0) <= max(0, int(min_seeds or 0)) + low_peers = False if ignore_seed_peer or min_peers <= 0 else int(t.get('peers') or 0) <= max(0, int(min_peers or 0)) + return low_speed or low_seeds or low_peers + + +def _is_waiting_download_candidate(t: dict[str, Any], manage_stopped: bool) -> bool: + """Return True for stopped torrents Smart Queue may start later.""" + if int(t.get('complete') or 0): + return False + if str(t.get('status') or '').lower() == 'checking': + # Note: Torrents still being checked must finish post-check handling before Smart Queue may start them. + return False + if _has_stalled_label(str(t.get('label') or '')): + return False + if _is_user_paused(t): + # Note: User-paused torrents are never candidates, even when they have no Smart Queue label. + return False + if _has_smart_queue_label(str(t.get('label') or '')): + return True + # Note: Enabled Smart Queue manages all stopped torrents; no separate stopped-torrent switch is needed. + return not int(t.get('state') or 0) + + + +def _split_start_candidates(torrents: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """Return all stopped torrents as start candidates without relying on stale source counts.""" + # Note: rTorrent/tracker source counts can be missing before announce, so start decisions are not filtered by seeds or peers. + return list(torrents), [] + + +def cooldown_remaining(settings: dict[str, Any]) -> int: + # Note: Returns seconds remaining until the next automatic Smart Queue run is allowed. + last = _ts(settings.get('last_run_at')) + minutes = max(1, int(settings.get('cooldown_minutes') or 10)) + if not last: + return 0 + return max(0, int((last + minutes * 60) - time.time())) + + + +def refill_remaining(settings: dict[str, Any]) -> int: + # Note: Custom refill interval is separate from the full Smart Queue cooldown. + if not int(settings.get('refill_enabled') or 0): + return 0 + minutes = int(settings.get('refill_interval_minutes') or 0) + if minutes <= 0: + return 0 + last = _ts(settings.get('last_refill_at')) + if not last: + return 0 + return max(0, int((last + minutes * 60) - time.time())) + + +def _refill_mode(settings: dict[str, Any]) -> str: + # Note: Expose one stable frontend mode while storing compact database fields. + if not int(settings.get('refill_enabled') or 0): + return 'off' + return 'custom' if int(settings.get('refill_interval_minutes') or 0) > 0 else 'auto' + + +def _mark_refill_run(profile_id: int, user_id: int) -> None: + # Note: Custom refill interval is measured from the last lightweight refill attempt. + with connect() as conn: + conn.execute('UPDATE smart_queue_settings SET last_refill_at=?, updated_at=? WHERE user_id=? AND profile_id=?', (utcnow(), utcnow(), user_id, profile_id)) + + +def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_id: int, user_id: int) -> dict[str, Any]: + """Fill free Smart Queue slots during cooldown without running full stalled/stop logic.""" + # Note: This lightweight pass fixes queue starvation after downloads finish or new stopped torrents are added. + torrents = rtorrent.list_torrents(profile) + user_excluded = _excluded_hashes(profile_id, user_id) + max_active = max(1, int(settings.get('max_active_downloads') or 5)) + min_seeds = int(settings.get('min_seeds') or 0) + min_peers = int(settings.get('min_peers') or 0) + stalled_label_hashes = {str(t.get('hash') or '') for t in torrents if _has_stalled_label(str(t.get('label') or '')) and t.get('hash')} + downloading = [ + t for t in torrents + if _is_running_download_slot(t) + and str(t.get('hash') or '') not in user_excluded + ] + stopped = [ + t for t in torrents + if str(t.get('hash') or '') not in user_excluded + and str(t.get('hash') or '') not in stalled_label_hashes + and _is_waiting_download_candidate(t, True) + and not _is_running_download_slot(t) + ] + if int(settings.get('auto_stop_idle') or 0) and not downloading and not stopped: + idle_details = { + 'decision': 'Smart Queue auto-stopped during cooldown refill: no active or waiting downloads', + 'enabled': False, + 'auto_stop_idle': True, + 'cooldown_refill': True, + 'checked': len(torrents), + 'active_before': 0, + 'active_after_stop': 0, + 'active_after_expected': 0, + 'max_active_downloads': max_active, + 'over_limit': 0, + 'stopped': [], + 'started': [], + 'start_requested': [], + 'active_verified_count': 0, + 'pending_confirmation_count': 0, + 'stalled_detected': 0, + 'stalled_stopped': 0, + 'protected_stalled': 0, + 'excluded': len(user_excluded), + 'excluded_stalled': len(stalled_label_hashes), + } + _diagnostics_write('smart_queue.auto_stopped_idle', {'profile_id': profile_id, 'checked': len(torrents), 'cooldown_refill': True}, idle_details) + return _disable_when_idle(profile_id, user_id, torrents, idle_details) + available_slots = max(0, max_active - len(downloading)) + startable_stopped, source_skipped = _split_start_candidates(stopped) + candidates = sorted( + startable_stopped, + key=lambda t: (int(t.get('seeds') or 0), int(t.get('peers') or 0), int(t.get('down_rate') or 0)), + reverse=True, + ) + c = rtorrent.client_for(profile) + started_by_queue: list[str] = [] + label_failed: list[str] = [] + start_failed: list[dict[str, str]] = [] + start_no_effect: list[dict[str, Any]] = [] + start_requested: list[str] = [] + start_results: list[dict[str, Any]] = [] + to_start = candidates[:available_slots] + to_label_waiting = candidates[available_slots:] + + for t in to_label_waiting: + h = str(t.get('hash') or '') + if not h: + continue + try: + if not _mark_auto_stopped(c, profile_id, t): + label_failed.append(h) + except Exception: + label_failed.append(h) + + start_summary = _start_and_verify_downloads(c, profile_id, to_start) + active_verified = start_summary['active_verified'] + start_no_effect = start_summary['start_no_effect'] + start_pending_confirmation = start_summary.get('start_pending_confirmation', []) + start_failed = start_summary['start_failed'] + start_requested = start_summary['start_requested'] + start_results = start_summary['start_results'] + _record_start_grace(profile_id, start_requested) + for h in start_requested: + _restore_auto_label(c, profile_id, h, None) + try: + rtorrent.clear_post_check_download_label(c, h, None) + except Exception: + label_failed.append(h) + started_by_queue = list(start_requested) + keep_labels = ( + {str(t.get('hash') or '') for t in to_label_waiting} + | {str(t.get('hash') or '') for t in stopped if _has_smart_queue_label(str(t.get('label') or '')) and str(t.get('hash') or '') not in set(started_by_queue)} + ) + restored = _cleanup_auto_labels(c, profile_id, torrents, keep_labels, True) + details = { + 'enabled': bool(settings.get('enabled')), + 'cooldown_refill': True, + 'cooldown_respected': True, + 'refill_mode': _refill_mode(settings), + 'refill_interval_minutes': int(settings.get('refill_interval_minutes') or 0), + 'active_before': len(downloading), + 'available_slots': available_slots, + 'candidates': len(candidates), + 'start_source_skipped': len(source_skipped), + 'waiting_labeled': len(to_label_waiting), + 'started_planned': len(to_start), + 'start_requested': start_requested, + 'start_results': start_results, + 'start_batch_size': start_summary['start_batch_size'], + 'start_batch_pause_seconds': start_summary['start_batch_pause_seconds'], + 'start_verify_attempts': start_summary['start_verify_attempts'], + 'start_verify_delay_seconds': start_summary['start_verify_delay_seconds'], + 'start_no_effect': start_no_effect, + 'start_pending_confirmation': start_pending_confirmation, + 'start_failed': start_failed, + 'active_verified': active_verified, + 'labels_failed': label_failed, + 'labels_restored': restored, + 'max_active_downloads': max_active, + 'excluded': len(user_excluded), + 'excluded_stalled': len(stalled_label_hashes), + } + _diagnostics_write( + 'smart_queue.cooldown_refill', + { + 'profile_id': profile_id, + 'checked': len(torrents), + 'active_before': len(downloading), + 'max_active_downloads': max_active, + 'available_slots': available_slots, + 'candidates': len(candidates), + 'start_source_skipped': len(source_skipped), + 'requested': len(start_requested), + 'verified': len(active_verified), + 'pending': len(start_pending_confirmation), + 'pending_reasons': _pending_reason_counts(start_pending_confirmation), + 'start_failed': len(start_failed), + 'no_effect': len(start_no_effect), + 'waiting_labeled': len(to_label_waiting), + 'labels_failed': len(label_failed), + }, + { + 'settings': { + 'refill_mode': _refill_mode(settings), + 'refill_interval_minutes': int(settings.get('refill_interval_minutes') or 0), + 'min_seeds': min_seeds, + 'min_peers': min_peers, + }, + 'to_start': _diagnostics_torrents(to_start), + 'to_label_waiting': _diagnostics_torrents(to_label_waiting), + 'source_skipped': _diagnostics_torrents(source_skipped), + 'pending_confirmation': _diagnostics_sample(start_pending_confirmation), + 'start_failed': _diagnostics_sample(start_failed), + 'start_results': _diagnostics_sample(start_results), + 'labels_failed': _diagnostics_sample(label_failed), + }, + ) + _mark_refill_run(profile_id, user_id) + if started_by_queue or to_label_waiting or start_failed or label_failed or restored: + add_history(profile_id, 'cooldown_refill', [], started_by_queue, len(torrents), details, user_id) + settings = get_settings(profile_id, user_id) + return { + 'ok': True, + 'enabled': bool(settings.get('enabled')), + 'cooldown_skipped': True, + 'cooldown_refill': True, + 'cooldown_respected': True, + 'refill_mode': _refill_mode(settings), + 'refill_interval_minutes': int(settings.get('refill_interval_minutes') or 0), + 'refill_remaining_seconds': refill_remaining(settings), + 'paused': [], + 'resumed': started_by_queue, + 'stopped': [], + 'started': started_by_queue, + 'start_requested': start_requested, + 'start_batch_size': start_summary['start_batch_size'], + 'start_verify_attempts': start_summary['start_verify_attempts'], + 'start_verify_delay_seconds': start_summary['start_verify_delay_seconds'], + 'waiting_labeled': len(to_label_waiting), + 'labels_restored': restored, + 'labels_failed': label_failed, + 'start_failed': start_failed, + 'start_no_effect': start_no_effect, + 'start_pending_confirmation': start_pending_confirmation, + 'active_verified': active_verified, + 'active_before': len(downloading), + 'active_after_expected': len(downloading) + len(started_by_queue), + 'available_slots': available_slots, + 'start_source_skipped': len(source_skipped), + 'checked': len(torrents), + 'excluded': len(user_excluded), + 'settings': settings, + } + +def mark_run(profile_id: int, user_id: int | None = None) -> None: + user_id = user_id or default_user_id() + with connect() as conn: + conn.execute('UPDATE smart_queue_settings SET last_run_at=?, updated_at=? WHERE user_id=? AND profile_id=?', (utcnow(), utcnow(), user_id, profile_id)) + +def _disable_when_idle(profile_id: int, user_id: int, torrents: list[dict[str, Any]], details: dict[str, Any]) -> dict[str, Any]: + # Note: Auto-stop is intentionally profile-scoped and only flips the Smart Queue enabled flag; saved thresholds remain intact. + now = utcnow() + with connect() as conn: + conn.execute('UPDATE smart_queue_settings SET enabled=0, last_run_at=?, updated_at=? WHERE user_id=? AND profile_id=?', (now, now, user_id, profile_id)) + add_history(profile_id, 'auto_stopped_idle', [], [], len(torrents), details, user_id) + settings = get_settings(profile_id, user_id) + return {'ok': True, 'enabled': False, 'auto_stopped_idle': True, 'paused': [], 'resumed': [], 'stopped': [], 'started': [], 'checked': len(torrents), 'settings': settings, 'message': 'Smart Queue stopped because there is no active or waiting work.'} + +def check(profile: dict | None = None, user_id: int | None = None, force: bool = False) -> 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']) + settings = get_settings(profile_id, user_id) + remaining = cooldown_remaining(settings) + if remaining and not force: + if int(settings.get('enabled') or 0): + refill_wait = refill_remaining(settings) + if not int(settings.get('refill_enabled') or 0): + return {'ok': True, 'enabled': True, 'cooldown_skipped': True, 'cooldown_refill': False, 'refill_disabled': True, 'cooldown_remaining_seconds': remaining, 'settings': settings} + if refill_wait: + return {'ok': True, 'enabled': True, 'cooldown_skipped': True, 'cooldown_refill': False, 'refill_wait_seconds': refill_wait, 'cooldown_remaining_seconds': remaining, 'settings': settings} + try: + # Note: Cooldown still blocks the full Smart Queue pass, but configured refill may fill free slots safely. + refill = _refill_underfilled_queue(profile, settings, profile_id, user_id) + refill['cooldown_remaining_seconds'] = remaining + return refill + except Exception as exc: + return {'ok': True, 'enabled': True, 'cooldown_skipped': True, 'cooldown_refill': False, 'cooldown_remaining_seconds': remaining, 'settings': settings, 'error': str(exc)} + return {'ok': True, 'enabled': bool(settings.get('enabled')), 'cooldown_skipped': True, 'cooldown_remaining_seconds': remaining, 'settings': settings} + if not force and not int(settings.get('enabled') or 0): + restored: list[str] = [] + try: + # Note: When Smart Queue is disabled, only technical labels are cleaned up, without starting or pausing torrents. + torrents = rtorrent.list_torrents(profile) + restored = _cleanup_auto_labels(rtorrent.client_for(profile), profile_id, torrents, set(), True) + except Exception: + restored = [] + add_history(profile_id, 'skipped_disabled', [], [], 0, {'enabled': False, 'labels_restored': restored}, user_id) + return {'ok': True, 'enabled': False, 'paused': [], 'resumed': [], 'stopped': [], 'started': [], 'labels_restored': restored, 'message': 'Smart Queue disabled'} + + torrents = rtorrent.list_torrents(profile) + # Note: Stalled labels block automatic starting only; a manually started Stalled item still counts as a running slot. + stalled_label_hashes = {str(t.get('hash') or '') for t in torrents if _has_stalled_label(str(t.get('label') or '')) and t.get('hash')} + user_excluded = _excluded_hashes(profile_id, user_id) + manage_stopped = True + + # Note: Count every started incomplete torrent, including items started manually and items with old Smart Queue labels. + downloading = [ + t for t in torrents + if _is_running_download_slot(t) + and str(t.get('hash') or '') not in user_excluded + ] + # Note: Waiting candidates are stopped queue holds only; Stalled labels are not auto-started again. + stopped = [ + t for t in torrents + if str(t.get('hash') or '') not in user_excluded + and str(t.get('hash') or '') not in stalled_label_hashes + and _is_waiting_download_candidate(t, manage_stopped) + and not _is_running_download_slot(t) + ] + manual_labeled_running = [ + str(t.get('hash') or '') for t in downloading + if str(t.get('hash') or '') and _has_smart_queue_label(str(t.get('label') or '')) + ] + if int(settings.get('auto_stop_idle') or 0) and not downloading and not stopped: + idle_details = { + 'decision': 'Smart Queue auto-stopped: no active or waiting downloads', + 'enabled': False, + 'auto_stop_idle': True, + 'checked': len(torrents), + 'active_before': 0, + 'active_after_stop': 0, + 'active_after_expected': 0, + 'max_active_downloads': max(1, int(settings.get('max_active_downloads') or 5)), + 'over_limit': 0, + 'stopped': [], + 'started': [], + 'start_requested': [], + 'active_verified_count': 0, + 'pending_confirmation_count': 0, + 'stalled_detected': 0, + 'stalled_stopped': 0, + 'protected_stalled': 0, + 'excluded': len(user_excluded), + 'excluded_stalled': len(stalled_label_hashes), + } + _diagnostics_write('smart_queue.auto_stopped_idle', {'profile_id': profile_id, 'checked': len(torrents)}, idle_details) + return _disable_when_idle(profile_id, user_id, torrents, idle_details) + min_speed = int(settings.get('min_speed_bytes') or 0) + min_seeds = int(settings.get('min_seeds') or 0) + min_peers = int(settings.get('min_peers') or 0) + ignore_seed_peer = bool(int(settings.get('ignore_seed_peer') or 0)) + ignore_speed = bool(int(settings.get('ignore_speed') or 0)) + stalled_seconds = int(settings.get('stalled_seconds') or 300) + stop_batch_size = max(1, int(settings.get('stop_batch_size') or 50)) + start_grace_seconds = max(0, int(settings.get('start_grace_seconds') or 0)) + protect_active_below_cap = bool(int(settings.get('protect_active_below_cap', 1) or 0)) + timer_key = _stalled_timer_key(min_speed, min_seeds, min_peers, stalled_seconds, ignore_seed_peer, ignore_speed) + now = utcnow() + now_ts = datetime.now(timezone.utc).timestamp() + start_grace_hashes = _load_active_start_grace(profile_id, start_grace_seconds, now_ts) + stalled: list[dict[str, Any]] = [] + stop_eligible: list[dict[str, Any]] = [] + # Note: Toast diagnostics count active torrents whose ignored criteria would otherwise match during this check. + ignored_seed_peer_count = 0 + ignored_speed_count = 0 + + with connect() as conn: + for t in downloading: + # Note: Ignore switches keep matching criteria from advancing stalled cleanup while preserving diagnostics. + if ignore_seed_peer and (int(t.get('seeds') or 0) <= max(0, int(min_seeds or 0)) or (min_peers > 0 and int(t.get('peers') or 0) <= max(0, int(min_peers or 0)))): + ignored_seed_peer_count += 1 + if ignore_speed and int(t.get('down_rate') or 0) <= max(0, int(min_speed or 0)): + ignored_speed_count += 1 + is_stalled = _is_stalled_download(t, min_speed, min_seeds, min_peers, ignore_seed_peer, ignore_speed) + # Note: Hard-limit enforcement uses only non-ignored weak criteria before choosing weak items. + if _is_low_activity_download(t, min_speed, min_seeds, min_peers, ignore_seed_peer, ignore_speed): + stop_eligible.append(t) + h = str(t.get('hash') or '') + if not h: + continue + if h in start_grace_hashes: + # Note: Fresh queue starts get time to announce/connect before stalled logic may stop them. + conn.execute('DELETE FROM smart_queue_stalled WHERE profile_id=? AND torrent_hash=?', (profile_id, h)) + continue + if is_stalled: + row = conn.execute('SELECT first_stalled_at, timer_key FROM smart_queue_stalled WHERE profile_id=? AND torrent_hash=?', (profile_id, h)).fetchone() + if row and str(row.get('timer_key') or '') == timer_key: + conn.execute('UPDATE smart_queue_stalled SET updated_at=? WHERE profile_id=? AND torrent_hash=?', (now, profile_id, h)) + first = row['first_stalled_at'] + else: + # Note: A changed stalled rule starts a fresh timer, so old rows cannot instantly mark torrents as Stalled. + first = now + conn.execute('INSERT OR REPLACE INTO smart_queue_stalled(profile_id,torrent_hash,first_stalled_at,updated_at,timer_key) VALUES(?,?,?,?,?)', (profile_id, h, first, now, timer_key)) + if now_ts - _ts(first) >= stalled_seconds: + stalled.append(t) + else: + conn.execute('DELETE FROM smart_queue_stalled WHERE profile_id=? AND torrent_hash=?', (profile_id, h)) + + # Note: Start candidates are not filtered by seeds/peers because those counts may be stale before announce. + startable_stopped, source_skipped = _split_start_candidates(stopped) + candidates = sorted( + startable_stopped, + key=lambda t: (int(t.get('seeds') or 0), int(t.get('peers') or 0), int(t.get('down_rate') or 0)), + reverse=True, + ) + max_active = max(1, int(settings.get('max_active_downloads') or 5)) + stalled_hashes = {str(t.get('hash') or '') for t in stalled} + + # Enforce the hard active-download cap across the whole started queue, including manual starts. + # Note: Weak/no-source torrents are stopped first, but the cap is still enforced when the overflow is larger. + over_limit = max(0, len(downloading) - max_active) + stop_eligible_hashes = {str(t.get('hash') or '') for t in stop_eligible} + stop_rank = sorted( + downloading, + key=lambda t: ( + 0 if str(t.get('hash') or '') in stalled_hashes else 1, + 0 if str(t.get('hash') or '') in stop_eligible_hashes else 1, + int(t.get('down_rate') or 0), + int(t.get('seeds') or 0), + int(t.get('peers') or 0), + ), + ) + # Note: The user-defined batch limit caps all automatic stops in one pass. + # Hard cap overflow is handled first, then stalled replacement uses only proven spare candidate capacity. + to_stop: list[dict[str, Any]] = stop_rank[:min(over_limit, stop_batch_size)] + stop_hashes = {str(t.get('hash') or '') for t in to_stop} + remaining_stop_budget = max(0, stop_batch_size - len(to_stop)) + free_slots_before_stop = max(0, max_active - len(downloading)) + replacement_capacity = max(0, len(candidates) - free_slots_before_stop) + stalled_replacement_allowed = not (protect_active_below_cap and len(downloading) < max_active and over_limit == 0) + stalled_replacement_limit = min(remaining_stop_budget, replacement_capacity) if stalled_replacement_allowed else 0 + + # Note: Stalled downloads are replaced gradually. With protection enabled, below-cap checks refill first + # and postpone stalled cleanup until the active count reaches the configured cap or overflows it. + for t in stalled: + if stalled_replacement_limit <= 0: + break + h = str(t.get('hash') or '') + if h and h not in stop_hashes: + to_stop.append(t) + stop_hashes.add(h) + stalled_replacement_limit -= 1 + + protected_stalled = max(0, len(stalled) - len([h for h in stop_hashes if h in stalled_hashes])) + + c = rtorrent.client_for(profile) + rtorrent_cap = _ensure_rtorrent_download_cap(c, max_active) + stopped_by_queue: list[str] = [] + started_by_queue: list[str] = [] + label_failed: list[str] = [] + stalled_labeled: list[str] = [] + stop_failed: list[dict[str, str]] = [] + start_failed: list[dict[str, str]] = [] + start_no_effect: list[dict[str, Any]] = [] + start_requested: list[str] = [] + start_results: list[dict[str, Any]] = [] + + for t in to_stop: + h = str(t.get('hash') or '') + try: + # Note: Smart Queue stops with the same low-level d.stop command used by the manual Stop action. + # This avoids extra pre-check RPCs and keeps large queues from failing after only a few items. + c.call('d.stop', h) + if h in stalled_hashes: + if _ensure_stalled_label(c, h, _read_label(c, h, str(t.get('label') or ''))): + stalled_labeled.append(h) + else: + label_failed.append(h) + elif not _mark_auto_stopped(c, profile_id, t): + label_failed.append(h) + stopped_by_queue.append(h) + except Exception as exc: + # Note: Stop failures are stored in history instead of being swallowed, so queue drift is visible. + stop_failed.append({'hash': h, 'error': str(exc)}) + + active_after_stop = max(0, len(downloading) - len(stopped_by_queue)) + # Note: Starts are planned only after confirmed stops, so failed stops cannot push the queue above the cap. + available_slots = max(0, max_active - active_after_stop) + to_start = candidates[:available_slots] + # Note: Items outside the current start batch are explicitly marked as pending Smart Queue items. + to_label_waiting = candidates[available_slots:] + + for t in to_label_waiting: + h = str(t.get('hash') or '') + if not h or h in stop_hashes: + continue + try: + if not _mark_auto_stopped(c, profile_id, t): + label_failed.append(h) + except Exception: + label_failed.append(h) + + # Note: Start the whole candidate batch in one round. Remove the label after an accepted RPC, + # because rTorrent may keep some items in its own queue with active=0 despite a valid d.start/d.resume. + start_summary = _start_and_verify_downloads(c, profile_id, to_start) + active_verified = start_summary['active_verified'] + start_no_effect = start_summary['start_no_effect'] + start_pending_confirmation = start_summary.get('start_pending_confirmation', []) + start_failed = start_summary['start_failed'] + start_requested = start_summary['start_requested'] + start_results = start_summary['start_results'] + _record_start_grace(profile_id, start_requested) + for h in start_requested: + _restore_auto_label(c, profile_id, h, None) + try: + # Note: Once Smart Queue starts a post-check torrent, its temporary download-after-check label is no longer needed. + rtorrent.clear_post_check_download_label(c, h, None) + except Exception: + label_failed.append(h) + # Note: History shows accepted Smart Queue starts; active_verified shows items already visible as started in rTorrent. + started_by_queue = list(start_requested) + keep_labels = ( + set(stopped_by_queue) + | {str(t.get('hash') or '') for t in to_label_waiting} + | {str(t.get('hash') or '') for t in stopped if _has_smart_queue_label(str(t.get('label') or '')) and str(t.get('hash') or '') not in set(started_by_queue)} + ) + restored = _cleanup_auto_labels(c, profile_id, torrents, keep_labels, manage_stopped) + stalled_stopped_hashes = [h for h in stopped_by_queue if h in stalled_hashes] + # Note: Smart Queue history now stores a compact decision summary while keeping enough hashes to audit Stalled actions. + details = { + 'decision': _decision_text(len(stopped_by_queue), len(started_by_queue), len(stalled), len(stalled_stopped_hashes), protected_stalled), + 'enabled': bool(settings.get('enabled')), + 'checked': len(torrents), + 'max_active_downloads': max_active, + 'active_before': len(downloading), + 'active_after_stop': active_after_stop, + 'active_after_expected': active_after_stop + len(started_by_queue), + 'over_limit': over_limit, + 'stopped': stopped_by_queue, + 'started': started_by_queue, + 'start_requested': start_requested, + 'active_verified_count': len(active_verified), + 'pending_confirmation_count': len(start_pending_confirmation), + 'start_failed_count': len(start_failed), + 'stop_failed_count': len(stop_failed), + 'label_failed_count': len(label_failed), + 'waiting_labeled': len(to_label_waiting), + 'stalled_detected': len(stalled), + 'stalled_hashes': _hash_sample(stalled_hashes), + 'stalled_stopped': len(stalled_stopped_hashes), + 'stalled_stopped_hashes': _hash_sample(stalled_stopped_hashes), + 'stalled_labeled': stalled_labeled, + 'protected_stalled': protected_stalled, + 'stalled_replacement_allowed': stalled_replacement_allowed, + 'excluded': len(user_excluded), + 'excluded_stalled': len(stalled_label_hashes), + 'manual_labeled_running': len(manual_labeled_running), + 'labels_restored_count': len(restored), + 'start_source_skipped': len(source_skipped), + 'ignore_seed_peer': ignore_seed_peer, + 'ignore_speed': ignore_speed, + 'ignored_seed_peer_count': ignored_seed_peer_count if ignore_seed_peer else 0, + 'ignored_speed_count': ignored_speed_count if ignore_speed else 0, + 'stalled_seconds': stalled_seconds, + 'stop_batch_size': stop_batch_size, + 'start_grace_seconds': start_grace_seconds, + 'start_grace_protected': len(start_grace_hashes), + 'replacement_capacity': replacement_capacity, + 'rtorrent_cap_updated': bool(rtorrent_cap.get('updated')), + 'rtorrent_cap': rtorrent_cap, + 'stop_failed': stop_failed, + 'start_failed': start_failed, + 'labels_failed': label_failed, + } + _diagnostics_write( + 'smart_queue.force_check' if force else 'smart_queue.auto_check', + { + 'profile_id': profile_id, + 'force': bool(force), + 'checked': len(torrents), + 'active_before': len(downloading), + 'active_after_stop': active_after_stop, + 'active_after_expected': active_after_stop + len(started_by_queue), + 'max_active_downloads': max_active, + 'over_limit': over_limit, + 'stopped': len(stopped_by_queue), + 'stalled': len(stalled), + 'protected_stalled': protected_stalled, + 'stalled_stopped': len(stalled_stopped_hashes), + 'stalled_stopped_hashes': _hash_sample(stalled_stopped_hashes, 20), + 'stop_eligible': len(stop_eligible), + 'candidates': len(candidates), + 'available_slots': available_slots, + 'requested': len(start_requested), + 'verified': len(active_verified), + 'pending': len(start_pending_confirmation), + 'pending_reasons': _pending_reason_counts(start_pending_confirmation), + 'start_failed': len(start_failed), + 'no_effect': len(start_no_effect), + 'waiting_labeled': len(to_label_waiting), + 'start_source_skipped': len(source_skipped), + 'labels_failed': len(label_failed), + 'stop_failed': len(stop_failed), + }, + { + 'settings': { + 'min_speed_bytes': min_speed, + 'min_seeds': min_seeds, + 'min_peers': min_peers, + 'ignore_seed_peer': ignore_seed_peer, + 'ignore_speed': ignore_speed, + 'stalled_seconds': stalled_seconds, + 'stop_batch_size': stop_batch_size, + 'start_grace_seconds': start_grace_seconds, + 'protect_active_below_cap': protect_active_below_cap, + 'auto_stop_idle': bool(int(settings.get('auto_stop_idle') or 0)), + }, + 'rtorrent_cap': rtorrent_cap, + 'to_stop': _diagnostics_torrents(to_stop), + 'stalled': _diagnostics_torrents(stalled), + 'stop_eligible': _diagnostics_torrents(stop_eligible), + 'to_start': _diagnostics_torrents(to_start), + 'to_label_waiting': _diagnostics_torrents(to_label_waiting), + 'source_skipped': _diagnostics_torrents(source_skipped), + 'pending_confirmation': _diagnostics_sample(start_pending_confirmation), + 'start_failed': _diagnostics_sample(start_failed), + 'stop_failed': _diagnostics_sample(stop_failed), + 'start_results': _diagnostics_sample(start_results), + 'manual_labeled_running': _diagnostics_sample(manual_labeled_running), + 'labels_failed': _diagnostics_sample(label_failed), + }, + ) + add_history(profile_id, 'force_check' if force else 'auto_check', stopped_by_queue, started_by_queue, len(torrents), {**details, 'stopped': stopped_by_queue, 'started': started_by_queue}, user_id) + mark_run(profile_id, user_id) + settings = get_settings(profile_id, user_id) + remaining = cooldown_remaining(settings) + return {'ok': True, 'enabled': bool(settings.get('enabled')), 'paused': stopped_by_queue, 'resumed': started_by_queue, 'stopped': stopped_by_queue, 'started': started_by_queue, 'start_requested': start_requested, 'start_batch_size': start_summary['start_batch_size'], 'start_verify_attempts': start_summary['start_verify_attempts'], 'start_verify_delay_seconds': start_summary['start_verify_delay_seconds'], 'waiting_labeled': len(to_label_waiting), 'stalled_labeled': stalled_labeled, 'excluded_stalled': len(stalled_label_hashes), 'manual_labeled_running': len(manual_labeled_running), 'labels_restored': restored, 'labels_failed': label_failed, 'stop_failed': stop_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'start_pending_confirmation': start_pending_confirmation, 'active_verified': active_verified, 'active_before': len(downloading), 'active_after_stop': active_after_stop, 'over_limit': over_limit, 'stop_eligible': len(stop_eligible), 'start_source_skipped': len(source_skipped), 'ignore_seed_peer': ignore_seed_peer, 'ignore_speed': ignore_speed, 'ignored_seed_peer_count': ignored_seed_peer_count if ignore_seed_peer else 0, 'ignored_speed_count': ignored_speed_count if ignore_speed else 0, 'stalled_seconds': stalled_seconds, 'stalled_timer_key': timer_key, 'stop_batch_size': stop_batch_size, 'start_grace_seconds': start_grace_seconds, 'protect_active_below_cap': protect_active_below_cap, 'auto_stop_idle': bool(int(settings.get('auto_stop_idle') or 0)), 'stalled_replacement_allowed': stalled_replacement_allowed, 'start_grace_protected': len(start_grace_hashes), 'replacement_capacity': replacement_capacity, 'protected_stalled': protected_stalled, 'healthy_active_protected': 0, 'rtorrent_cap': rtorrent_cap, 'checked': len(torrents), 'excluded': len(user_excluded), 'settings': settings, 'cooldown_remaining_seconds': remaining} diff --git a/pytorrent/services/speed_peaks.py b/pytorrent/services/speed_peaks.py new file mode 100644 index 0000000..a8b8f58 --- /dev/null +++ b/pytorrent/services/speed_peaks.py @@ -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))) diff --git a/pytorrent/services/startup_config.py b/pytorrent/services/startup_config.py new file mode 100644 index 0000000..9075026 --- /dev/null +++ b/pytorrent/services/startup_config.py @@ -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) diff --git a/pytorrent/services/torrent_cache.py b/pytorrent/services/torrent_cache.py new file mode 100644 index 0000000..a6eac98 --- /dev/null +++ b/pytorrent/services/torrent_cache.py @@ -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() diff --git a/pytorrent/services/torrent_creator.py b/pytorrent/services/torrent_creator.py new file mode 100644 index 0000000..6dbf909 --- /dev/null +++ b/pytorrent/services/torrent_creator.py @@ -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, + } diff --git a/pytorrent/services/torrent_meta.py b/pytorrent/services/torrent_meta.py new file mode 100644 index 0000000..3f53aaa --- /dev/null +++ b/pytorrent/services/torrent_meta.py @@ -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, + } diff --git a/pytorrent/services/torrent_stats.py b/pytorrent/services/torrent_stats.py new file mode 100644 index 0000000..6745b05 --- /dev/null +++ b/pytorrent/services/torrent_stats.py @@ -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 diff --git a/pytorrent/services/torrent_summary.py b/pytorrent/services/torrent_summary.py new file mode 100644 index 0000000..c3f52b8 --- /dev/null +++ b/pytorrent/services/torrent_summary.py @@ -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) diff --git a/pytorrent/services/tracker_cache.py b/pytorrent/services/tracker_cache.py new file mode 100644 index 0000000..deaca74 --- /dev/null +++ b/pytorrent/services/tracker_cache.py @@ -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" 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 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"]*>", 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" 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 diff --git a/pytorrent/services/traffic_history.py b/pytorrent/services/traffic_history.py new file mode 100644 index 0000000..cdb459f --- /dev/null +++ b/pytorrent/services/traffic_history.py @@ -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} diff --git a/pytorrent/services/websocket.py b/pytorrent/services/websocket.py new file mode 100644 index 0000000..45fa188 --- /dev/null +++ b/pytorrent/services/websocket.py @@ -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)}) diff --git a/pytorrent/services/workers.py b/pytorrent/services/workers.py new file mode 100644 index 0000000..a711981 --- /dev/null +++ b/pytorrent/services/workers.py @@ -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 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 diff --git a/pytorrent/static/favicon.svg b/pytorrent/static/favicon.svg new file mode 100644 index 0000000..5f52965 --- /dev/null +++ b/pytorrent/static/favicon.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/pytorrent/static/js/api.js b/pytorrent/static/js/api.js new file mode 100644 index 0000000..a431970 --- /dev/null +++ b/pytorrent/static/js/api.js @@ -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?` ${esc(code.toUpperCase())}`:'-'; }\n function table(headers,rows,extraClass=''){ const cls=extraClass?` ${extraClass}`:''; return `${headers.map(h=>``).join('')}${rows.map(r=>`${r.map(c=>``).join('')}`).join('')}
${esc(h)}
${c}
`; }\n function responsiveTable(headers,rows,extraClass=''){ return `
${table(headers,rows,extraClass)}
`; }\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"; diff --git a/pytorrent/static/js/app.js b/pytorrent/static/js/app.js new file mode 100644 index 0000000..b07e9e0 --- /dev/null +++ b/pytorrent/static/js/app.js @@ -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(); +} diff --git a/pytorrent/static/js/bootstrap.js b/pytorrent/static/js/bootstrap.js new file mode 100644 index 0000000..8838aa9 --- /dev/null +++ b/pytorrent/static/js/bootstrap.js @@ -0,0 +1 @@ +export const bootstrapSource = " async function loadInitialSnapshotFallback(reason=''){\n if(initialLoaderDone) return;\n try{\n const profilesResp = await fetch('/api/profiles', {cache:'no-store'});\n const profilesJson = await profilesResp.json().catch(()=>({ok:false}));\n const active = profilesJson.active || null;\n if(!active){ showFirstRunSetup(); return; }\n const torrentsResp = await fetch('/api/torrents', {cache:'no-store'});\n const j = await torrentsResp.json().catch(()=>({ok:false,error:'Invalid /api/torrents response'}));\n if(j.ok === false) throw new Error(j.error || 'Torrent API failed');\n const rows = j.torrents || [];\n if(j.error && !rows.length){ renderRtorrentStartingState(j.error, true); hideInitialLoader(); return; }\n clearRtorrentStartingState();\n hasTorrentSnapshot = true;\n torrentSummary = j.summary || null;\n torrents.clear();\n rows.forEach(t=>torrents.set(t.hash,t));\n if(j.speed_status) applyLiveSpeedStats(j.speed_status); else updateBrowserSpeedTitle();\n scheduleRender(true);\n scheduleTrackerSummary(true);\n hideInitialLoader();\n }catch(e){\n setInitialLoader('Waiting for rTorrent...', (reason ? reason + ': ' : '') + (e.message || 'Unable to load torrent data.'));\n renderRtorrentStartingState(e.message || reason || 'Unable to load torrent data.', true);\n hideInitialLoader();\n }\n }\n setTimeout(()=>loadInitialSnapshotFallback('Socket fallback'), 4000);\n if(!socket || !socket.io || typeof socket.on !== 'function') setTimeout(()=>loadInitialSnapshotFallback('Socket.IO unavailable'), 200);\n socket.on('connect',()=>{ if(!hasActiveProfile){ showFirstRunSetup(); return; } $('connBadge').className='badge text-bg-success'; $('connBadge').textContent='online'; setInitialLoader('Loading torrents...','Connection is ready. Waiting for the first torrent snapshot.'); socket.emit('select_profile',{profile_id:window.PYTORRENT.activeProfile}); }); socket.on('disconnect',()=>{ $('connBadge').className='badge text-bg-danger'; $('connBadge').textContent='offline'; setInitialLoader('Waiting for connection...','pyTorrent is not connected yet. The application will open after data is received.'); }); socket.io.on('reconnect_attempt',()=>{ $('connBadge').className='badge text-bg-warning'; $('connBadge').textContent='reconnecting'; setInitialLoader('Reconnecting...','Trying to restore the live connection and load torrent data.'); }); socket.io.on('reconnect',()=>{ if(!hasActiveProfile){ showFirstRunSetup(); return; } $('connBadge').className='badge text-bg-success'; $('connBadge').textContent='online'; setInitialLoader('Loading torrents...','Connection restored. Waiting for the first torrent snapshot.'); socket.emit('select_profile',{profile_id:window.PYTORRENT.activeProfile}); }); socket.on('profile_required',()=>showFirstRunSetup()); socket.on('torrent_snapshot',msg=>{const rows=msg.torrents||[]; if(msg.error && !rows.length){ renderRtorrentStartingState(msg.error, true); return; } clearRtorrentStartingState(); hasTorrentSnapshot=true;torrentSummary=msg.summary||null;torrents.clear();rows.forEach(t=>torrents.set(t.hash,t));if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle();scheduleRender(true);scheduleTrackerSummary(true);hideInitialLoader();}); socket.on('torrent_patch',msg=>{patchRows(msg);scheduleTrackerSummary(false);}); socket.on('job_update',()=>{ if(document.body.classList.contains('modal-open')) loadJobs().catch(()=>{}); }); socket.on('operation_started',msg=>{setBusy(true);markTorrentOperation(msg.hashes||[],msg.action,msg.job_id,'running');if(shouldShowOperationToast(msg)) toast(`${msg.action} started`,'secondary');}); socket.on('operation_finished',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);if(shouldShowOperationToast(msg)) toast(`${msg.action} done`,'success');}); socket.on('operation_failed',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);if(shouldShowOperationToast(msg)) toast(`${msg.action}: ${msg.error}`,'danger');}); socket.on('rtorrent_error',msg=>{ if(msg.error){ recordNotification('error','rTorrent error',msg.error);$('connBadge').className='badge badge-degraded';$('connBadge').textContent='degraded'; setInitialLoader('Waiting for rTorrent...','rTorrent is not ready yet. Data will appear automatically after it responds.'); scheduleRtorrentStartingState(msg.error);} }); socket.on('heartbeat',msg=>{ if(msg.error){$('connBadge').className='badge badge-degraded';$('connBadge').textContent='degraded'; setInitialLoader('Waiting for rTorrent...','rTorrent is not ready yet. Data will appear automatically after it responds.'); scheduleRtorrentStartingState(msg.error);} else if(socket.connected){clearRtorrentStartingState();$('connBadge').className='badge text-bg-success';$('connBadge').textContent='online';} }); socket.on('smart_queue_update',msg=>{ if(msg?.enabled && !msg.cooldown_skipped) recordNotification('queue','Smart Queue decision',smartQueueToastMessage(msg)); if(msg?.cooldown_remaining_seconds!==undefined) updateCooldownBadge('smartCooldownBadge', Number(msg.cooldown_remaining_seconds||0)); if(msg && msg.enabled && !msg.cooldown_skipped && smartQueueToastsEnabled){ toast(smartQueueToastMessage(msg),'secondary'); } }); socket.on('automation_update',msg=>{ if(msg?.error) recordNotification('error','Automation error',msg.error); if(msg?.applied?.length) recordNotification('info','Automation applied',`${msg.applied.length} item(s)`); if(msg?.applied?.length && automationToastsEnabled) toast(`Automations applied ${msg.applied.length} item(s)`,'secondary'); }); socket.on('torrent_stats_update',msg=>{ if(msg?.stats){ renderTorrentStats(msg.stats); } else if(msg?.error && $('toolTorrentStats') && !$('toolTorrentStats').classList.contains('d-none')){ toast(`Torrent stats: ${msg.error}`,'danger'); } }); socket.on('rtorrent_config_applied',msg=>{ if(msg?.result?.updated?.length) toast(`Startup rTorrent config applied (${msg.result.updated.length})`,'success'); if(msg?.error) toast(`Startup rTorrent config: ${msg.error}`,'danger'); }); socket.on('download_plan_update',msg=>{ if(msg?.enabled && (msg.paused||msg.resumed||msg.limits_changed||msg.pause_reason)) recordNotification('planner','Planner action',`paused ${msg.paused||0}, resumed ${msg.resumed||0}${msg.pause_reason?`, ${msg.pause_reason}`:''}`); if(msg?.settings) fillPlanner(msg.settings); if(msg?.preview) renderPlannerPreview(msg.preview); else if(msg?.matched_rule) renderPlannerPreview(msg); if(msg?.history) renderPlannerHistory(msg.history); if(msg?.enabled && (msg.paused||msg.resumed||msg.limits_changed)) toast(`Planner: paused ${msg.paused||0}, resumed ${msg.resumed||0}${msg.dry_run?' dry-run':''}`,'secondary'); }); socket.on('poller_settings',msg=>fillPoller(msg?.settings||{},msg?.runtime||{}));\n function rtorrentPairText(current, max){\n if(current == null) return '-';\n return max == null ? String(current) : `${current}/${max}`;\n }\n function footerStatusUpdatedText(s={}){\n const value=s.footer_updated_at || s.updated_at;\n if(!value) return '';\n const date=new Date(value);\n return Number.isNaN(date.getTime()) ? '' : ` · last known ${date.toLocaleString()}`;\n }\n function updateRtorrentFooterStats(s={}, cached=false){\n const suffix=cached ? footerStatusUpdatedText(s) : '';\n const sockets=rtorrentPairText(s.open_sockets, s.max_open_sockets);\n if($('statSockets')) $('statSockets').textContent=sockets;\n if($('statusSockets')) $('statusSockets').title=s.open_sockets == null ? `Open sockets unavailable${suffix}` : `Open rTorrent sockets${s.max_open_sockets == null ? '' : ' / max'}: ${sockets}${suffix}`;\n if($('statRtDownloads')) $('statRtDownloads').textContent=rtorrentPairText(s.active_downloads, s.max_downloads_global);\n if($('statusRtDownloads')) $('statusRtDownloads').title=`Active rTorrent downloads / max global downloads${suffix}`;\n if($('statRtUploads')) $('statRtUploads').textContent=rtorrentPairText(s.active_uploads, s.max_uploads_global);\n if($('statusRtUploads')) $('statusRtUploads').title=`Active rTorrent uploads / max global uploads${suffix}`;\n if($('statRtHttp')) $('statRtHttp').textContent=rtorrentPairText(s.open_http, s.max_open_http);\n if($('statusRtHttp')) $('statusRtHttp').title=`Open rTorrent HTTP connections / max HTTP connections${suffix}`;\n if($('statRtFiles')) $('statRtFiles').textContent=rtorrentPairText(s.open_files, s.max_open_files);\n if($('statusRtFiles')) $('statusRtFiles').title=`Open rTorrent files / max open files${suffix}`;\n if($('statRtPort')) $('statRtPort').textContent=(s.listen_port ?? '-') || '-';\n if($('statusRtPort')) $('statusRtPort').title=`rTorrent incoming port${suffix}`;\n if(cached){\n if(s.cpu!==undefined && $('statCpu')) $('statCpu').textContent=s.cpu;\n if(s.ram!==undefined && $('statRam')) $('statRam').textContent=s.ram;\n if(s.version!==undefined && $('statVersion')) $('statVersion').textContent=s.version || '-';\n if(s.down_rate_h!==undefined && $('statDl')) $('statDl').textContent=s.down_rate_h || '0 B/s';\n if(s.up_rate_h!==undefined && $('statUl')) $('statUl').textContent=s.up_rate_h || '0 B/s';\n if(s.down_rate_h!==undefined && $('mobileSpeedDl')) $('mobileSpeedDl').textContent=s.down_rate_h || '0 B/s';\n if(s.up_rate_h!==undefined && $('mobileSpeedUl')) $('mobileSpeedUl').textContent=s.up_rate_h || '0 B/s';\n updateBrowserSpeedTitle(s.down_rate_h, s.up_rate_h);\n }\n }\n function saveFooterStatusCache(s={}){\n const payload={\n open_sockets:s.open_sockets, max_open_sockets:s.max_open_sockets,\n active_downloads:s.active_downloads, max_downloads_global:s.max_downloads_global,\n active_uploads:s.active_uploads, max_uploads_global:s.max_uploads_global,\n open_http:s.open_http, max_open_http:s.max_open_http,\n open_files:s.open_files, max_open_files:s.max_open_files,\n listen_port:s.listen_port,\n cpu:s.cpu, ram:s.ram, version:s.version,\n down_rate_h:s.down_rate_h, up_rate_h:s.up_rate_h,\n footer_updated_at:new Date().toISOString()\n };\n try{ localStorage.setItem(FOOTER_STATUS_STORAGE_KEY, JSON.stringify(payload)); }catch(_){}\n }\n function restoreFooterStatusCache(){\n try{\n const cached=JSON.parse(localStorage.getItem(FOOTER_STATUS_STORAGE_KEY)||'null');\n if(cached && typeof cached==='object') updateRtorrentFooterStats(cached, true);\n }catch(_){}\n }\n async function refreshFooterStatusNow(){\n try{\n const res=await fetch('/api/system/status', {cache:'no-store'});\n const j=await res.json();\n const s=j.status||{};\n if(j.ok && s){\n updateRtorrentFooterStats(s, false);\n saveFooterStatusCache(s);\n applyFooterPreferences();\n }\n }catch(_){}\n }\n socket.on('system_stats',s=>{\n const usageAvailable=s.usage_available!==false && s.cpu!==undefined && s.ram!==undefined;\n $('statCpuBox')?.classList.toggle('d-none',!usageAvailable);\n $('statRamBox')?.classList.toggle('d-none',!usageAvailable);\n $('systemChart')?.classList.toggle('d-none',!usageAvailable);\n if(usageAvailable){\n $('statCpu').textContent=s.cpu??'-';\n $('statRam').textContent=s.ram??'-';\n drawSystemUsage(s.cpu,s.ram);\n }\n $('statVersion').textContent=s.version||'-';\n applyLiveSpeedStats(s);\n lastLimits={down:Number(s.down_limit||0),up:Number(s.up_limit||0)};\n $('statDlLimit').textContent=s.down_limit_h||'∞';\n $('statUlLimit').textContent=s.up_limit_h||'∞';\n $('statTotalDl').textContent=compactTransferText(s.total_down_h);\n $('statTotalUl').textContent=compactTransferText(s.total_up_h);\n updateSpeedPeaks(s.speed_peaks||{});\n drawTraffic(s.down_rate,s.up_rate);\n if(diskMonitorMode==='default'){\n drawDiskUsage(s.disk);\n }else{\n refreshUserDiskUsage(false);\n }\n updateRtorrentFooterStats(s, false);\n saveFooterStatusCache(s);\n if(s.poller) fillPoller(null,s.poller);\n applyFooterPreferences();\n });\n document.addEventListener('change',e=>{ const sel=e.target.closest('#mobileFilterSelect'); if(!sel) return; setMobileFilterValue(sel.value); });\n document.addEventListener('click',e=>{ const mobileSort=e.target.closest('#mobileSortCycle'); if(mobileSort){ cycleMobileSort(); return; } const mobileSelectAll=e.target.closest('#mobileSelectAll'); if(mobileSelectAll){ const all=visibleRows.length>0 && visibleRows.every(t=>selected.has(t.hash)); if(all) visibleRows.forEach(t=>selected.delete(t.hash)); else visibleRows.forEach(t=>selected.add(t.hash)); if(selected.size===0){selectedHash=null;lastSelectedHash=null;} else {selectedHash=[...selected][selected.size-1];lastSelectedHash=selectedHash;} scheduleRender(true); return; } const mobileClear=e.target.closest('#mobileClearSelection'); if(mobileClear){ selected.clear(); selectedHash=null; lastSelectedHash=null; scheduleRender(true); return; } });\n updateSortHeaders(); setupColumnResizers(); applyColumnVisibility(); renderColumnManager(); restoreFooterStatusCache(); refreshFooterStatusNow(); renderFooterPreferences(); applyFooterPreferences(); updateFooterClock(); updateBrowserSpeedTitle(); setupTorrentDropZone(); setInterval(updateFooterClock,1000); scheduleRender(true); if(!hasActiveProfile) renderNoProfileState(); loadLabels().catch(()=>{}); loadRatios().catch(()=>{}); loadSmartQueue().catch(()=>{}); loadAutomations().catch(()=>{}); ensureDashboardToolsUI(); if(portCheckEnabled) loadPortCheck(false); else renderPortCheck({status:'disabled',enabled:false}); if(hasActiveProfile) applyDefaultDownloadPath(false).catch(()=>{}); if(hasActiveProfile) refreshUserDiskUsage(true).catch(()=>{}); scheduleTrackerSummary(true);\n"; diff --git a/pytorrent/static/js/charts.js b/pytorrent/static/js/charts.js new file mode 100644 index 0000000..45d7f86 --- /dev/null +++ b/pytorrent/static/js/charts.js @@ -0,0 +1 @@ +export const chartsSource = ' function drawTraffic(down, up){\n // Note: Live traffic rendering is throttled to animation frames to keep frequent socket updates smooth.\n traffic.push({down:Number(down||0), up:Number(up||0)});\n if(traffic.length>90) traffic.shift();\n if(drawTraffic.raf) return;\n drawTraffic.raf=requestAnimationFrame(()=>{\n drawTraffic.raf=0;\n const c=$(\'trafficChart\');\n if(!c) return;\n const rect=c.getBoundingClientRect();\n const dpr=window.devicePixelRatio||1;\n const cssW=Math.max(120, Math.floor(rect.width||c.width||300));\n const cssH=Math.max(32, Math.floor(rect.height||c.height||80));\n if(c.width!==Math.floor(cssW*dpr) || c.height!==Math.floor(cssH*dpr)){\n c.width=Math.floor(cssW*dpr);\n c.height=Math.floor(cssH*dpr);\n }\n const ctx=c.getContext(\'2d\');\n ctx.setTransform(dpr,0,0,dpr,0,0);\n ctx.clearRect(0,0,cssW,cssH);\n const max=Math.max(1,...traffic.map(p=>Math.max(p.down,p.up)));\n const pad=3;\n const drawSeries=(key,color)=>{\n ctx.beginPath();\n traffic.forEach((p,i)=>{\n const x=pad+i*((cssW-pad*2)/Math.max(1,traffic.length-1));\n const y=cssH-pad-(Number(p[key]||0)/max)*(cssH-pad*2);\n i?ctx.lineTo(x,y):ctx.moveTo(x,y);\n });\n ctx.lineWidth=1.75;\n ctx.lineJoin=\'round\';\n ctx.lineCap=\'round\';\n ctx.strokeStyle=color;\n ctx.stroke();\n };\n ctx.fillStyle=\'rgba(148,163,184,.12)\';\n ctx.fillRect(0,0,cssW,cssH);\n drawSeries(\'down\',\'#38bdf8\');\n drawSeries(\'up\',\'#f59e0b\');\n });\n }\n function drawSystemUsage(cpu,ram){\n const c=$(\'systemChart\'); if(!c) return;\n const cpuVal=Math.max(0,Math.min(100,Number(cpu||0)));\n const ramVal=Math.max(0,Math.min(100,Number(ram||0)));\n systemUsage.push({cpu:cpuVal,ram:ramVal}); if(systemUsage.length>60) systemUsage.shift();\n const ctx=c.getContext(\'2d\'), w=c.width, h=c.height; ctx.clearRect(0,0,w,h);\n ctx.fillStyle=\'rgba(148,163,184,.18)\'; ctx.fillRect(0,0,w,h);\n ctx.beginPath(); systemUsage.forEach((p,i)=>{const x=i*(w/Math.max(1,systemUsage.length-1)), y=h-(p.cpu/100)*h; i?ctx.lineTo(x,y):ctx.moveTo(x,y);}); ctx.strokeStyle=\'#a78bfa\'; ctx.stroke();\n ctx.beginPath(); systemUsage.forEach((p,i)=>{const x=i*(w/Math.max(1,systemUsage.length-1)), y=h-(p.ram/100)*h; i?ctx.lineTo(x,y):ctx.moveTo(x,y);}); ctx.strokeStyle=\'#22c55e\'; ctx.stroke();\n c.title=`CPU ${cpuVal.toFixed(1)}% / RAM ${ramVal.toFixed(1)}%`;\n }\n async function refreshUserDiskUsage(force=false){\n // Note: Profile switches force a fresh no-store disk read and ignore older in-flight responses.\n const now=Date.now();\n if(userDiskFetchInFlight && !force) return;\n if(!force && now-lastUserDiskFetchAt<15000) return;\n const seq=++userDiskFetchSeq;\n userDiskFetchInFlight=true;\n try{\n const res=await fetch(`/api/system/disk?_=${Date.now()}`, {cache:\'no-store\'});\n const json=await res.json();\n if(seq!==userDiskFetchSeq) return;\n if(json.ok && json.disk){\n lastUserDiskFetchAt=Date.now();\n drawDiskUsage(json.disk);\n }\n }catch(_){\n }finally{\n if(seq===userDiskFetchSeq) userDiskFetchInFlight=false;\n }\n }\n\n function diskUsageTooltip(disk){\n // Note: The footer tooltip explains the active disk source and every monitored path.\n const mode=disk.mode===\'aggregate\'?\'Aggregate\':disk.mode===\'selected\'?\'Selected path\':\'Default rTorrent path\';\n const lines=[mode, `Used: ${disk.used_h||\'-\'} / ${disk.total_h||\'-\'}`, `Free: ${disk.free_h||\'-\'}`];\n if(disk.path && disk.path!==\'aggregate\') lines.unshift(`Path: ${disk.path}`);\n if(disk.fallback) lines.push(`Measured on: ${disk.source_path||\'-\'}`);\n const paths=Array.isArray(disk.paths)?disk.paths:[];\n if(paths.length){\n lines.push(\'\', \'Monitored paths:\');\n paths.forEach(p=>{\n const marker=(disk.mode===\'selected\' && p.path===disk.path) ? \'*\' : \'+\';\n const measured=p.fallback && p.source_path ? `, measured on ${p.source_path}` : \'\';\n const pct=Number(p.percent||0);\n const shownPct=Number.isFinite(pct)?pct.toFixed(pct%1?1:0):\'0\';\n const status=p.ok ? `${shownPct}% used, ${p.free_h||\'-\'} free${measured}` : `unavailable${p.error?`: ${p.error}`:\'\'}`;\n lines.push(`${marker} ${p.path}: ${status}`);\n });\n }\n return lines.join(\'\\n\');\n }\n\n function drawDiskUsage(disk){\n const box=$(\'diskStatus\'), label=$(\'statDisk\'), c=$(\'diskChart\');\n if(!box||!label||!c)return;\n const ctx=c.getContext(\'2d\'), w=c.width, h=c.height;\n ctx.clearRect(0,0,w,h);\n const ok=disk&&disk.ok;\n const pct=ok?Math.max(0,Math.min(100,Number(disk.percent||0))):0;\n label.textContent=ok?`${pct.toFixed(pct%1?1:0)}%`:\'-\';\n box.classList.toggle(\'disk-warn\', !ok || pct>=90);\n box.title=ok?diskUsageTooltip(disk):`Disk usage unavailable${disk?.error?`\n${disk.error}`:\'\'}`;\n ctx.fillStyle=\'rgba(148,163,184,.22)\'; ctx.fillRect(0,5,w,14);\n ctx.fillStyle=pct>=90?\'#ef4444\':pct>=75?\'#f59e0b\':\'#22c55e\'; ctx.fillRect(0,5,Math.round(w*pct/100),14);\n ctx.strokeStyle=\'rgba(148,163,184,.55)\'; ctx.strokeRect(.5,5.5,w-1,13);\n }\n let lastTrafficHistory = null;\n let lastTrafficHistoryRange = \'7d\';\n let trafficHistoryAbort = null;\n const trafficHistoryCache = new Map();\n\n async function loadTrafficHistory(range="7d", force=false){\n const info=$(\'trafficHistoryInfo\');\n const volume=$(\'trafficHistoryChart\');\n const speed=$(\'trafficSpeedChart\');\n if(!volume||!speed) return;\n lastTrafficHistoryRange=range;\n const cached=trafficHistoryCache.get(range);\n if(cached && !force){\n lastTrafficHistory=cached;\n drawTrafficHistory(cached);\n updateTrafficHistoryInfo(cached);\n refreshTrafficHistoryInBackground(range);\n return;\n }\n if(info) info.textContent=\'Loading...\';\n await fetchTrafficHistory(range, true);\n }\n\n async function refreshTrafficHistoryInBackground(range){\n try{ await fetchTrafficHistory(range, false); }catch(_){ }\n }\n\n async function fetchTrafficHistory(range, showErrors){\n if(trafficHistoryAbort) trafficHistoryAbort.abort();\n trafficHistoryAbort = new AbortController();\n try{\n const res=await fetch(`/api/traffic/history?range=${encodeURIComponent(range)}`,{signal:trafficHistoryAbort.signal,cache:\'no-store\'});\n const j=await res.json();\n if(!j.ok) throw new Error(j.error||\'Failed to load history\');\n const history=j.history || {rows:[],range};\n trafficHistoryCache.set(range, history);\n if(range===lastTrafficHistoryRange){\n lastTrafficHistory=history;\n drawTrafficHistory(history);\n updateTrafficHistoryInfo(history);\n }\n }catch(e){\n if(e.name===\'AbortError\') return;\n if(showErrors){\n const info=$(\'trafficHistoryInfo\');\n if(info) info.textContent=e.message;\n [$(\'trafficHistoryChart\'),$(\'trafficSpeedChart\')].forEach(c=>{ if(c) c.getContext(\'2d\').clearRect(0,0,c.width,c.height); });\n }\n }finally{\n trafficHistoryAbort=null;\n }\n }\n\n function updateTrafficHistoryInfo(hist){\n const info=$(\'trafficHistoryInfo\');\n if(!info) return;\n const rows=Array.isArray(hist.rows)?hist.rows:[];\n const bucket=hist.bucket||\'bucket\';\n info.textContent=rows.length ? `${rows.length} ${bucket} bucket(s), retention ${hist.retention_days||90} days.` : \'No retained samples yet. Data is stored every minute while pyTorrent is running.\';\n }\n\n function setupChartCanvas(canvas){\n const rect=canvas.getBoundingClientRect();\n const dpr=window.devicePixelRatio||1;\n const cssW=Math.max(320, Math.floor(rect.width || canvas.parentElement?.clientWidth || 900));\n const cssH=Math.max(340, Math.floor(rect.height || 420));\n const pxW=Math.floor(cssW*dpr), pxH=Math.floor(cssH*dpr);\n if(canvas.width!==pxW || canvas.height!==pxH){\n canvas.width=pxW;\n canvas.height=pxH;\n }\n const ctx=canvas.getContext(\'2d\');\n ctx.setTransform(dpr,0,0,dpr,0,0);\n return {ctx,w:cssW,h:cssH,dpr};\n }\n function fmtBytes(v){\n v=Number(v||0);\n const u=[\'B\',\'KiB\',\'MiB\',\'GiB\',\'TiB\'];\n let i=0;\n while(v>=1024&&i({...r}));\n const step=Math.ceil(rows.length/limit);\n const output=[];\n for(let i=0;i{\n acc.downloaded+=Number(r.downloaded||0);\n acc.uploaded+=Number(r.uploaded||0);\n acc.downRate+=Number(r.avg_down_rate||0);\n acc.upRate+=Number(r.avg_up_rate||0);\n return acc;\n },{downloaded:0,uploaded:0,downRate:0,upRate:0});\n output.push({\n bucket: chunk[0]?.bucket || \'\',\n bucket_end: chunk[chunk.length-1]?.bucket || chunk[0]?.bucket || \'\',\n downloaded: sums.downloaded,\n uploaded: sums.uploaded,\n avg_down_rate: sums.downRate/chunk.length,\n avg_up_rate: sums.upRate/chunk.length,\n });\n }\n return output;\n }\n function cssColor(name, fallback){\n const value=getComputedStyle(document.documentElement).getPropertyValue(name).trim();\n return value || fallback;\n }\n function chartTheme(){\n const body=getComputedStyle(document.body);\n return {\n body: body.color || \'#d7d7d7\',\n muted: cssColor(\'--bs-secondary-color\', \'rgba(160,160,160,.85)\'),\n grid: \'rgba(var(--bs-border-color-rgb, 128,128,128), .45)\',\n panel: cssColor(\'--bs-body-bg\', \'#202020\'),\n surface: cssColor(\'--bs-secondary-bg\', \'#2b2b2b\'),\n border: cssColor(\'--bs-border-color\', \'rgba(128,128,128,.6)\'),\n down: \'#2f63c7\',\n up: \'#209638\',\n downFill: \'rgba(47,99,199,.36)\',\n upFill: \'rgba(32,150,56,.32)\',\n };\n }\n function drawChartPanel(ctx,w,h,theme){\n ctx.clearRect(0,0,w,h);\n ctx.fillStyle=theme.surface;\n ctx.fillRect(0,0,w,h);\n ctx.strokeStyle=theme.border;\n ctx.lineWidth=1;\n ctx.strokeRect(.5,.5,w-1,h-1);\n }\n function chartLayout(w,h){ return {left:72,right:22,top:34,bottom:42,width:w-94,height:h-76}; }\n function drawGrid(ctx,layout,maxValue,theme,suffix=\'\'){\n ctx.strokeStyle=theme.grid;\n ctx.fillStyle=theme.muted;\n ctx.font=\'11px system-ui\';\n ctx.lineWidth=1;\n for(let i=0;i<=4;i++){\n const y=layout.top+(layout.height*i/4);\n const value=maxValue*(1-i/4);\n ctx.beginPath();\n ctx.moveTo(layout.left,y);\n ctx.lineTo(layout.left+layout.width,y);\n ctx.stroke();\n ctx.fillText(`${fmtBytes(value)}${suffix}`,8,y+4);\n }\n }\n function drawLegend(ctx,title,theme,labels){\n ctx.fillStyle=theme.body;\n ctx.font=\'600 12px system-ui\';\n ctx.fillText(title,14,21);\n ctx.font=\'11px system-ui\';\n const width=ctx.canvas.width/(window.devicePixelRatio||1);\n const x=Math.max(120,width-154);\n ctx.fillStyle=theme.down; ctx.fillRect(x,10,12,10);\n ctx.fillStyle=theme.body; ctx.fillText(labels.down,x+18,19);\n ctx.fillStyle=theme.up; ctx.fillRect(x,28,12,10);\n ctx.fillStyle=theme.body; ctx.fillText(labels.up,x+18,37);\n }\n function pickXAxisIndexes(rows, maxTicks=9){\n if(rows.length<=1) return rows.length?[0]:[];\n const count=Math.min(maxTicks, rows.length);\n const seen=new Set();\n for(let i=0;ia-b);\n }\n function drawXAxis(ctx,layout,rows,theme,range){\n if(!rows.length) return;\n const y=layout.top+layout.height+20;\n ctx.fillStyle=theme.muted;\n ctx.font=\'11px system-ui\';\n ctx.strokeStyle=theme.grid;\n pickXAxisIndexes(rows).forEach((idx)=>{\n const x=layout.left+idx*(layout.width/Math.max(1,rows.length-1));\n const label=formatBucketLabel(rows[idx].bucket, range);\n const width=ctx.measureText(label).width;\n ctx.beginPath();\n ctx.moveTo(x,layout.top+layout.height);\n ctx.lineTo(x,layout.top+layout.height+4);\n ctx.stroke();\n ctx.fillText(label,Math.max(2,Math.min(x-width/2,layout.left+layout.width-width)),y);\n });\n }\n function drawRuTorrentLine(ctx,rows,layout,maxValue,key,color,fillColor){\n const points=rows.map((r,i)=>({\n x: layout.left+i*(layout.width/Math.max(1,rows.length-1)),\n y: layout.top+layout.height-(Number(r[key]||0)/maxValue)*layout.height,\n }));\n if(!points.length) return;\n ctx.save();\n ctx.beginPath();\n points.forEach((p,i)=>{ i?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y); });\n ctx.lineTo(points[points.length-1].x, layout.top+layout.height);\n ctx.lineTo(points[0].x, layout.top+layout.height);\n ctx.closePath();\n ctx.fillStyle=fillColor||color;\n ctx.fill();\n ctx.beginPath();\n points.forEach((p,i)=>{ i?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y); });\n ctx.lineWidth=2.4;\n ctx.lineJoin=\'round\';\n ctx.lineCap=\'round\';\n ctx.strokeStyle=color;\n ctx.stroke();\n ctx.restore();\n }\n function drawHoverMarker(ctx,rows,layout,idx,theme){\n if(idx<0||idx>=rows.length) return;\n const x=layout.left+idx*(layout.width/Math.max(1,rows.length-1));\n ctx.save();\n ctx.strokeStyle=theme.body;\n ctx.globalAlpha=.55;\n ctx.beginPath();\n ctx.moveTo(x,layout.top);\n ctx.lineTo(x,layout.top+layout.height);\n ctx.stroke();\n ctx.restore();\n }\n function drawEmptyChart(canvas,message){\n const {ctx,w,h}=setupChartCanvas(canvas);\n const theme=chartTheme();\n drawChartPanel(ctx,w,h,theme);\n ctx.fillStyle=theme.muted;\n ctx.font=\'13px system-ui\';\n ctx.fillText(message,18,34);\n }\n function tooltipNode(){\n let node=document.querySelector(\'.traffic-chart-tooltip\');\n if(node) return node;\n node=document.createElement(\'div\');\n node.className=\'traffic-chart-tooltip d-none\';\n document.body.appendChild(node);\n return node;\n }\n function hideTrafficTooltip(){ tooltipNode().classList.add(\'d-none\'); }\n function showTrafficTooltip(canvas,event,row,kind,range){\n const node=tooltipNode();\n const title=tooltipBucketLabel(row.bucket, range);\n const end=row.bucket_end && row.bucket_end!==row.bucket ? ` - ${tooltipBucketLabel(row.bucket_end, range)}` : \'\';\n const body=kind===\'speed\'\n ? `
Download: ${fmtBytes(row.avg_down_rate)}/s
Upload: ${fmtBytes(row.avg_up_rate)}/s
`\n : `
Downloaded: ${fmtBytes(row.downloaded)}
Uploaded: ${fmtBytes(row.uploaded)}
`;\n node.innerHTML=`
${esc(title+end)}
${body}`;\n node.classList.remove(\'d-none\');\n const box=node.getBoundingClientRect();\n let left=event.clientX+14;\n let top=event.clientY+14;\n if(left+box.width>window.innerWidth-8) left=event.clientX-box.width-14;\n if(top+box.height>window.innerHeight-8) top=event.clientY-box.height-14;\n node.style.left=`${Math.max(8,left)}px`;\n node.style.top=`${Math.max(8,top)}px`;\n }\n function attachTrafficTooltip(canvas,rows,layout,kind,range){\n canvas._trafficTooltip={rows,layout,kind,range};\n if(canvas._trafficTooltipReady) return;\n canvas._trafficTooltipReady=true;\n canvas.addEventListener(\'mousemove\',event=>{\n const data=canvas._trafficTooltip;\n if(!data||!data.rows.length) return;\n const rect=canvas.getBoundingClientRect();\n const x=event.clientX-rect.left;\n const inside=x>=data.layout.left && x<=data.layout.left+data.layout.width;\n if(!inside){ hideTrafficTooltip(); return; }\n const idx=Math.max(0,Math.min(data.rows.length-1,Math.round((x-data.layout.left)/data.layout.width*(data.rows.length-1))));\n canvas._trafficHoverIndex=idx;\n drawTrafficHistory(lastTrafficHistory);\n showTrafficTooltip(canvas,event,data.rows[idx],data.kind,data.range);\n });\n canvas.addEventListener(\'mouseleave\',()=>{ canvas._trafficHoverIndex=-1; hideTrafficTooltip(); if(lastTrafficHistory) drawTrafficHistory(lastTrafficHistory); });\n }\n function drawTrafficHistory(hist){\n const sourceRows=Array.isArray(hist.rows)?hist.rows:[];\n const rows=downsampleRows(sourceRows);\n const range=hist.range||lastTrafficHistoryRange||\'7d\';\n const volume=$(\'trafficHistoryChart\'), speed=$(\'trafficSpeedChart\');\n if(!volume||!speed) return;\n if(!sourceRows.length){\n drawEmptyChart(volume,\'No history yet. Samples appear after pyTorrent records traffic.\');\n drawEmptyChart(speed,\'No speed samples yet.\');\n return;\n }\n const theme=chartTheme();\n\n let canvas=setupChartCanvas(volume);\n let {ctx,w,h}=canvas;\n let layout=chartLayout(w,h);\n let maxVol=Math.max(1,...rows.map(r=>Math.max(Number(r.downloaded||0),Number(r.uploaded||0))));\n drawChartPanel(ctx,w,h,theme);\n drawGrid(ctx,layout,maxVol,theme,\'\');\n drawRuTorrentLine(ctx,rows,layout,maxVol,\'downloaded\',theme.down,theme.downFill);\n drawRuTorrentLine(ctx,rows,layout,maxVol,\'uploaded\',theme.up,theme.upFill);\n drawHoverMarker(ctx,rows,layout,volume._trafficHoverIndex ?? -1,theme);\n drawLegend(ctx,\'Transferred data\',theme,{down:\'Downloaded\',up:\'Uploaded\'});\n drawXAxis(ctx,layout,rows,theme,range);\n attachTrafficTooltip(volume,rows,layout,\'transfer\',range);\n\n canvas=setupChartCanvas(speed);\n ({ctx,w,h}=canvas);\n layout=chartLayout(w,h);\n const maxSpeed=Math.max(1,...rows.map(r=>Math.max(Number(r.avg_down_rate||0),Number(r.avg_up_rate||0))));\n drawChartPanel(ctx,w,h,theme);\n drawGrid(ctx,layout,maxSpeed,theme,\'/s\');\n drawRuTorrentLine(ctx,rows,layout,maxSpeed,\'avg_down_rate\',theme.down,theme.downFill);\n drawRuTorrentLine(ctx,rows,layout,maxSpeed,\'avg_up_rate\',theme.up,theme.upFill);\n drawHoverMarker(ctx,rows,layout,speed._trafficHoverIndex ?? -1,theme);\n drawLegend(ctx,\'Speed trend\',theme,{down:\'Download\',up:\'Upload\'});\n drawXAxis(ctx,layout,rows,theme,range);\n attachTrafficTooltip(speed,rows,layout,\'speed\',range);\n }\n $(\'trafficModal\')?.addEventListener("show.bs.modal",()=>loadTrafficHistory(lastTrafficHistoryRange||"7d"));\n document.querySelectorAll(\'.traffic-history-tab\').forEach(tab=>tab.addEventListener(\'shown.bs.tab\',()=>{ if(lastTrafficHistory) requestAnimationFrame(()=>drawTrafficHistory(lastTrafficHistory)); }));\n document.querySelectorAll(".traffic-range").forEach(b=>b.addEventListener("click",()=>{\n document.querySelectorAll(".traffic-range").forEach(x=>{x.classList.remove("btn-primary");x.classList.add("btn-outline-secondary");});\n b.classList.add("btn-primary"); b.classList.remove("btn-outline-secondary");\n loadTrafficHistory(b.dataset.range||"7d");\n }));\n window.addEventListener(\'resize\',()=>{ if(document.body.classList.contains(\'modal-open\') && lastTrafficHistory) requestAnimationFrame(()=>drawTrafficHistory(lastTrafficHistory)); });\n'; diff --git a/pytorrent/static/js/createTorrent.js b/pytorrent/static/js/createTorrent.js new file mode 100644 index 0000000..3bf3e50 --- /dev/null +++ b/pytorrent/static/js/createTorrent.js @@ -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"; diff --git a/pytorrent/static/js/dashboard.js b/pytorrent/static/js/dashboard.js new file mode 100644 index 0000000..b8d0a1b --- /dev/null +++ b/pytorrent/static/js/dashboard.js @@ -0,0 +1 @@ +export const dashboardSource = "const NOTIFICATION_STORAGE_KEY = 'pytorrent.notifications.v1';\nconst HEALTH_PANE_STORAGE_KEY = 'pytorrent.healthPane.v1';\nconst SMART_VIEW_DEFS = [\n ['smart:needs_attention', 'Needs attention', 'Errors, dead torrents, inactive downloads or stalled seeding.'],\n ['smart:large_slow', 'Large and slow', 'Large active downloads below the slow speed threshold.'],\n ['smart:seeding_too_long', 'Seeding too long', 'Completed torrents seeding longer than 14 days or above ratio 2.0.'],\n ['smart:new_rss', 'New from RSS', 'RSS-labeled torrents added during the last 7 days.'],\n ['smart:no_label', 'No label', 'Torrents without any label.'],\n ['smart:private_trackers', 'Private trackers', 'Torrents matched by known private tracker domains.'],\n];\nfunction torrentTrackers(t){\n return trackerRowsForHash(t.hash).map(x=>String(x.domain||'')).filter(Boolean);\n}\nfunction torrentSearchText(t){\n return [\n t.name, t.hash, t.label, t.path, t.ratio_group, t.status, t.message,\n t.size_h, t.progress, torrentWarning(t), ...torrentTrackers(t),\n ].filter(v=>v!==undefined&&v!==null).join(' ').toLowerCase();\n}\nfunction torrentAgeSeconds(t){\n const created=Number(t.created||0);\n return created ? Math.max(0, Date.now()/1000-created) : 0;\n}\nfunction torrentCompletedAgeSeconds(t){\n const completedAt=Number(t.completed_at||t.finished_at||t.done_at||0);\n if(completedAt > 0) return Math.max(0, Date.now()/1000-completedAt);\n if(t.complete) return 0;\n return torrentAgeSeconds(t);\n}\nfunction torrentRatio(t){ return Number(t.ratio||0); }\nfunction torrentSize(t){ return Number(t.size||0); }\nfunction torrentDownRate(t){ return Number(t.down_rate||0); }\nfunction isIncompleteTorrent(t){ return !t.complete; }\nfunction isRunningTorrent(t){ return !!t.state && !t.paused; }\nfunction isSlowTorrent(t){ return torrentDownRate(t) > 0 && torrentDownRate(t) < 64*1024; }\nfunction isLargeTorrent(t){ return torrentSize(t) >= 20*1024*1024*1024; }\nfunction isDeadTorrent(t){ return isIncompleteTorrent(t) && Number(t.seeds||0) <= 0 && Number(t.peers||0) <= 0; }\nfunction shouldBeActiveTorrent(t){ return isIncompleteTorrent(t) && !isChecking(t) && !t.paused && !isRunningTorrent(t); }\nfunction isPrivateTrackerDomain(domain){\n return /(iptorrents|torrentleech|beyond-hd|passthepopcorn|btn|redacted|empornium|gazelle|private|hd-torrents|filelist|alpharatio|avistaz|cinemaz|animetorrents)/i.test(domain||'');\n}\nfunction smartViewVisible(t, view){\n const warning=torrentWarning(t);\n if(view==='smart:needs_attention') return !!warning || isDeadTorrent(t) || shouldBeActiveTorrent(t) || (t.complete && Number(t.seeds||0) <= 0);\n if(view==='smart:large_slow') return isIncompleteTorrent(t) && isRunningTorrent(t) && isLargeTorrent(t) && isSlowTorrent(t);\n if(view==='smart:seeding_too_long') return !!t.complete && (torrentRatio(t) >= 2 || torrentCompletedAgeSeconds(t) >= 14*86400);\n if(view==='smart:new_rss') return /rss/i.test(String(t.label||'') + ' ' + String(t.path||'')) && torrentAgeSeconds(t) <= 7*86400;\n if(view==='smart:no_label') return !labelNames(t.label).length;\n if(view==='smart:private_trackers') return torrentTrackers(t).some(isPrivateTrackerDomain);\n return true;\n}\nfunction duplicateTorrentRows(rows){\n const groups=new Map();\n rows.forEach(t=>{\n const name=String(t.name||'').trim().toLowerCase();\n if(!name) return;\n const key=`${name}|${torrentSize(t)||''}`;\n if(!groups.has(key)) groups.set(key,[]);\n groups.get(key).push(t);\n });\n return [...groups.values()].filter(g=>g.length>1).flat();\n}\nfunction healthRows(){\n const rows=trackerScopedRows();\n return {\n noSeeders: rows.filter(t=>isIncompleteTorrent(t) && Number(t.seeds||0)<=0),\n stoppedActive: rows.filter(shouldBeActiveTorrent),\n trackerErrors: rows.filter(t=>torrentWarning(t)),\n duplicates: duplicateTorrentRows(rows),\n slowest: rows.filter(t=>isIncompleteTorrent(t) && isRunningTorrent(t)).sort((a,b)=>torrentDownRate(a)-torrentDownRate(b)).slice(0,12),\n dead: rows.filter(isDeadTorrent),\n largest: rows.slice().sort((a,b)=>torrentSize(b)-torrentSize(a)).slice(0,12),\n belowRatio: rows.filter(t=>t.complete && torrentRatio(t)<1).sort((a,b)=>torrentRatio(a)-torrentRatio(b)).slice(0,12),\n };\n}\nfunction healthSection(title, rows, note){\n const sample=rows.slice(0,8).map(t=>``).join('');\n return `
${esc(title)}${esc(rows.length)}
${esc(note)}
${sample||'No items.'}
`;\n}\nfunction activeHealthPane(){\n const value=localStorage.getItem(HEALTH_PANE_STORAGE_KEY)||'availability';\n return ['availability','quality','size'].includes(value) ? value : 'availability';\n}\nfunction setHealthPane(pane){\n const box=$('healthDashboardManager');\n if(!box) return;\n localStorage.setItem(HEALTH_PANE_STORAGE_KEY, pane);\n box.querySelectorAll('[data-health-pane]').forEach(x=>x.classList.toggle('active',x.dataset.healthPane===pane));\n box.querySelectorAll('[data-health-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.healthPanel!==pane));\n}\nfunction renderHealthDashboard(){\n const box=$('healthDashboardManager');\n if(!box) return;\n const h=healthRows();\n const active=activeHealthPane();\n const panes=[\n ['availability','Availability', `${healthSection('Torrents without seeders',h.noSeeders,'Incomplete torrents with zero reported seeders.')}${healthSection('Stopped torrents that should be active',h.stoppedActive,'Incomplete torrents stopped outside explicit pause state.')}${healthSection('Dead torrents',h.dead,'No seeders and no peers.')}`],\n ['quality','Quality', `${healthSection('Tracker errors',h.trackerErrors,'Rows with tracker or torrent warning state.')}${healthSection('Duplicate torrents',h.duplicates,'Same normalized name and size appear more than once.')}${healthSection('Slowest torrents',h.slowest,'Running incomplete torrents sorted by current download speed.')}`],\n ['size','Size / ratio', `${healthSection('Largest torrents',h.largest,'Largest torrents in the current profile.')}${healthSection('Below target ratio',h.belowRatio,'Completed torrents below the default ratio target 1.0.')}`]\n ];\n box.innerHTML=`
    ${panes.map(p=>`
  • `).join('')}
${panes.map(p=>`
${p[2]}
`).join('')}`;\n}\nfunction renderSmartViewsManager(){\n const box=$('smartViewsManager');\n if(!box) return;\n const rows=trackerScopedRows();\n box.innerHTML=`
${SMART_VIEW_DEFS.map(([key,label,note])=>``).join('')}
`;\n}\nfunction notificationItems(){\n try{ return JSON.parse(localStorage.getItem(NOTIFICATION_STORAGE_KEY)||'[]'); }catch(e){ return []; }\n}\nfunction saveNotificationItems(items){ localStorage.setItem(NOTIFICATION_STORAGE_KEY, JSON.stringify(items.slice(0,120))); }\nfunction recordNotification(type, title, message){\n const item={at:new Date().toISOString(), type:String(type||'info'), title:String(title||type||'Notification'), message:String(message||'')};\n const items=[item,...notificationItems()].slice(0,120);\n saveNotificationItems(items);\n renderNotificationCenter();\n updateNotificationBadge();\n}\nfunction notificationIcon(type){\n if(type==='error') return 'fa-triangle-exclamation';\n if(type==='warning') return 'fa-circle-exclamation';\n if(type==='planner') return 'fa-calendar-days';\n if(type==='queue') return 'fa-shuffle';\n return 'fa-circle-info';\n}\nfunction updateNotificationBadge(){\n const btn=document.querySelector('.tool-tab[data-tool=\"notifications\"]');\n if(!btn) return;\n const count=notificationItems().length;\n btn.innerHTML=` Notifications${count?` ${count}`:''}`;\n}\nfunction renderNotificationCenter(){\n const box=$('notificationCenterManager');\n if(!box) return;\n const items=notificationItems();\n box.innerHTML=`
${esc(items.length)} saved event(s)
${items.map(x=>`
${esc(x.title)}${esc(x.message)}${esc(new Date(x.at).toLocaleString())}
`).join('')||'No notifications yet.'}
`;\n $('clearNotificationsBtn')?.addEventListener('click',()=>{ saveNotificationItems([]); renderNotificationCenter(); updateNotificationBadge(); });\n}\nfunction diagnosticsSection(title, cards){\n return `
${esc(title)}
${cards.join('')}
`;\n}\nasync function loadDiagnosticsPage(){\n const box=$('diagnosticsPageManager');\n if(!box) return;\n box.innerHTML=' Loading diagnostics...';\n try{\n const [status,poller,planner,smart]=await Promise.all([\n fetch('/api/app/status').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({})),\n fetch('/api/download-planner/preview').then(r=>r.json()).catch(()=>({})),\n fetch('/api/smart-queue?history_limit=100').then(r=>r.json()).catch(()=>({ok:false})),\n ]);\n if(status && status.ok===false) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, profile=st.profile||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, cleanup=st.cleanup||{}, db=cleanup.database||{}, pc=st.port_check||{};\n const rt=poller.runtime||{}, ps=poller.settings||{}, pv=planner.preview||{}, smartStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null;\n const profileCards=[diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), diagCard('Incoming port', pc.port||'-'), diagCard('Port status', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':'')];\n const rtCards=[diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('Connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('First byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('Total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'), diagCard('Request bytes', scgi.request_bytes), diagCard('Response bytes', scgi.response_bytes), diagCard('XML bytes', scgi.xml_bytes), diagCard('rTorrent version', scgi.client_version||'-')];\n const pollerCards=[diagCard('Adaptive', ps.adaptive_enabled===false?'off':'on'), diagCard('Mode', rt.adaptive_mode||'-'), diagCard('Effective interval', `${rt.effective_interval_seconds??'-'}s`), diagCard('Minimum interval', `${rt.configured_min_interval_seconds??'-'}s`), diagCard('Tick duration', `${rt.duration_ms||rt.last_tick_ms||0} ms`), diagCard('Tick gap', `${rt.last_tick_gap_ms||0} ms`), diagCard('Payload', fmtBytes(rt.emitted_payload_size||0)), diagCard('rTorrent calls', rt.rtorrent_call_count||0), diagCard('Skipped emissions', rt.skipped_emissions||0), diagCard('Ticks', rt.tick_count||0)];\n const plannerCards=[diagCard('Matched rule', pv.matched_rule||'-'), diagCard('Next change', pv.next_change_at||'-'), diagCard('Planner state', pv.enabled===false?'disabled':'enabled')];\n const databaseCards=[diagCard('DB size', db.size_h||'-'), diagCard('Job logs', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-')];\n const workerCards=[diagCard('Worker threads', py.worker_threads ?? '-'), diagCard('Jobs total', py.jobs_total ?? '-'), diagCard('Threads', py.threads ?? '-'), diagCard('CPU', `${py.cpu_percent ?? '-'}%`)];\n const smartBlock=`
Smart Queue decisions
${renderSmartQueueNerdStats(smartStats)}
`;\n box.innerHTML=[diagnosticsSection('Profile and port',profileCards), diagnosticsSection('rTorrent connection',rtCards), diagnosticsSection('Adaptive poller',pollerCards), diagnosticsSection('Planner',plannerCards), diagnosticsSection('Database and cleanup',databaseCards), diagnosticsSection('Worker state',workerCards), smartBlock, scgi.error?`
${esc(scgi.error)}
`:''].join('');\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n}\nfunction ensureDashboardToolsUI(){\n const host=$('toolRss')?.parentElement || document.querySelector('#toolsModal .modal-body');\n if(!host) return;\n addToolTab('smartviews','fa-layer-group','Smart Views','torrentstats');\n addToolTab('notifications','fa-bell','Notifications','appstatus');\n const stats=$('toolTorrentStats');\n if(stats && !$('healthDashboardManager')){\n const section=document.createElement('div');\n section.className='surface-section mt-3';\n section.innerHTML='
Torrent health
Live health buckets calculated from the current torrent snapshot.
';\n stats.appendChild(section);\n section.addEventListener('click',e=>{ const tab=e.target.closest('[data-health-pane]'); if(tab){ const pane=tab.dataset.healthPane; section.querySelectorAll('[data-health-pane]').forEach(x=>x.classList.toggle('active',x.dataset.healthPane===pane)); section.querySelectorAll('[data-health-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.healthPanel!==pane)); return; } const row=e.target.closest('[data-hash]'); if(!row) return; selectedHash=row.dataset.hash; selected.clear(); selected.add(selectedHash); scheduleRender(true); });\n }\n if(!$('toolSmartviews')){\n const p=document.createElement('div');\n p.id='toolSmartviews';\n p.className='d-none';\n p.innerHTML='
Smart Views
One-click filters for common torrent maintenance tasks.
';\n host.appendChild(p);\n p.addEventListener('click',e=>{ const card=e.target.closest('.smart-view-card'); if(!card) return; activeTrackerFilter=''; activeFilter=card.dataset.filter||'all'; mobileActiveFilterKey=activeFilter; saveActiveFilterPreference(); syncFilterButtons(); scheduleRender(true); renderSmartViewsManager(); });\n }\n if(!$('toolNotifications')){\n const p=document.createElement('div');\n p.id='toolNotifications';\n p.className='d-none';\n p.innerHTML='
Notification center
Persistent local history for rTorrent, RSS, automation, disk, queue, planner and port events.
';\n host.appendChild(p);\n }\n renderHealthDashboard();\n renderSmartViewsManager();\n renderNotificationCenter();\n updateNotificationBadge();\n}\n"; diff --git a/pytorrent/static/js/modals.js b/pytorrent/static/js/modals.js new file mode 100644 index 0000000..e869bb7 --- /dev/null +++ b/pytorrent/static/js/modals.js @@ -0,0 +1 @@ +export const modalsSource = " function copyText(text){\n text=String(text ?? '');\n if(navigator.clipboard && window.isSecureContext){\n return navigator.clipboard.writeText(text);\n }\n return new Promise((resolve,reject)=>{\n const ta=document.createElement('textarea');\n ta.value=text; ta.setAttribute('readonly','');\n ta.style.position='fixed'; ta.style.left='-9999px'; ta.style.top='0';\n document.body.appendChild(ta); ta.focus(); ta.select();\n try{ document.execCommand('copy') ? resolve() : reject(new Error('copy command failed')); }\n catch(e){ reject(e); }\n finally{ ta.remove(); }\n });\n }\n function copySelected(field){\n const t=torrents.get(selectedHash);\n if(!t) return toast('No torrent selected','warning');\n const value=String(t[field] ?? '');\n if(!value) return toast(`No ${field} to copy`,'warning');\n copyText(value).then(()=>toast(`Copied ${field}`,'success')).catch(()=>toast('Copy failed','danger'));\n }\n\n async function getDefaultDownloadPath(){ if(defaultDownloadPath) return defaultDownloadPath; try{ const j=await (await fetch('/api/path/default')).json(); if(j.ok && j.path) defaultDownloadPath=j.path; }catch(e){} return defaultDownloadPath || '/'; }\n async function applyDefaultDownloadPath(force=false){ const p=await getDefaultDownloadPath(); ['addPath','rssPath','autoEffectPath'].forEach(id=>{ const el=$(id); if(el && (force || !el.value)) el.value=p; }); return p; }\n async function openPathPicker(target){\n pathTarget=target;\n const modal=$('pathModal');\n if(!modal) return toast('Path picker is unavailable','danger');\n const def=await getDefaultDownloadPath();\n const initial=def || ($(target)?.value||'/');\n // Note: The same modal is used for Move and simple path selection; only Move shows extra options.\n $('moveOptions')?.classList.toggle('d-none', target!=='move');\n if($('moveDataPhysical')) $('moveDataPhysical').checked=true;\n if($('moveRecheck')) $('moveRecheck').checked=true;\n // Note: The path picker can be opened from Add/Create modals, so it must sit above the parent modal.\n modal.classList.toggle('path-picker-stacked', document.querySelectorAll('.modal.show').length > 0);\n new bootstrap.Modal(modal).show();\n browsePath(initial);\n }\n function pathInfoHtml(j){\n // Note: Move modal shows remote-side capacity and entry counts before queuing a move.\n const meta=[];\n if(j.free_h) meta.push(` Free ${esc(j.free_h)}`);\n if(j.used_percent!==undefined) meta.push(`${esc(j.used_percent)}% used`);\n if(j.dir_count!==undefined) meta.push(`${esc(j.dir_count)} dirs`);\n if(j.file_count!==undefined) meta.push(`${esc(j.file_count)} files`);\n return meta.length ? `
${meta.join('')}
` : '';\n }\n async function browsePath(path){\n const list=$('pathList');\n const current=$('pathCurrent');\n if(!list || !current) return;\n list.innerHTML=' Loading...';\n try{\n const res=await fetch(`/api/path/browse?path=${encodeURIComponent(path||'/')}`);\n const j=await res.json();\n if(!j.ok) throw new Error(j.error);\n current.value=j.path;\n lastPathParent=j.parent;\n const rows=j.dirs.map(d=>`
${esc(d.name)}
`).join('')||'
No directories.
';\n list.innerHTML=pathInfoHtml(j)+rows;\n }catch(e){\n list.innerHTML=`
${esc(e.message)}
`;\n }\n }\n $('pathList')?.addEventListener('click',e=>{const r=e.target.closest('.path-row'); if(r) browsePath(r.dataset.path);});\n $('pathGoBtn')?.addEventListener('click',()=>browsePath($('pathCurrent')?.value));\n $('pathUpBtn')?.addEventListener('click',()=>browsePath(lastPathParent));\n $('pathReloadBtn')?.addEventListener('click',()=>browsePath($('pathCurrent')?.value));\n $('pathSelectBtn')?.addEventListener('click',async()=>{\n const p=($('pathCurrent')?.value||'').trim();\n if(!p) return toast('Path is empty','warning');\n if(pathTarget==='move'){\n const hashes=selectedHashes();\n if(!hashes.length) return toast('No torrents selected','warning');\n const j=await post('/api/torrents/move',{hashes,path:p,move_data:!!($('moveDataPhysical')?.checked),recheck:!!($('moveRecheck')?.checked)});\n markQueuedJobs(j,hashes,'move');\n const parts=Number(j.bulk_parts||1);\n toast(parts>1?`move queued in ${parts} bulk parts`:$('moveDataPhysical')?.checked?'physical move queued':'move queued','success');\n } else if($(pathTarget)) {\n $(pathTarget).value=p;\n }\n bootstrap.Modal.getInstance($('pathModal'))?.hide();\n });\n document.querySelectorAll('.browse-path').forEach(b=>b.addEventListener('click',()=>openPathPicker(b.dataset.target)));\n\n function columnPrefsPayload(){\n return JSON.stringify({hidden:cleanColumnPrefsHidden(hiddenColumns), shown:cleanColumnPrefsHidden(DEFAULT_HIDDEN_COLUMNS).filter(key => !hiddenColumns.has(key)), mobile:mobileColumns, mobileSmartFiltersEnabled, widths:columnWidths});\n }\n function parseTableColumnsPreference(value){\n if(!value) return {};\n if(typeof value === 'object') return value;\n try{ return JSON.parse(value); }catch(e){ return {}; }\n }\n function applyTableColumnsPreference(value){\n const prefs = parseTableColumnsPreference(value);\n const explicitlyShown = new Set(prefs.shown || []);\n hiddenColumns = new Set([...(prefs.hidden || []), ...[...DEFAULT_HIDDEN_COLUMNS].filter(key => !explicitlyShown.has(key))]);\n mobileColumns = normalizeMobileColumns(prefs.mobile || {});\n mobileSmartFiltersEnabled = prefs.mobileSmartFiltersEnabled ?? true;\n columnWidths = normalizeColumnWidths(prefs.widths || {});\n saveBrowserViewPrefs({mobileColumns, mobileSmartFiltersEnabled, columnWidths});\n }\n function renderColumnCards(defs, values, inputClass, dataAttr, icon){\n return defs.map(([key,label,hiddenByDefault])=>{\n const active = !!values[key];\n return ``;\n }).join('');\n }\n function renderColumnManager(){\n const box=$('columnManager');\n if(!box) return;\n const desktopValues = Object.fromEntries(COLUMN_DEFS.map(([key])=>[key, !hiddenColumns.has(key)]));\n const desktop = renderColumnCards(COLUMN_DEFS, desktopValues, 'column-toggle', 'data-col-key', 'fa-table-columns');\n const mobile = renderColumnCards(MOBILE_COLUMN_DEFS, mobileColumns, 'mobile-column-toggle', 'data-mobile-col-key', 'fa-mobile-screen');\n const smart = ``;\n box.innerHTML=`
${desktop}
${mobile}${smart}
`;\n }\n $('saveColumnsBtn')?.addEventListener('click',async()=>{ document.querySelectorAll('.column-toggle').forEach(cb=>cb.checked?hiddenColumns.delete(cb.dataset.colKey):hiddenColumns.add(cb.dataset.colKey)); document.querySelectorAll('.mobile-column-toggle').forEach(cb=>mobileColumns[cb.dataset.mobileColKey]=cb.checked); mobileSmartFiltersEnabled = $('mobileSmartFiltersToggle')?.checked ?? true; saveBrowserViewPrefs({mobileColumns, mobileSmartFiltersEnabled, columnWidths}); applyColumnVisibility(); scheduleRender(true); await post('/api/preferences',{table_columns_json:columnPrefsPayload()}).catch(e=>toast(e.message,'danger')); toast('Columns saved','success'); });\n $('resetColumnsBtn')?.addEventListener('click',async()=>{ hiddenColumns = new Set(DEFAULT_HIDDEN_COLUMNS); mobileColumns = normalizeMobileColumns(); mobileSmartFiltersEnabled = true; columnWidths = normalizeColumnWidths(); saveBrowserViewPrefs({mobileColumns, mobileSmartFiltersEnabled, columnWidths}); renderColumnManager(); applyColumnVisibility(); scheduleRender(true); await post('/api/preferences',{table_columns_json:columnPrefsPayload()}).catch(()=>{}); });\n $('recommendedColumnsBtn')?.addEventListener('click',async()=>{\n try{\n // Note: The recommended layout is applied by the backend and includes desktop, mobile and widths.\n const j = await post('/api/preferences/table-columns/recommended', {});\n applyTableColumnsPreference(j.preferences?.table_columns_json);\n renderColumnManager();\n applyColumnVisibility();\n scheduleRender(true);\n toast('Recommended columns applied','success');\n }catch(e){\n toast(e.message,'danger');\n }\n });\n\n function jobActions(r){ const id=esc(r.id); const status=String(r.status||''); const actions=[]; if(status==='failed'||status==='cancelled') actions.push(``); if(status==='pending') actions.push(``); if(status==='pending'||status==='running') actions.push(``); return actions.join(' ') || '-'; }\n function jobStatusBadgeClass(status){\n // Note: Running means active work, so it uses primary instead of danger; danger stays reserved for failed.\n const classes={done:'success',failed:'danger',running:'primary',cancelled:'secondary',pending:'warning'};\n return classes[String(status||'')] || 'warning';\n }\n async function loadJobs(page=jobsPage){\n const box=$('jobsTable');\n // Note: Finished shows only a real finished_at value; running/pending do not receive a date from updated_at.\n if(!box) return;\n jobsPage=Math.max(0,page|0);\n box.innerHTML=' Loading jobs...';\n const offset=jobsPage*jobsLimit;\n const j=await (await fetch(`/api/jobs?limit=${jobsLimit}&offset=${offset}`)).json();\n const rows=j.jobs||[];\n jobsTotal=Number(j.total||rows.length);\n const details=r=>{\n const count=Number(r.hash_count||0);\n if(r.is_bulk || count>1) return `bulk
${esc(count)} torrent(s), details hidden`;\n const bits=[];\n if(count) bits.push(`${esc(count)} torrent`);\n if(r.summary) bits.push(esc(r.summary));\n return bits.join('
') || '-';\n };\n box.innerHTML=responsiveTable(\n ['Status','Source','Action','Profile','Count','Details','Attempts','Started','Finished','Error','Actions'],\n rows.map(r=>[\n `${esc(r.status)}`,\n r.source==='automation'?` automation`:(r.is_forced?' forced':'user'),\n esc(r.action),\n esc(r.profile_id),\n esc(r.hash_count||0),\n details(r),\n esc(r.attempts||0),\n humanDateCell(r.started_at||r.created_at),\n humanDateCell(r.finished_at),\n compactCell(r.error||'',140),\n jobActions(r),\n ]),\n 'jobs-table'\n );\n renderJobsPager();\n }\n function renderJobsPager(){ const p=$('jobsPager'); if(!p)return; const pages=Math.max(1,Math.ceil(jobsTotal/jobsLimit)); p.innerHTML=`
Page ${jobsPage+1} / ${pages} · ${jobsTotal} jobs
`; $('jobsPrev')?.addEventListener('click',()=>loadJobs(jobsPage-1)); $('jobsNext')?.addEventListener('click',()=>loadJobs(jobsPage+1)); }\n // Note: Job log buttons are separated so normal cleanup cannot accidentally trigger emergency cleanup.\n $('jobsModal')?.addEventListener('show.bs.modal',loadJobs); $('refreshJobsBtn')?.addEventListener('click',loadJobs); $('jobsTable')?.addEventListener('click',async e=>{ const btn=e.target.closest('.job-retry,.job-cancel,.job-force'); if(!btn)return; const id=btn.dataset.id; if(!id)return; if(btn.classList.contains('job-retry')) await post(`/api/jobs/${id}/retry`,{}).catch(x=>toast(x.message,'danger')); if(btn.classList.contains('job-force')){ if(!confirm('Force this pending job now in a separate worker? This can break normal queue ordering.')) return; await post(`/api/jobs/${id}/force`,{}).catch(x=>toast(x.message,'danger')); } if(btn.classList.contains('job-cancel')){ const st=btn.dataset.status||''; if((st==='pending'||st==='running') && !confirm('Emergency cancel this unfinished job?')) return; await post(`/api/jobs/${id}/cancel`,{}).catch(x=>toast(x.message,'danger')); } loadJobs(); });\n $('clearJobsBtn')?.addEventListener('click',async()=>{ if(!confirm('Clear finished job logs? Pending and running jobs will stay.')) return; try{ const j=await post('/api/jobs/clear',{}); toast(`Cleared ${j.deleted||0} finished job log(s)`,'success'); jobsPage=0; loadJobs(0); }catch(e){ toast(e.message,'danger'); } });\n $('emergencyClearJobsBtn')?.addEventListener('click',async()=>{ if(!confirm('Emergency clean ALL job logs, including unfinished jobs?')) return; try{ const j=await post('/api/jobs/clear?force=1',{}); toast(`Emergency cleared ${j.deleted||0} job log(s)`,'success'); jobsPage=0; loadJobs(0); }catch(e){ toast(e.message,'danger'); } });\n\n async function loadLabels(){ const j=await (await fetch('/api/labels')).json(); const labels=j.labels||[]; knownLabels=labels; renderLabelFilters(); renderLabelChooser(); if($('labelsManager')) $('labelsManager').innerHTML=labels.length?labels.map(l=>`
${esc(l.name)}
`).join(''):'
No labels.Add first label above.
'; }\n function renderLabelChooser(){ if($('selectedLabelList')) $('selectedLabelList').innerHTML=[...modalLabels].map(l=>``).join('') || 'No labels selected.'; if($('labelList')) $('labelList').innerHTML=knownLabels.map(l=>``).join('') || 'No saved labels.'; }\n async function saveKnownLabel(name){ name=String(name||'').trim(); if(!name) return; await post('/api/labels',{name}); await loadLabels(); }\n async function loadRatios(){ const j=await (await fetch('/api/ratio-groups')).json(); const groups=j.groups||[], history=j.history||[]; if($('ratioAssignSelect')) $('ratioAssignSelect').innerHTML=groups.map(g=>``).join(''); if($('ratioManager')) $('ratioManager').innerHTML=`
Groups
${table(['Name','Min','Max','Seed min','Action','Move path','Set label','Enabled'],groups.map(g=>[esc(g.name),esc(g.min_ratio),esc(g.max_ratio),esc(g.seed_time_minutes||g.min_seed_time_minutes||0),esc(g.action),esc(g.move_path||''),esc(g.set_label||''),g.enabled?'yes':'no']))}
Applied history
${table(['Time','Torrent','Group','Action','Status','Reason'],history.map(h=>[humanDateCell(h.created_at),esc(h.torrent_name||h.torrent_hash),esc(h.group_name||''),esc(h.action),esc(h.status),esc(h.reason||'')]))}`; }\n $('labelModal')?.addEventListener('show.bs.modal',async()=>{ modalLabels=new Set(selectedHashes().flatMap(h=>labelNames(torrents.get(h)?.label))); if($('labelInput')) $('labelInput').value=''; await loadLabels(); renderLabelChooser(); });\n $('saveLabelBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } await runAction('set_label',{label:labelValue([...modalLabels])}); bootstrap.Modal.getInstance($('labelModal'))?.hide(); });\n $('addLabelToSelectionBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } if($('labelInput')) $('labelInput').value=''; renderLabelChooser(); });\n $('clearLabelsBtn')?.addEventListener('click',()=>{ modalLabels.clear(); renderLabelChooser(); });\n $('labelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-chip'); if(!chip) return; const v=chip.dataset.label||''; modalLabels.has(v)?modalLabels.delete(v):modalLabels.add(v); renderLabelChooser(); });\n $('selectedLabelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-selected'); if(!chip) return; modalLabels.delete(chip.dataset.label||''); renderLabelChooser(); });\n $('newLabelBtn')?.addEventListener('click',async()=>{ await saveKnownLabel($('newLabelName')?.value||''); if($('newLabelName')) $('newLabelName').value=''; });\n $('ratioAssignModal')?.addEventListener('show.bs.modal',loadRatios); $('applyRatioBtn')?.addEventListener('click',async()=>{ await runAction('set_ratio_group',{ratio_group:$('ratioAssignSelect').value}); bootstrap.Modal.getInstance($('ratioAssignModal'))?.hide(); }); $('ratioSaveBtn')?.addEventListener('click',async()=>{ await post('/api/ratio-groups',{name:$('ratioName').value,min_ratio:$('ratioMin').value,max_ratio:$('ratioMax').value,seed_time_minutes:$('ratioSeed').value,action:$('ratioAction').value,move_path:$('ratioMovePath')?.value||'',set_label:$('ratioSetLabel')?.value||'',ignore_private:$('ratioIgnorePrivate')?.checked!==false,ignore_active_upload:$('ratioIgnoreUpload')?.checked!==false}); loadRatios(); }); $('ratioCheckBtn')?.addEventListener('click',async()=>{ const j=await post('/api/ratio-groups/check',{}); toast(`Ratio applied ${j.result?.applied||0} torrent(s)`,'success'); loadRatios(); });\n"; diff --git a/pytorrent/static/js/planner.js b/pytorrent/static/js/planner.js new file mode 100644 index 0000000..f9c2d5b --- /dev/null +++ b/pytorrent/static/js/planner.js @@ -0,0 +1 @@ +export const plannerSource = " function ensurePlannerToolsUI(){\n addToolTab('planner','fa-calendar-days','Planner','appstatus');\n addToolTab('poller','fa-satellite-dish','Poller','appstatus');\n const host=$('toolRss')?.parentElement || document.querySelector('#toolsModal .modal-body');\n if(!host) return;\n if(!$('toolPlanner')){\n const panel=document.createElement('div');\n panel.id='toolPlanner'; panel.className='d-none';\n panel.innerHTML=`
\n
    \n
  • \n
  • \n
\n
\n
\n
\n
\n
\n
Download planner off
One place for hourly speed limits, quiet hours and safety rules for the active rTorrent profile.
\n
${inlineSwitch('plannerEnabled')}
\n
\n
\n
\n
Basics
\n
\n \n \n \n \n
\n
\n
\n
Hourly speed planner
\n ${plannerToggleRow('plannerHourlyEnabled','Use hourly speed limits','When enabled, the current hour overrides weekday and weekend speed limits.')}\n
\n
\n
\n
\n
Fallback speed limits
\n
${plannerSpeedCard('plannerWeekday','Weekday limits','Used when hourly planner is disabled')}${plannerSpeedCard('plannerWeekend','Weekend limits','Saturday and Sunday fallback')}
\n
\n
\n
Time windows
\n
\n ${plannerToggleRow('plannerNightOnly','Download only at night','Pause downloads outside the selected window.')}\n ${plannerToggleRow('plannerQuietEnabled','Quiet hours','Pause active downloads during the selected quiet window.')}\n
\n
\n \n \n \n \n
\n
\n
\n
Protection
\n
\n ${plannerToggleRow('plannerCpuEnabled','CPU protection','Pause downloads when CPU usage stays above the threshold for about 10 seconds.')}\n ${plannerToggleRow('plannerDiskEnabled','Disk protection','Pause downloads and block new download starts when disk usage is high.')}\n ${plannerToggleRow('plannerNetworkEnabled','Network protection','Clamp Planner speed limits to configured network caps.')}\n ${plannerToggleRow('plannerLoadEnabled','Load protection','Pause downloads when system load is above threshold.')}\n ${plannerToggleRow('plannerAutoResume','Auto resume planner-paused torrents','Resume only torrents paused by the planner when all protection rules become clear.')}\n
\n
\n \n \n \n \n \n
\n
\n
Preview
No preview loaded.
\n
\n
\n
\n
\n
\n
\n
Action history
No actions yet.
\n
\n
\n
`\n host.appendChild(panel);\n renderPlannerHourlyGrid();\n $('plannerSaveBtn')?.addEventListener('click',saveDownloadPlanner);\n $('plannerCheckBtn')?.addEventListener('click',()=>applyDownloadPlannerNow(false));\n $('plannerDryRunBtn')?.addEventListener('click',()=>applyDownloadPlannerNow(true));\n $('plannerOverrideBtn')?.addEventListener('click',setPlannerOverride);\n $('plannerPreviewBtn')?.addEventListener('click',loadPlannerPreview);\n $('plannerHistory')?.addEventListener('click',async e=>{\n const toggle=e.target.closest('#plannerHistoryToggle');\n const clear=e.target.closest('#plannerHistoryClear');\n if(toggle){ plannerHistoryExpanded=!plannerHistoryExpanded; await loadPlannerPreview(); return; }\n if(clear && confirm('Clear Planner action history?')){\n try{ await post('/api/download-planner/history',{},'DELETE'); plannerHistoryExpanded=false; await loadPlannerPreview(); toast('Planner history cleared','success'); }\n catch(err){ toast(err.message,'danger'); }\n }\n });\n $('plannerProfileName')?.addEventListener('change',applyPlannerPreset);\n $('plannerHourCopyWeekday')?.addEventListener('click',()=>copyPlannerSpeedToHours('plannerWeekday'));\n document.querySelectorAll('.planner-hour-fill').forEach(btn=>btn.addEventListener('click',()=>fillPlannerHours(Number(btn.dataset.mbps||0))));\n setupPlannerSpeedControls();\n }\n if(!$('toolPoller')){\n const panel=document.createElement('div');\n panel.id='toolPoller'; panel.className='d-none';\n panel.innerHTML=`
\n
\n
Adaptive WebSocket poller normal
Controls live refresh cadence per active rTorrent profile.
\n
${inlineSwitch('pollerAdaptive')}
\n
\n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n
\n ${plannerToggleRow('pollerSafeFallback','Safe fallback mode','Clamp unsafe poller settings to known-safe intervals.')}\n
DiagnosticsNot loaded.
\n
\n
\n
`;\n host.appendChild(panel);\n $('pollerSaveBtn')?.addEventListener('click',savePollerSettings);\n $('pollerReloadBtn')?.addEventListener('click',loadPollerSettings);\n }\n }\n const plannerMbpsToBytes=mbps=>mbps?Math.round(Number(mbps)*1000000/8):0;\n const plannerBytesToMbps=bytes=>bytes?Math.round(Number(bytes)*8/1000000):0;\n function plannerLimitText(bytes){ const mbps=plannerBytesToMbps(Number(bytes||0)); return mbps?`${mbps} Mbit/s`:'Unlimited'; }\n function plannerHourLabel(hour){ return `${String(hour).padStart(2,'0')}:00-${String((hour+1)%24).padStart(2,'0')}:00`; }\n function renderPlannerHourlyGrid(){\n const box=$('plannerHourlyGrid'); if(!box) return;\n box.innerHTML=Array.from({length:24},(_,hour)=>`
${plannerHourLabel(hour)}Unlimited
`).join('');\n document.querySelectorAll('.planner-hour-input').forEach(input=>input.addEventListener('input',()=>updatePlannerHourSummary(Number(input.closest('.planner-hour-row')?.dataset.hour||0))));\n }\n function updatePlannerHourSummary(hour){ const down=Number($(`plannerHour${hour}Down`)?.value||0), up=Number($(`plannerHour${hour}Up`)?.value||0); const out=$(`plannerHour${hour}Summary`); if(out) out.textContent=`DL ${plannerLimitText(down)} / UL ${plannerLimitText(up)}`; }\n function fillPlannerHours(mbps){ const bytes=plannerMbpsToBytes(mbps); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=bytes; if(u)u.value=bytes; updatePlannerHourSummary(hour); } }\n function copyPlannerSpeedToHours(prefix){ const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=down; if(u)u.value=up; updatePlannerHourSummary(hour); } }\n function plannerHourlyPayload(){ return Array.from({length:24},(_,hour)=>({hour,down:Number($(`plannerHour${hour}Down`)?.value||0),up:Number($(`plannerHour${hour}Up`)?.value||0)})); }\n function setPlannerSpeed(prefix,mbps){\n const bytes=plannerMbpsToBytes(mbps);\n ['Down','Up'].forEach(dir=>{ const input=$(`${prefix}${dir}`); if(input) input.value=bytes; });\n updatePlannerSpeedControls(prefix);\n }\n function updatePlannerSpeedControls(prefix){\n const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0);\n [['Down',down],['Up',up]].forEach(([dir,value])=>{ const slider=$(`${prefix}${dir}Slider`), out=$(`${prefix}${dir}Mbps`); const mbps=plannerBytesToMbps(value); if(slider){ if(mbps>Number(slider.max||0)) slider.max=String(mbps); slider.value=String(mbps); } if(out) out.textContent=plannerLimitText(value); });\n const sum=$(`${prefix}Summary`); if(sum) sum.textContent=`DL ${plannerLimitText(down)} / UL ${plannerLimitText(up)}`;\n }\n function setupPlannerSpeedControls(){\n document.querySelectorAll('.planner-speed-preset').forEach(btn=>btn.addEventListener('click',()=>setPlannerSpeed(btn.dataset.prefix,Number(btn.dataset.mbps||0))));\n document.querySelectorAll('.planner-mbps-slider').forEach(slider=>slider.addEventListener('input',()=>{ const target=$(slider.dataset.target); if(target) target.value=plannerMbpsToBytes(Number(slider.value||0)); const prefix=(slider.dataset.target||'').replace(/(Down|Up)$/,''); updatePlannerSpeedControls(prefix); }));\n document.querySelectorAll('.planner-byte-input').forEach(input=>input.addEventListener('input',()=>updatePlannerSpeedControls(input.id.replace(/(Down|Up)$/,''))));\n }\n function plannerPayload(){ return {enabled:$('plannerEnabled')?.checked,profile_name:$('plannerProfileName')?.value||'night mode',dry_run:$('plannerDryRun')?.checked,night_only_enabled:$('plannerNightOnly')?.checked,night_start:$('plannerNightStart')?.value||'23:00',night_end:$('plannerNightEnd')?.value||'07:00',quiet_hours_enabled:$('plannerQuietEnabled')?.checked,quiet_start:$('plannerQuietStart')?.value||'22:00',quiet_end:$('plannerQuietEnd')?.value||'06:00',weekday_down:Number($('plannerWeekdayDown')?.value||0),weekday_up:Number($('plannerWeekdayUp')?.value||0),weekend_down:Number($('plannerWeekendDown')?.value||0),weekend_up:Number($('plannerWeekendUp')?.value||0),hourly_schedule_enabled:$('plannerHourlyEnabled')?.checked,hourly_schedule:plannerHourlyPayload(),auto_pause_cpu_enabled:$('plannerCpuEnabled')?.checked,auto_pause_cpu_percent:Number($('plannerCpuPercent')?.value||90),auto_pause_disk_enabled:$('plannerDiskEnabled')?.checked,auto_pause_disk_percent:Number($('plannerDiskPercent')?.value||95),network_protection_enabled:$('plannerNetworkEnabled')?.checked,network_max_down:Number($('plannerNetworkDown')?.value||0),network_max_up:Number($('plannerNetworkUp')?.value||0),load_protection_enabled:$('plannerLoadEnabled')?.checked,load_cpu_percent:Number($('plannerLoadCpu')?.value||95),auto_resume:$('plannerAutoResume')?.checked,auto_resume_grace_seconds:Number($('plannerResumeGrace')?.value||0)}; }\n function updatePlannerFooter(enabled,preview={}){ const btn=$('statusPlannerOpen'); if(btn){ btn.classList.toggle('d-none',!enabled); btn.classList.toggle('text-warning',!!preview.manual_override_until); btn.title=enabled?`Planner ${preview.matched_rule||'enabled'}${preview.dry_run?' \u00b7 dry-run':''}`:'Download planner is disabled.'; const span=btn.querySelector('span'); if(span) span.textContent=preview.dry_run?'Planner dry-run':preview.manual_override_until?'Planner paused':'Planner'; } const badge=$('plannerStatusBadge'); if(badge){ badge.className=`badge ${enabled?'text-bg-success':'text-bg-secondary'}`; badge.textContent=enabled?(preview.dry_run?'dry-run':preview.manual_override_until?'override':'enabled'):'off'; } }\n function plannerDateText(value){ if(!value) return '-'; if(typeof value==='number') return formatDateTime(value); const d=new Date(value); return isNaN(d.getTime())?'-':d.toLocaleString(); }\n function renderPlannerPreview(preview={}){ const box=$('plannerPreview'); if(!box)return; const down=plannerLimitText(preview.down||0), up=plannerLimitText(preview.up||0); box.innerHTML=`Matched ${esc(preview.matched_rule||'-')} \u00b7 next change ${esc(plannerDateText(preview.next_change_at))} \u00b7 DL ${esc(down)} / UL ${esc(up)}${preview.pause_downloads?' \u00b7 pauses downloads':''}${preview.manual_override_until?' \u00b7 override active':''}`; updatePlannerFooter(!!$('plannerEnabled')?.checked,preview); const ov=$('plannerOverrideStatus'); if(ov) ov.textContent=preview.manual_override_until?`Active until ${plannerDateText(preview.manual_override_until)}`:'No active override.'; }\n function plannerHistoryDetails(row={}){ return row && typeof row==='object' ? row : {}; }\n function plannerHistoryLimitText(value){ return plannerLimitText(Number(value||0)); }\n function renderPlannerHistory(items=[], total=items.length){\n const box=$('plannerHistory'); if(!box)return;\n const body=items.length\n ? responsiveTable(['Time','Event','Rule','DL','UL','Paused','Resumed','Dry run','Reason'],items.map(x=>{\n // Note: Planner history uses the same table pattern as Smart Queue, with compact decision columns first.\n const d=plannerHistoryDetails(x);\n const event=d.event||'-';\n const rule=d.rule||d.matched_rule||d.profile_name||'-';\n const down=d.down!==undefined?plannerHistoryLimitText(d.down):'-';\n const up=d.up!==undefined?plannerHistoryLimitText(d.up):'-';\n const paused=d.paused ?? d.count ?? 0;\n const resumed=d.resumed ?? 0;\n const dry=d.dry_run?'yes':'-';\n const reason=d.pause_reason||d.reason||d.manual_override_reason||'-';\n return [dateCell(d.at),esc(event),esc(rule),esc(down),esc(up),esc(paused),esc(resumed),esc(dry),esc(reason)];\n }),'planner-history-table')\n : '
No Planner actions yet.
';\n const canToggle=Number(total||0)>10;\n const toggle=canToggle?``:'';\n const clear=Number(total||0)?``:'';\n box.innerHTML=`${body}${toggle}${clear}`;\n }\n function fillPlanner(st){ if(!st)return; $('plannerEnabled')&&($('plannerEnabled').checked=!!st.enabled); $('plannerProfileName')&&($('plannerProfileName').value=st.profile_name||'night mode'); $('plannerDryRun')&&($('plannerDryRun').checked=!!st.dry_run); updatePlannerFooter(!!st.enabled,st); $('plannerHourlyEnabled')&&($('plannerHourlyEnabled').checked=!!st.hourly_schedule_enabled); const hourly=Array.isArray(st.hourly_schedule)?st.hourly_schedule:[]; for(let hour=0;hour<24;hour++){ const item=hourly.find(x=>Number(x.hour)===hour)||{}; const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=Number(item.down||0); if(u)u.value=Number(item.up||0); updatePlannerHourSummary(hour); } $('plannerNightOnly')&&($('plannerNightOnly').checked=!!st.night_only_enabled); $('plannerNightStart')&&($('plannerNightStart').value=st.night_start||'23:00'); $('plannerNightEnd')&&($('plannerNightEnd').value=st.night_end||'07:00'); $('plannerQuietEnabled')&&($('plannerQuietEnabled').checked=!!st.quiet_hours_enabled); $('plannerQuietStart')&&($('plannerQuietStart').value=st.quiet_start||'22:00'); $('plannerQuietEnd')&&($('plannerQuietEnd').value=st.quiet_end||'06:00'); $('plannerWeekdayDown')&&($('plannerWeekdayDown').value=st.weekday_down||0); $('plannerWeekdayUp')&&($('plannerWeekdayUp').value=st.weekday_up||0); $('plannerWeekendDown')&&($('plannerWeekendDown').value=st.weekend_down||0); $('plannerWeekendUp')&&($('plannerWeekendUp').value=st.weekend_up||0); updatePlannerSpeedControls('plannerWeekday'); updatePlannerSpeedControls('plannerWeekend'); $('plannerCpuEnabled')&&($('plannerCpuEnabled').checked=!!st.auto_pause_cpu_enabled); $('plannerCpuPercent')&&($('plannerCpuPercent').value=st.auto_pause_cpu_percent||90); $('plannerDiskEnabled')&&($('plannerDiskEnabled').checked=!!st.auto_pause_disk_enabled); $('plannerDiskPercent')&&($('plannerDiskPercent').value=st.auto_pause_disk_percent||95); $('plannerNetworkEnabled')&&($('plannerNetworkEnabled').checked=!!st.network_protection_enabled); $('plannerNetworkDown')&&($('plannerNetworkDown').value=st.network_max_down||0); $('plannerNetworkUp')&&($('plannerNetworkUp').value=st.network_max_up||0); $('plannerLoadEnabled')&&($('plannerLoadEnabled').checked=!!st.load_protection_enabled); $('plannerLoadCpu')&&($('plannerLoadCpu').value=st.load_cpu_percent||95); $('plannerAutoResume')&&($('plannerAutoResume').checked=st.auto_resume!==false); $('plannerResumeGrace')&&($('plannerResumeGrace').value=st.auto_resume_grace_seconds||0); if(st.manual_override_until) renderPlannerPreview(st); }\n function applyPlannerPreset(){ const name=$('plannerProfileName')?.value||''; if(name==='night mode'){ $('plannerNightOnly').checked=true; $('plannerQuietEnabled').checked=false; setPlannerSpeed('plannerWeekday',100); setPlannerSpeed('plannerWeekend',250); } if(name==='weekend mode'){ $('plannerNightOnly').checked=false; setPlannerSpeed('plannerWeekday',50); setPlannerSpeed('plannerWeekend',0); } if(name==='low power mode'){ $('plannerLoadEnabled').checked=true; $('plannerCpuEnabled').checked=true; $('plannerCpuPercent').value=70; setPlannerSpeed('plannerWeekday',50); setPlannerSpeed('plannerWeekend',50); } if(name==='unlimited mode'){ $('plannerNightOnly').checked=false; $('plannerQuietEnabled').checked=false; setPlannerSpeed('plannerWeekday',0); setPlannerSpeed('plannerWeekend',0); } }\n async function loadPlannerPreview(){ try{const limit=plannerHistoryExpanded?100:10; const j=await fetch(`/api/download-planner/preview?history_limit=${limit}`).then(r=>r.json()); renderPlannerPreview(j.preview||{}); renderPlannerHistory(j.history||[], Number(j.history_total ?? (j.history||[]).length));}catch(e){} }\n async function loadDownloadPlanner(){ ensurePlannerToolsUI(); try{const j=await fetch('/api/download-planner').then(r=>r.json()); fillPlanner(j.settings||{}); await loadPlannerPreview();}catch(e){} }\n async function saveDownloadPlanner(){ try{const j=await post('/api/download-planner',plannerPayload()); fillPlanner(j.settings||plannerPayload()); await loadPlannerPreview(); toast('Download planner saved','success');}catch(e){toast(e.message,'danger');} }\n async function applyDownloadPlannerNow(dryRun=false){ try{const j=await post('/api/download-planner/check',{dry_run:!!dryRun}); const r=j.result||{}; if(r.settings) fillPlanner(r.settings); renderPlannerPreview(r.preview||r); if(r.history) renderPlannerHistory(r.history, r.history_total ?? r.history.length); else await loadPlannerPreview(); toast(`${dryRun?'Planner dry-run':'Planner applied'}: paused ${r.paused||0}, resumed ${r.resumed||0}, limits ${r.limits_changed?'changed':'unchanged'}`,'success');}catch(e){toast(e.message,'danger');} }\n async function setPlannerOverride(){ try{const seconds=Number($('plannerOverrideSeconds')?.value||0); await post('/api/download-planner/override',{seconds}); toast(seconds?'Planner override set':'Planner override cleared','success'); await loadDownloadPlanner();}catch(e){toast(e.message,'danger');} }\n"; diff --git a/pytorrent/static/js/poller.js b/pytorrent/static/js/poller.js new file mode 100644 index 0000000..89f73df --- /dev/null +++ b/pytorrent/static/js/poller.js @@ -0,0 +1 @@ +export const pollerSource = " function pollerPayload(){return {adaptive_enabled:$('pollerAdaptive')?.checked,safe_fallback_enabled:$('pollerSafeFallback')?.checked,active_interval_seconds:Number($('pollerActive')?.value||0.5),idle_interval_seconds:Number($('pollerIdle')?.value||3),error_interval_seconds:Number($('pollerError')?.value||2),torrent_list_interval_seconds:Number($('pollerTorrentList')?.value||0.5),system_stats_interval_seconds:Number($('pollerSystem')?.value||1),tracker_stats_interval_seconds:Number($('pollerTracker')?.value||30),disk_stats_interval_seconds:Number($('pollerDisk')?.value||30),queue_stats_interval_seconds:Number($('pollerQueue')?.value||5),slow_stats_interval_seconds:Number($('pollerQueue')?.value||5),heartbeat_interval_seconds:Number($('pollerHeartbeat')?.value||5),slow_response_threshold_ms:Number($('pollerSlowThreshold')?.value||10000),slowdown_multiplier:Number($('pollerSlowdown')?.value||1),recovery_after_errors:Number($('pollerRecoveryErrors')?.value||3),emit_heartbeat_on_change:true};}\n function updatePollerBadge(rt={}){ const badge=$('pollerStatusBadge'); if(!badge)return; const adaptive=rt.adaptive_enabled!==false; const mode=adaptive?(rt.adaptive_mode||'normal'):'fixed'; badge.className=`badge ${mode==='recovery'?'text-bg-danger':mode==='slowdown'?'text-bg-warning':mode==='idle'||mode==='fixed'?'text-bg-secondary':'text-bg-success'}`; badge.textContent=mode==='fixed'?'fixed interval':mode; }\n function fillPoller(st,rt){ if(!st){ const merged={...(rt||{})}; if($('pollerAdaptive') && merged.adaptive_enabled===undefined) merged.adaptive_enabled=$('pollerAdaptive').checked; if(rt && $('pollerRuntime')) $('pollerRuntime').innerHTML=pollerDiagnostics(merged); updatePollerBadge(merged); return; } $('pollerAdaptive')&&($('pollerAdaptive').checked=!!st.adaptive_enabled); $('pollerSafeFallback')&&($('pollerSafeFallback').checked=st.safe_fallback_enabled!==false); $('pollerActive')&&($('pollerActive').value=st.active_interval_seconds??0.5); $('pollerIdle')&&($('pollerIdle').value=st.idle_interval_seconds??3); $('pollerError')&&($('pollerError').value=st.error_interval_seconds??2); $('pollerTorrentList')&&($('pollerTorrentList').value=st.torrent_list_interval_seconds??0.5); $('pollerSystem')&&($('pollerSystem').value=st.system_stats_interval_seconds??1); $('pollerTracker')&&($('pollerTracker').value=st.tracker_stats_interval_seconds??30); $('pollerDisk')&&($('pollerDisk').value=st.disk_stats_interval_seconds||30); $('pollerQueue')&&($('pollerQueue').value=st.queue_stats_interval_seconds??5); $('pollerHeartbeat')&&($('pollerHeartbeat').value=st.heartbeat_interval_seconds??5); $('pollerSlowThreshold')&&($('pollerSlowThreshold').value=st.slow_response_threshold_ms??10000); $('pollerSlowdown')&&($('pollerSlowdown').value=st.slowdown_multiplier??1); $('pollerRecoveryErrors')&&($('pollerRecoveryErrors').value=st.recovery_after_errors||3); if($('pollerRuntime')) $('pollerRuntime').innerHTML=rt?pollerDiagnostics({...rt,adaptive_enabled:st.adaptive_enabled}):''; updatePollerBadge(rt?{...rt,adaptive_enabled:st.adaptive_enabled}:{adaptive_enabled:st.adaptive_enabled}); }\n function pollerDiagnostics(rt={}){ const adaptive=rt.adaptive_enabled!==false; const mode=adaptive?(rt.adaptive_mode||'normal'):'fixed interval'; return `duration ${esc(rt.duration_ms||rt.last_tick_ms||0)} ms \u00b7 gap ${esc(rt.last_tick_gap_ms||0)} ms \u00b7 effective ${esc(rt.effective_interval_seconds||0)}s \u00b7 min ${esc(rt.configured_min_interval_seconds||0)}s \u00b7 payload ${esc(fmtBytes(rt.emitted_payload_size||0))} \u00b7 rTorrent calls ${esc(rt.rtorrent_call_count||0)} \u00b7 skipped ${esc(rt.skipped_emissions||0)} \u00b7 mode ${esc(mode)} \u00b7 adaptive ${adaptive?'on':'off'} \u00b7 ok ${rt.last_ok?'yes':'no'} \u00b7 ticks ${esc(rt.tick_count||0)}`; }\n async function loadPollerSettings(){ ensurePlannerToolsUI(); try{const j=await fetch('/api/poller/settings').then(r=>r.json()); fillPoller(j.settings||{},j.runtime||{});}catch(e){} }\n async function savePollerSettings(){ try{const j=await post('/api/poller/settings',pollerPayload()); fillPoller(j.settings||pollerPayload(),null); toast('Poller settings saved','success');}catch(e){toast(e.message,'danger');} }\n ensurePlannerToolsUI(); ensureDashboardToolsUI(); loadDownloadPlanner(); $('toolsModal')?.addEventListener('show.bs.modal',()=>{ensurePlannerToolsUI();ensureDashboardToolsUI();refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadBackup();loadAppStatus();renderHealthDashboard();renderSmartViewsManager();renderNotificationCenter();loadPreferences();loadJobSettings();if(document.querySelector('.tool-tab[data-tool=\"users\"]')?.classList.contains('active')) loadAuthUsers();loadDownloadPlanner();loadPollerSettings();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',torrentstats:'toolTorrentStats',preferences:'toolPreferences',jobs:'toolJobs',users:'toolUsers',labels:'toolLabels',ratio:'toolRatio',rss:'toolRss',columns:'toolColumns',smart:'toolSmart',automations:'toolAutomations',rtconfig:'toolRtconfig',cleanup:'toolCleanup',backup:'toolBackup',appstatus:'toolAppstatus',planner:'toolPlanner',poller:'toolPoller',smartviews:'toolSmartviews',notifications:'toolNotifications'}; const hideToolPanels=()=>Object.values(toolPanelIds).filter((v,i,a)=>a.indexOf(v)===i).forEach(id=>$(id)?.classList.add('d-none')); const showToolPanel=tool=>{hideToolPanels(); $(toolPanelIds[tool]||'toolRtorrents')?.classList.remove('d-none');}; const activateToolTab=tool=>{document.querySelectorAll('.tool-tab').forEach(x=>x.classList.toggle('active',(x.dataset.tool||'rtorrents')===tool)); showToolPanel(tool); if(tool==='torrentstats') loadTorrentStats(false); if(tool==='appstatus') loadAppStatus(); if(tool==='cleanup') loadCleanup(); if(tool==='backup') loadBackup(); if(tool==='preferences') loadPreferences(); if(tool==='jobs') loadJobSettings(); if(tool==='users') loadAuthUsers(); if(tool==='planner') loadDownloadPlanner(); if(tool==='poller') loadPollerSettings(); if(tool==='smartviews') renderSmartViewsManager(); if(tool==='notifications') renderNotificationCenter(); if(tool==='diagnostics') loadAppStatus(); }; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); function switchAppStatusPane(pane){ document.querySelectorAll('#appStatusTabs [data-appstatus-pane], #appStatusManager [data-appstatus-pane]').forEach(x=>x.classList.toggle('active',x.dataset.appstatusPane===pane)); $('appStatusManager')?.querySelectorAll('[data-appstatus-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.appstatusPanel!==pane)); } $('appStatusTabs')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-appstatus-pane]'); if(tab) switchAppStatusPane(tab.dataset.appstatusPane); }); $('appStatusManager')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-appstatus-pane]'); if(tab) switchAppStatusPane(tab.dataset.appstatusPane); }); $('healthDashboardManager')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-health-pane]'); if(tab && typeof setHealthPane==='function') setHealthPane(tab.dataset.healthPane); }); $('torrentStatsManager')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-torrentstats-pane]'); if(tab && typeof setTorrentStatsPane==='function') setTorrentStatsPane(tab.dataset.torrentstatsPane); }); $('torrentStatsRefreshBtn')?.addEventListener('click',()=>loadTorrentStats(true)); $('authUserSaveBtn')?.addEventListener('click',saveAuthUser); $('authUserCancelBtn')?.addEventListener('click',resetAuthUserForm); $('authUsersManager')?.addEventListener('click',async e=>{ const edit=e.target.closest('.auth-edit'); const token=e.target.closest('.auth-token:not(.auth-token-list)'); const tokenList=e.target.closest('.auth-token-list'); const del=e.target.closest('.auth-delete'); if(edit){ editAuthUser(JSON.parse(edit.dataset.user||'{}')); return; } if(token){ await generateAuthToken(token.dataset.id); return; } if(tokenList){ await showAuthTokens(tokenList.dataset.id); return; } if(del && confirm('Delete user?')){ try{ const j=await post(`/api/auth/users/${del.dataset.id}`,{},'DELETE'); if(!j.ok) throw new Error(j.error||'Delete failed'); toast('User deleted','success'); await loadAuthUsers(); }catch(e){ toast(e.message,'danger'); } } }); $('rssFeedBtn')?.addEventListener('click',async()=>{await post('/api/rss/feeds',{id:$('rssFeedId')?.value||null,name:$('rssName').value,url:$('rssUrl').value,interval_minutes:$('rssInterval')?.value||30,enabled:true}); if($('rssFeedId')) $('rssFeedId').value=''; loadRss();}); $('rssRuleBtn')?.addEventListener('click',async()=>{await post('/api/rss/rules',{id:$('rssRuleId')?.value||null,name:$('rssRuleName').value,pattern:$('rssPattern').value,exclude_pattern:$('rssExclude')?.value||'',min_size_mb:$('rssMinSize')?.value||0,max_size_mb:$('rssMaxSize')?.value||0,category:$('rssCategory')?.value||'',quality:$('rssQuality')?.value||'',season:$('rssSeason')?.value||null,episode:$('rssEpisode')?.value||null,save_path:$('rssPath').value,label:$('rssLabel').value}); if($('rssRuleId')) $('rssRuleId').value=''; loadRss();}); $('rssTestBtn')?.addEventListener('click',async()=>{try{const j=await post('/api/rss/rules/test',{feed_url:$('rssUrl').value,rule:{pattern:$('rssPattern').value,exclude_pattern:$('rssExclude')?.value||'',min_size_mb:$('rssMinSize')?.value||0,max_size_mb:$('rssMaxSize')?.value||0,category:$('rssCategory')?.value||'',quality:$('rssQuality')?.value||'',season:$('rssSeason')?.value||null,episode:$('rssEpisode')?.value||null}}); $('rssTestResult').innerHTML=table(['Title','Reason'],(j.result?.matches||[]).map(x=>[esc(x.title),esc(x.reason)]));}catch(e){toast(e.message,'danger');}}); $('rssCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/rss/check',{}); toast(`RSS queued ${j.queued} item(s)`,'success'); loadRss();}catch(e){toast(e.message,'danger');} finally{setBusy(false);}}); $('rssManager')?.addEventListener('click',async e=>{const ef=e.target.closest('.rss-edit-feed'); const er=e.target.closest('.rss-edit-rule'); const df=e.target.closest('.rss-delete-feed'); const dr=e.target.closest('.rss-delete-rule'); if(ef){const f=JSON.parse(ef.dataset.feed||'{}'); $('rssFeedId').value=f.id||''; $('rssName').value=f.name||''; $('rssUrl').value=f.url||''; $('rssInterval').value=f.interval_minutes||30;} if(er){const r=JSON.parse(er.dataset.rule||'{}'); $('rssRuleId').value=r.id||''; $('rssRuleName').value=r.name||''; $('rssPattern').value=r.pattern||''; $('rssExclude').value=r.exclude_pattern||''; $('rssMinSize').value=r.min_size_mb||''; $('rssMaxSize').value=r.max_size_mb||''; $('rssCategory').value=r.category||''; $('rssQuality').value=r.quality||''; $('rssSeason').value=r.season||''; $('rssEpisode').value=r.episode||''; $('rssPath').value=r.save_path||''; $('rssLabel').value=r.label||'';} if(df&&confirm('Delete RSS feed?')){await fetch(`/api/rss/feeds/${df.dataset.id}`,{method:'DELETE'}); loadRss();} if(dr&&confirm('Delete RSS rule?')){await fetch(`/api/rss/rules/${dr.dataset.id}`,{method:'DELETE'}); loadRss();}}); $('smartRefillMode')?.addEventListener('change',updateSmartRefillControls); $('smartSaveBtn')?.addEventListener('click',saveSmartQueue); $('smartCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/smart-queue/check',{}); if(j.queued){toast('Smart Queue check queued. It will continue in the background.','success'); await loadJobs().catch(()=>{}); await loadSmartQueue(); return;} const r=j.result||{}; if(j.torrent_patch) patchRows(j.torrent_patch); toast(smartQueueToastMessage(r),'success'); await loadSmartQueue();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('smartManager')?.addEventListener('click',async e=>{const h=e.target.closest('.smart-unexclude')?.dataset.hash; if(!h)return; await post('/api/smart-queue/exclusion',{hash:h,excluded:false}); await loadSmartQueue();}); $('backupCreateBtn')?.addEventListener('click',async()=>{await post('/api/backup',{name:$('backupName')?.value||'Manual backup'}); toast('Backup created','success'); loadBackup();}); $('backupSettingsSaveBtn')?.addEventListener('click',async()=>{await post('/api/backup/settings',{enabled:$('backupAutoEnabled')?.checked,interval_hours:Number($('backupAutoInterval')?.value||24),retention_days:Number($('backupRetentionDays')?.value||30)}); toast('Backup schedule saved','success'); loadBackup();}); $('backupManager')?.addEventListener('click',async e=>{const preview=e.target.closest('.backup-preview-btn'); const restore=e.target.closest('.backup-restore'); const del=e.target.closest('.backup-delete'); if(preview){ const j=await (await fetch(`/api/backup/${preview.dataset.id}/preview`)).json(); if(!j.ok) throw new Error(j.error||'Backup preview failed'); const box=$('backupPreview'); if(box){ box.classList.remove('d-none'); box.innerHTML=backupPreviewTable(j.preview||{}); box.scrollIntoView({block:'nearest'}); } return; } if(restore){ if(!confirm('Restore this backup and replace current app settings?')) return; await post(`/api/backup/${restore.dataset.id}/restore`,{}); toast('Backup restored','success'); loadBackup(); return; } if(del){ if(!confirm('Delete this backup permanently?')) return; await post(`/api/backup/${del.dataset.id}`,{},'DELETE'); toast('Backup deleted','success'); loadBackup(); }}); $('cleanupManager')?.addEventListener('click',async e=>{ if(e.target.closest('#cleanupRefreshBtn')) return loadCleanup(); if(e.target.closest('#cleanupProfileCacheBtn')) return runCleanupAction('/api/cleanup/cache','Clear active profile cache'); if(e.target.closest('#cleanupJobsBtn')) return runCleanupAction('/api/cleanup/jobs','Clear finished job logs'); if(e.target.closest('#cleanupSmartQueueBtn')) return runCleanupAction('/api/cleanup/smart-queue','Clear Smart Queue logs'); if(e.target.closest('#cleanupPlannerBtn')) return runCleanupAction('/api/cleanup/planner','Clear Planner logs'); if(e.target.closest('#cleanupAutomationsBtn')) return runCleanupAction('/api/cleanup/automations','Clear automation logs'); if(e.target.closest('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear job, Smart Queue, Planner and automation logs'); }); $('rtConfigReloadBtn')?.addEventListener('click',loadRtConfig); $('rtConfigResetBtn')?.addEventListener('click',resetRtConfig); $('rtConfigSaveBtn')?.addEventListener('click',saveRtConfig); $('rtConfigGenerateBtn')?.addEventListener('click',generateRtConfig); $('rtConfigManager')?.addEventListener('input',e=>{ if(e.target.classList.contains('rt-config-input')) updateRtConfigDirty(); }); $('rtConfigManager')?.addEventListener('change',e=>{ if(e.target.classList.contains('rt-config-input')){ const label=e.target.closest('.rt-config-switch')?.querySelector('.form-check-label'); if(label) label.textContent=e.target.checked?'On':'Off'; updateRtConfigDirty(); } }); $('rtConfigApplyOnStart')?.addEventListener('change',updateRtConfigDirty); $('statusPlannerOpen')?.addEventListener('click',()=>{ ensurePlannerToolsUI(); activateToolTab('planner'); new bootstrap.Modal($('toolsModal')).show(); }); $('peersRefreshSelect')?.addEventListener('change',async e=>{peersRefreshSeconds=Number(e.target.value||0); await post('/api/preferences',{peers_refresh_seconds:peersRefreshSeconds}).catch(()=>{}); setupPeersRefresh(activeTab()); toast('Peers refresh preference saved','success');});\n $('autoConditionType')?.addEventListener('change',updateAutomationForm); $('autoEffectType')?.addEventListener('change',updateAutomationForm); $('automationAddConditionBtn')?.addEventListener('click',()=>{automationConditions.push(automationCondition()); renderAutomationBuilder();}); $('automationAddEffectBtn')?.addEventListener('click',()=>{automationEffects.push(automationEffect()); renderAutomationBuilder();}); $('automationConditionList')?.addEventListener('click',e=>{const b=e.target.closest('.automation-remove-condition'); if(!b)return; automationConditions.splice(Number(b.dataset.index||0),1); renderAutomationBuilder();}); $('automationEffectList')?.addEventListener('click',e=>{const b=e.target.closest('.automation-remove-effect'); if(!b)return; automationEffects.splice(Number(b.dataset.index||0),1); renderAutomationBuilder();}); $('automationCancelEditBtn')?.addEventListener('click',resetAutomationForm); $('automationSaveBtn')?.addEventListener('click',saveAutomation); $('automationExportBtn')?.addEventListener('click',exportAutomations); $('automationImportBtn')?.addEventListener('click',()=>$('automationImportFile')?.click()); $('automationImportFile')?.addEventListener('change',e=>importAutomations(e.target.files?.[0])); $('automationCheckBtn')?.addEventListener('click',async()=>{setBusy(true);try{const j=await post('/api/automations/check',{}); const torrents=j.result?.applied?.length||0; const batches=j.result?.batches?.length||0; toast(`Automations applied ${torrents} torrent(s) in ${batches} batch(es)`,'success'); await loadAutomations();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('automationManager')?.addEventListener('click',async e=>{const run=e.target.closest('.automation-run'); if(run){ setBusy(true); try{ const j=await post(`/api/automations/${run.dataset.id}/run`,{}); toast(`Automation force run done (${j.result?.applied?.length||0} torrent item(s))`,'success'); await loadAutomations(); }catch(err){ toast(err.message,'danger'); } finally{ setBusy(false); } return; } const toggle=e.target.closest('.automation-toggle'); if(toggle){ await toggleAutomationRule(automationRulesCache.find(r=>String(r.id)===String(toggle.dataset.id))); return; } const edit=e.target.closest('.automation-edit'); if(edit){ editAutomationRule(automationRulesCache.find(r=>String(r.id)===String(edit.dataset.id))); return; } const id=e.target.closest('.automation-delete')?.dataset.id;if(!id)return;if(!confirm('Delete this automation rule?'))return;const r=await fetch('/api/automations/'+id,{method:'DELETE'});const j=await r.json();if(!j.ok)toast(j.error||'Delete failed','danger');await loadAutomations();}); $('automationHistory')?.addEventListener('click',e=>{ if(e.target.closest('#automationClearHistoryBtn')) clearAutomationHistory(); });\n document.addEventListener('click',async e=>{ const btn=e.target.closest('.delete-label'); if(!btn)return; if(!confirm('Delete this label?')) return; setBusy(true); try{ const r=await fetch('/api/labels/'+btn.dataset.id,{method:'DELETE'}); const j=await r.json(); if(!j.ok) throw new Error(j.error||'Delete failed'); await loadLabels(); toast('Label deleted','success'); }catch(err){toast(err.message,'danger');} finally{setBusy(false);} });\n $('bulkClearBtn')?.addEventListener('click',()=>{selected.clear(); selectedHash=null; lastSelectedHash=null; updateBulkBar(); if($('selectAll')) $('selectAll').checked=false; if($('detailPane')) $('detailPane').innerHTML='Select a torrent.'; setupPeersRefresh('general'); scheduleRender(true);});\n $('smartExcludeSelectedBtn')?.addEventListener('click',openSmartQueueExclusionModal);\n $('smartExclusionSearch')?.addEventListener('input',filterSmartQueueExclusionChoices);\n $('smartExclusionSaveBtn')?.addEventListener('click',saveSmartQueueExclusionChoices);\n $('smartHistory')?.addEventListener('click',async e=>{\n const clear=e.target.closest('#smartHistoryClear');\n if(clear){\n // Note: Clear history removes only Smart Queue audit rows for the active profile.\n if(!confirm('Clear Smart Queue history?')) return;\n try{ await post('/api/smart-queue/history',{},'DELETE'); smartHistoryExpanded=false; toast('Smart Queue history cleared','success'); await loadSmartQueue(); }catch(err){ toast(err.message,'danger'); }\n return;\n }\n const btn=e.target.closest('#smartHistoryToggle'); if(!btn) return; smartHistoryExpanded=!smartHistoryExpanded; loadSmartQueue();\n });\n\n // Note: Mobile filter changes are handled by setMobileFilterValue in bootstrap.js to avoid duplicate preference writes.\n function awaitMaybeRun(action){ runAction(action).catch?.(()=>{}); }\n function openRemoveModalForCurrentSelection(){\n // Note: Mobile remove uses the same Bootstrap modal as desktop, including the Remove with data switch.\n const modal=$('removeModal');\n if(!modal) return toast('Remove dialog is unavailable','danger');\n new bootstrap.Modal(modal).show();\n }\n document.addEventListener('click',e=>{ const ctx=$('ctxMenu'); if(!e.target.closest('#ctxMenu')) ctx.style.display='none'; const mobileFilter=e.target.closest('#mobileFilterBar .mobile-filter'); if(mobileFilter){ const key=mobileFilter.dataset.filter||'all'; if(key.startsWith('tracker:')){ activeTrackerFilter=key.slice(8); activeFilter='all'; mobileActiveFilterKey=key; } else { activeTrackerFilter=''; activeFilter=key; mobileActiveFilterKey=key; } syncFilterButtons(); saveActiveFilterPreference(); if($('tableWrap'))$('tableWrap').scrollTop=0; if($('mobileList'))$('mobileList').scrollTop=0; scheduleRender(true); return; } const mobileSort=e.target.closest('#mobileSortCycle'); if(mobileSort){ cycleMobileSort(); return; } const mobileSelectAll=e.target.closest('#mobileSelectAll'); if(mobileSelectAll){ const all=visibleRows.length>0 && visibleRows.every(t=>selected.has(t.hash)); if(all) visibleRows.forEach(t=>selected.delete(t.hash)); else visibleRows.forEach(t=>selected.add(t.hash)); if(selected.size===0){selectedHash=null;lastSelectedHash=null;} else {selectedHash=[...selected][selected.size-1];lastSelectedHash=selectedHash;} scheduleRender(true); return; } const mobileClear=e.target.closest('#mobileClearSelection'); if(mobileClear){ selected.clear(); selectedHash=null; lastSelectedHash=null; scheduleRender(true); return; } const mobileTorrentDownload=e.target.closest('#mobileBulkTorrentDownload'); if(mobileTorrentDownload){ downloadTorrentFiles(); return; } const mobileAct=e.target.closest('.mobile-card [data-action]'); if(mobileAct){ const card0=mobileAct.closest('.mobile-card'); selected.clear(); selected.add(card0.dataset.hash); selectedHash=card0.dataset.hash; lastSelectedHash=selectedHash; if(mobileAct.dataset.action==='remove') openRemoveModalForCurrentSelection(); else awaitMaybeRun(mobileAct.dataset.action); scheduleRender(true); return; } const mobileModal=e.target.closest('.mobile-card [data-mobile-modal]'); if(mobileModal){ const card0=mobileModal.closest('.mobile-card'); selected.clear(); selected.add(card0.dataset.hash); selectedHash=card0.dataset.hash; lastSelectedHash=selectedHash; scheduleRender(true); if(mobileModal.dataset.mobileModal==='label') new bootstrap.Modal($('labelModal')).show(); return; } const card=e.target.closest('.mobile-card'); const tr=e.target.closest('tr[data-hash]'); const row=tr||card; if(row){ const h=row.dataset.hash; const additive=e.ctrlKey||e.metaKey; if(e.shiftKey){ setSelectionRange(h, additive); } else if(e.target.classList.contains('row-check')){ e.target.checked?selected.add(h):selected.delete(h); lastSelectedHash=h; selectedHash=selected.size?h:null; } else { selectedHash=h; if(!additive)selected.clear(); selected.add(h); lastSelectedHash=h; loadDetails(activeTab()); } updateBulkBar(); scheduleRender(true); } const copy=e.target.closest('[data-copy]'); if(copy) copySelected(copy.dataset.copy); const torrentExport=e.target.closest('[data-download-torrent]'); if(torrentExport){ downloadTorrentFiles(); return; } const smartEx=e.target.closest('#smartExcludeCtx'); if(smartEx){ selectedHashes().forEach(h=>post('/api/smart-queue/exclusion',{hash:h,excluded:true,reason:'manual'}).catch(()=>{})); toast('Smart Queue exception saved','success'); loadSmartQueue().catch(()=>{}); } const act=e.target.closest('.torrent-action,[data-action]'); if(act&&act.dataset.action&&!act.closest('#detailTabs')&&!act.closest('.mobile-card')) runAction(act.dataset.action); });\n document.addEventListener('contextmenu',e=>{ const tr=e.target.closest('tr[data-hash],.mobile-card'); if(!tr)return; e.preventDefault(); selectedHash=tr.dataset.hash; if(!selected.has(selectedHash)){selected.clear();selected.add(selectedHash);scheduleRender(true);} const m=$('ctxMenu'); m.style.left=`${e.pageX}px`; m.style.top=`${e.pageY}px`; m.style.display='block'; });\n setupDetailResizer();\n document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>th.addEventListener('click',()=>{ const key=th.dataset.sort; if(sortState.key===key) sortState.dir*=-1; else sortState={key,dir:1}; saveTorrentSortPreference(); scheduleRender(true); })); $('tableWrap')?.addEventListener('scroll',()=>scheduleRender(false),{passive:true}); $('selectAll')?.addEventListener('change',e=>{selected.clear(); if(e.target.checked)visibleRows.forEach(t=>selected.add(t.hash)); updateBulkBar(); scheduleRender(true);}); $('searchBox')?.addEventListener('input',()=>{if($('tableWrap'))$('tableWrap').scrollTop=0;scheduleRender(true);}); document.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('.filter').forEach(x=>x.classList.remove('active')); b.classList.add('active'); activeTrackerFilter=''; activeFilter=b.dataset.filter; mobileActiveFilterKey=activeFilter; saveActiveFilterPreference(); if($('tableWrap'))$('tableWrap').scrollTop=0; scheduleRender(true);})); document.querySelectorAll('#detailTabs .nav-link').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('#detailTabs .nav-link').forEach(x=>x.classList.remove('active')); b.classList.add('active'); loadDetails(b.dataset.tab);})); document.addEventListener('change',e=>{ const sel=e.target.closest('.file-priority'); if(sel){ setFilePriorities([{index:Number(sel.dataset.index),priority:Number(sel.value)}]); return; } if(e.target && e.target.id==='fileSelectAll'){ document.querySelectorAll('#detailPane .file-check').forEach(cb=>cb.checked=e.target.checked); } }); document.addEventListener('click',e=>{ const bulk=e.target.closest('.file-priority-bulk'); if(!bulk) return; const priority=Number(bulk.dataset.priority); const checked=[...document.querySelectorAll('#detailPane .file-check:checked')].map(cb=>({index:Number(cb.dataset.index),priority})); if(!checked.length) return toast('No files selected','warning'); setFilePriorities(checked); }); document.addEventListener('click',e=>{ const tree=e.target.closest('.file-tree-refresh'); if(tree){ loadFileTree(); return; } const oneDownload=e.target.closest('.file-download-one'); if(oneDownload){ downloadResponse(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${oneDownload.dataset.index}/download`,{},'file.bin','Preparing file...').catch(err=>toast(err.message,'danger')); return; } const selectedDownload=e.target.closest('.file-download-selected'); if(selectedDownload){ downloadSelectedFiles(); return; } const allZip=e.target.closest('.file-download-zip'); if(allZip){ downloadZip(null); return; } const folder=e.target.closest('.folder-priority'); if(folder){ post(`/api/torrents/${encodeURIComponent(selectedHash)}/files/folder-priority`,{path:folder.dataset.path||'',priority:Number(folder.dataset.priority||0)}).then(()=>{toast('Folder priority updated','success');loadDetails('files');}).catch(err=>toast(err.message,'danger')); } }); document.addEventListener('click',e=>{ const cell=e.target.closest('.chunk-cell'); if(cell){ cell.classList.toggle('is-selected'); if(typeof updateChunkSelectionInfo==='function') updateChunkSelectionInfo(); return; } const refresh=e.target.closest('.chunk-refresh'); if(refresh){ loadDetails('chunks'); return; } const recheck=e.target.closest('.chunk-action-recheck'); if(recheck){ runChunkAction('recheck',{}); return; } const prio=e.target.closest('.chunk-action-prioritize'); if(prio){ const range=selectedChunkRange(); if(!range) return toast('No chunks selected','warning'); runChunkAction('prioritize_files',{...range,priority:2}); } }); document.addEventListener('click',e=>{ const add=e.target.closest('#trackerAddBtn'); if(add){ const url=$('trackerAddUrl')?.value||''; trackerAction('add',{url}); return; } const del=e.target.closest('.tracker-delete'); if(del && !del.disabled){ trackerAction('delete',{index:Number(del.dataset.index)}); return; } const rea=e.target.closest('#trackerReannounceBtn'); if(rea) trackerAction('reannounce',{}); }); $('appStatusRefreshBtn')?.addEventListener('click',loadAppStatus); $('portCheckEnabled')?.addEventListener('change',savePortCheckPref); $('portCheckNowBtn')?.addEventListener('click',()=>loadPortCheck(true)); $('bootstrapThemeSelect')?.addEventListener('change',saveAppearancePreferences); $('fontFamilySelect')?.addEventListener('change',saveAppearancePreferences); $('interfaceScaleRange')?.addEventListener('input',e=>applyInterfaceScale(e.target.value)); $('interfaceScaleRange')?.addEventListener('change',saveAppearancePreferences); $('resetViewPreferencesBtn')?.addEventListener('click',resetViewPreferences); $('titleSpeedEnabled')?.addEventListener('change',saveTitleSpeedPreference); $('trackerFaviconsEnabled')?.addEventListener('change',saveTrackerFaviconsPreference); $('automationToastsEnabled')?.addEventListener('change',saveNotificationPrefs); $('smartQueueToastsEnabled')?.addEventListener('change',saveNotificationPrefs); document.querySelectorAll('.disk-monitor-mode').forEach(input=>input.addEventListener('change',async e=>{ diskMonitorMode=e.target.value||'default'; if(diskMonitorMode==='selected' && !diskMonitorSelectedPath && diskMonitorPaths.length) diskMonitorSelectedPath=diskMonitorPaths[0]; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); })); $('diskMonitorSelectedPath')?.addEventListener('change',async e=>{ diskMonitorSelectedPath=e.target.value||''; if(diskMonitorSelectedPath) diskMonitorMode='selected'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('addDiskPathBtn')?.addEventListener('click',async()=>{ const p=($('diskMonitorPathInput')?.value||'').trim(); if(!p) return; if(!diskMonitorPaths.includes(p)) diskMonitorPaths.push(p); if(!diskMonitorSelectedPath) diskMonitorSelectedPath=p; if(diskMonitorMode==='default') diskMonitorMode='selected'; if($('diskMonitorPathInput')) $('diskMonitorPathInput').value=''; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('diskMonitorPaths')?.addEventListener('click',async e=>{ const use=e.target.closest('.disk-path-select'); if(use){ diskMonitorSelectedPath=use.dataset.path||''; diskMonitorMode='selected'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); return; } const btn=e.target.closest('.disk-path-remove'); if(!btn) return; diskMonitorPaths=diskMonitorPaths.filter(p=>p!==btn.dataset.path); if(diskMonitorSelectedPath===btn.dataset.path) diskMonitorSelectedPath=diskMonitorPaths[0]||''; if(diskMonitorMode==='selected' && !diskMonitorSelectedPath) diskMonitorMode='default'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('saveFooterPrefsBtn')?.addEventListener('click',saveFooterPreferences);\n document.addEventListener('keydown',e=>{ const tag=(e.target?.tagName||'').toLowerCase(); const editable=tag==='input'||tag==='textarea'||tag==='select'||e.target?.isContentEditable; if(editable){ if(e.key==='Enter' && e.target?.id==='labelInput'){ e.preventDefault(); $('addLabelToSelectionBtn')?.click(); } return; } if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='a'){e.preventDefault();selected.clear();visibleRows.forEach(t=>selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='i'){e.preventDefault();visibleRows.forEach(t=>selected.has(t.hash)?selected.delete(t.hash):selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='o'){e.preventDefault();new bootstrap.Modal($('addModal')).show();} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='s'){e.preventDefault();downloadTorrentFiles();return;} if(e.key==='Escape'){selected.clear();scheduleRender(true);} if(e.key==='Delete') new bootstrap.Modal($('removeModal')).show(); if(e.key===' ') {e.preventDefault();runAction('start');} if(e.key.toLowerCase()==='p')runAction('pause'); if(e.key.toLowerCase()==='s' && !(e.ctrlKey||e.metaKey))runAction('stop'); if(e.key.toLowerCase()==='r')runAction('resume'); if(e.key.toLowerCase()==='m')runAction('move'); });\n $('removeModal')?.addEventListener('show.bs.modal',()=>{$('removeCount').textContent=selected.size;$('removeData').checked=true;}); $('confirmRemoveBtn')?.addEventListener('click',async()=>{await runAction('remove',{remove_data:$('removeData').checked});bootstrap.Modal.getInstance($('removeModal'))?.hide();});\n $('addModal')?.addEventListener('show.bs.modal',()=>applyDefaultDownloadPath(true));\n\n $('toolsModal')?.addEventListener('show.bs.modal',()=>applyDefaultDownloadPath(false));\n const addPreviewState = {items: []};\n function renderTorrentPreview(items=[]){\n addPreviewState.items = items;\n const box=$('torrentPreview');\n if(!box) return;\n if(!items.length){ box.innerHTML=''; return; }\n const cards=items.map(item=>{\n const files=(item.files||[]).map((f,index)=>`${esc(f.path)}${esc(fmtBytes(f.size||0))}`).join('');\n const limitWarn=item.xmlrpc_too_large?`
Too large for current rTorrent XML-RPC upload limit: request ${esc(item.xmlrpc_request_h||'')} exceeds the configured limit. Change network.xmlrpc.size_limit in rTorrent config, e.g. 16M.
`:'';\n return `
${esc(item.name||item.filename)}${item.duplicate?'duplicate':''}${esc(fmtBytes(item.size||0))} \u00b7 ${esc(item.file_count||0)} files
${esc(item.info_hash||'')}
${limitWarn}
${files}
`;\n }).join('');\n box.innerHTML=`
Preview before adding
${cards}`;\n }\n async function previewTorrentFiles(){\n const input=$('torrentFiles');\n const files=[...(input?.files||[])];\n $('torrentFilesInfo').textContent=files.length?`Selected files: ${files.length}`:'You can select multiple files at once.';\n if(!files.length) return renderTorrentPreview([]);\n const fd=new FormData();\n files.forEach(f=>fd.append('files',f));\n try{\n const j=await (await fetch('/api/torrents/preview',{method:'POST',body:fd})).json();\n if(!j.ok) throw new Error(j.error||'Preview failed');\n renderTorrentPreview(j.previews||[]);\n }catch(e){ if($('torrentPreview')) $('torrentPreview').innerHTML=`
${esc(e.message)}
`; }\n }\n function collectPreviewPriorities(){\n const out={};\n addPreviewState.items.forEach(item=>{\n const key=item.info_hash||item.filename;\n out[key]=[...(item.files||[])].map((f,index)=>({index,priority:document.querySelector(`.preview-file-priority[data-torrent=\"${CSS.escape(key)}\"][data-index=\"${index}\"]`)?.checked ? 1 : 0}));\n });\n return out;\n }\n function torrentFilesFromDrop(event){\n return [...(event.dataTransfer?.files||[])].filter(file=>/\\.torrent$/i.test(file.name||'') || file.type==='application/x-bittorrent');\n }\n function dragHasFiles(event){\n const dt=event.dataTransfer;\n if(!dt) return false;\n if([...(dt.types||[])].includes('Files')) return true;\n return [...(dt.items||[])].some(item=>item.kind==='file');\n }\n async function droppedTorrentSummary(files){\n const fd=new FormData();\n files.forEach(file=>fd.append('files',file));\n try{\n const j=await (await fetch('/api/torrents/preview',{method:'POST',body:fd})).json();\n if(!j.ok) throw new Error(j.error||'Preview failed');\n const names=(j.previews||[]).map(item=>`${item.duplicate?'[duplicate] ':''}${item.name||item.filename}`).filter(Boolean);\n return names.length ? names : files.map(file=>file.name);\n }catch(e){\n return files.map(file=>file.name);\n }\n }\n async function addDroppedTorrentFiles(files){\n const torrentFiles=[...files].filter(file=>/\\.torrent$/i.test(file.name||'') || file.type==='application/x-bittorrent');\n if(!torrentFiles.length){ toast('Drop .torrent files only','warning'); return; }\n const names=await droppedTorrentSummary(torrentFiles);\n const preview=names.slice(0,8).join('\\n');\n const suffix=names.length>8?`\\n...and ${names.length-8} more`:'';\n if(!confirm(`Add ${torrentFiles.length} torrent file(s)?\\n\\n${preview}${suffix}`)) return;\n setBusy(true,'Adding dropped torrent files...');\n try{\n const fd=new FormData();\n fd.append('uris','');\n fd.append('directory',await getDefaultDownloadPath());\n fd.append('label','');\n fd.append('start','1');\n torrentFiles.forEach(file=>fd.append('files',file));\n const res=await fetch('/api/torrents/add',{method:'POST',body:fd});\n const j=await res.json().catch(()=>({ok:false,error:`Add failed: HTTP ${res.status}`}));\n if(!res.ok || !j.ok) throw new Error(j.error||`Add failed: HTTP ${res.status}`);\n const skipped=(j.skipped_duplicates||[]).length;\n const queued=(j.job_ids||[]).length;\n if(queued && skipped) toast(`Added ${queued} torrent(s), skipped ${skipped} duplicate(s)`,'warning');\n else if(queued) toast(`Added ${queued} torrent(s)`,'success');\n else if(skipped) toast(`Skipped ${skipped} duplicate torrent(s)`,'warning');\n else toast('No torrents were added','warning');\n }catch(e){\n toast(e.message,'danger');\n }finally{\n setBusy(false);\n document.body.classList.remove('dragging-torrent-files');\n }\n }\n function setupTorrentDropZone(){\n const zones=[$('tableWrap'),$('torrentBody'),$('mobileList'),document.querySelector('.content'),document.body].filter(Boolean);\n let dragDepth=0;\n const markActive=()=>document.body.classList.add('dragging-torrent-files');\n const clearActive=()=>document.body.classList.remove('dragging-torrent-files');\n const onDragEnter=event=>{\n if(!dragHasFiles(event)) return;\n event.preventDefault();\n dragDepth+=1;\n markActive();\n };\n const onDragOver=event=>{\n if(!dragHasFiles(event)) return;\n event.preventDefault();\n if(event.dataTransfer) event.dataTransfer.dropEffect='copy';\n markActive();\n };\n const onDragLeave=event=>{\n if(!dragHasFiles(event)) return;\n dragDepth=Math.max(0,dragDepth-1);\n if(!dragDepth) clearActive();\n };\n const onDrop=event=>{\n if(!dragHasFiles(event)) return;\n event.preventDefault();\n event.stopPropagation();\n dragDepth=0;\n clearActive();\n addDroppedTorrentFiles(event.dataTransfer?.files||[]);\n };\n zones.forEach(zone=>{\n if(zone.dataset?.torrentDropZoneBound==='1') return;\n if(zone.dataset) zone.dataset.torrentDropZoneBound='1';\n zone.addEventListener('dragenter',onDragEnter);\n zone.addEventListener('dragover',onDragOver);\n zone.addEventListener('dragleave',onDragLeave);\n zone.addEventListener('drop',onDrop);\n });\n }\n function hasTooLargeTorrentPreview(){\n // Note: Client-side upload blocking mirrors the server validation and gives feedback before the add request.\n return addPreviewState.items.some(item=>item.xmlrpc_too_large);\n }\n function addTorrentPayload(){\n const fd=new FormData();\n fd.append('uris',$('magnetInput')?.value||'');\n fd.append('directory',$('addPath')?.value||'');\n fd.append('label',$('addLabel')?.value||'');\n fd.append('start',$('addStart')?.checked?'1':'0');\n fd.append('file_priorities',JSON.stringify(collectPreviewPriorities()));\n [...($('torrentFiles')?.files||[])].forEach(f=>fd.append('files',f));\n return fd;\n }\n function resetAddTorrentForm(){\n if($('magnetInput')) $('magnetInput').value='';\n if($('torrentFiles')) $('torrentFiles').value='';\n renderTorrentPreview([]);\n }\n async function addTorrentFromModal(){\n const btn=$('addBtn');\n buttonBusy(btn,true);\n setBusy(true);\n try{\n if(hasTooLargeTorrentPreview()) throw new Error('One or more .torrent files exceed the current rTorrent XML-RPC upload limit. Open rTorrent config and set network.xmlrpc.size_limit to e.g. 16M.');\n const res=await fetch('/api/torrents/add',{method:'POST',body:addTorrentPayload()});\n const j=await res.json().catch(()=>({ok:false,error:`Add failed: HTTP ${res.status}`}));\n if(!res.ok || !j.ok) throw new Error(j.error||`Add failed: HTTP ${res.status}`);\n const skipped=(j.skipped_duplicates||[]).length;\n if(skipped) toast(`Add queued, skipped ${skipped} duplicate torrent(s)`,'warning');\n else toast('Add queued','success');\n resetAddTorrentForm();\n bootstrap.Modal.getInstance($('addModal'))?.hide();\n }catch(e){\n toast(e.message,'danger');\n }finally{\n buttonBusy(btn,false);\n setBusy(false);\n }\n }\n $('addBtn')?.addEventListener('click',addTorrentFromModal); $('torrentFiles')?.addEventListener('change',previewTorrentFiles); $('torrentPreview')?.addEventListener('click',e=>{const card=e.target.closest('.torrent-preview-card'); if(!card) return; if(e.target.closest('.preview-select-all')) card.querySelectorAll('.preview-file-priority').forEach(x=>x.checked=true); if(e.target.closest('.preview-select-none')) card.querySelectorAll('.preview-file-priority').forEach(x=>x.checked=false);});\n const mbpsToKib=mbps=>mbps?Math.round((Number(mbps)*1000000/8)/1024):0;\n const kibToMbps=kib=>kib?Math.round((Number(kib)*1024*8)/1000000):0;\n function setLimitSliderMax(slider,mbps){ if(slider && mbps>Number(slider.max||0)) slider.max=String(mbps); }\n function setLimitValue(targetId,kib){ const input=$(targetId); if(input) input.value=Math.max(0,Math.round(Number(kib)||0)); }\n function updateLimitSlider(slider){ if(!slider) return; const input=$(slider.dataset.target); const out=$(slider.dataset.output); const mbps=kibToMbps(Number(input?.value||0)); setLimitSliderMax(slider,mbps); slider.value=String(mbps); if(out) out.textContent=mbps?`${mbps} Mbit/s`:'Unlimited'; }\n function updateLimitSliders(){ document.querySelectorAll('.limit-slider').forEach(updateLimitSlider); }\n function syncLimitInputFromSlider(slider){ const mbps=Number(slider.value||0); setLimitValue(slider.dataset.target,mbpsToKib(mbps)); updateLimitSlider(slider); }\n document.querySelectorAll('.limit-preset').forEach(b=>b.addEventListener('click',()=>{const kib=mbpsToKib(Number(b.dataset.mbps||0));setLimitValue('limitDown',kib);setLimitValue('limitUp',kib);updateLimitSliders();}));\n document.querySelectorAll('.limit-slider').forEach(slider=>slider.addEventListener('input',()=>syncLimitInputFromSlider(slider)));\n ['limitDown','limitUp'].forEach(id=>$(id)?.addEventListener('input',updateLimitSliders));\n $('saveSpeedBtn')?.addEventListener('click',async()=>{const btn=$('saveSpeedBtn');buttonBusy(btn,true);setBusy(true);try{await post('/api/speed/limits',{down:Math.round(Number($('limitDown').value||0)*1024),up:Math.round(Number($('limitUp').value||0)*1024)});toast('Speed limits queued','success');bootstrap.Modal.getInstance($('speedModal'))?.hide();}catch(e){toast(e.message,'danger');}finally{buttonBusy(btn,false);setBusy(false);}}); $('speedModal')?.addEventListener('show.bs.modal',()=>{setLimitValue('limitDown',lastLimits.down?Math.round(lastLimits.down/1024):0);setLimitValue('limitUp',lastLimits.up?Math.round(lastLimits.up/1024):0);updateLimitSliders();});\n async function activeProfileForSettings(){\n const j=await (await fetch('/api/profiles')).json();\n return j.active || (j.profiles||[])[0] || null;\n }\n function fillJobSettings(profile){\n if(!profile) return;\n if($('jobHeavyParallel')) $('jobHeavyParallel').value=profile.max_parallel_jobs||5;\n if($('jobLightParallel')) $('jobLightParallel').value=profile.light_parallel_jobs||4;\n if($('jobLightTimeout')) $('jobLightTimeout').value=profile.light_job_timeout_seconds||300;\n if($('jobHeavyTimeout')) $('jobHeavyTimeout').value=profile.heavy_job_timeout_seconds||7200;\n if($('jobPendingTimeout')) $('jobPendingTimeout').value=profile.pending_job_timeout_seconds||900;\n if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent=profile.name?`Active profile: ${profile.name}`:'';\n }\n async function loadJobSettings(){\n try{\n const profile=await activeProfileForSettings();\n if(!profile){ if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent='No active profile.'; return; }\n fillJobSettings(profile);\n }catch(e){ if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent=e.message; }\n }\n function jobSettingsPayload(profile){\n return {\n name:profile.name,\n scgi_url:profile.scgi_url,\n timeout_seconds:profile.timeout_seconds||5,\n max_parallel_jobs:$('jobHeavyParallel')?.value||5,\n light_parallel_jobs:$('jobLightParallel')?.value||4,\n light_job_timeout_seconds:$('jobLightTimeout')?.value||300,\n heavy_job_timeout_seconds:$('jobHeavyTimeout')?.value||7200,\n pending_job_timeout_seconds:$('jobPendingTimeout')?.value||900,\n is_remote:!!profile.is_remote,\n is_default:!!profile.is_default\n };\n }\n async function saveJobSettings(){\n const btn=$('saveJobSettingsBtn');\n buttonBusy(btn,true);\n try{\n const profile=await activeProfileForSettings();\n if(!profile) throw new Error('No active profile');\n const j=await post(`/api/profiles/${profile.id}`,jobSettingsPayload(profile),'PUT');\n fillJobSettings(j.profile||profile);\n await refreshProfiles();\n toast('Job settings saved','success');\n }catch(e){ toast(e.message,'danger'); }\n finally{ buttonBusy(btn,false); }\n }\n async function refreshProfiles(){ const j=await (await fetch('/api/profiles')).json(); profileCache=new Map((j.profiles||[]).map(p=>[String(p.id),p])); const active=j.active?.id; const rows=j.profiles||[]; const statusMap=new Map(); try{ const d=await (await fetch('/api/profiles/diagnostics')).json(); (d.diagnostics||[]).forEach(x=>statusMap.set(String(x.profile_id), x)); }catch(e){} $('profileList').innerHTML=rows.map(p=>{ const d=statusMap.get(String(p.id))||{}; const st=d.status || 'unknown'; const cls=st==='online'?'success':st==='slow'?'warning':st==='error'?'danger':'secondary'; return `
${esc(p.name)} ${p.id===active?\"active\":''} ${p.is_remote?\"remote\":''} ${esc(st)}${esc(p.scgi_url)} \u00b7 heavy ${esc(p.max_parallel_jobs||5)} \u00b7 light ${esc(p.light_parallel_jobs||4)} \u00b7 API ${esc(p.api_limit_per_minute||'-')}/min \u00b7 poll ${esc(p.polling_min_interval_seconds||'-')}s${d.response_time_ms?` \u00b7 ${esc(d.response_time_ms)} ms`:''}
`; }).join('')||'No profiles.'; }\n function profileFormPayload(){ return {id:$('profileId')?.value||null,name:$('profileName')?.value||'',scgi_url:$('profileUrl')?.value||'',timeout_seconds:$('profileTimeout')?.value||5,max_parallel_jobs:$('profileParallel')?.value||5,light_parallel_jobs:$('jobLightParallel')?.value||4,light_job_timeout_seconds:$('jobLightTimeout')?.value||300,heavy_job_timeout_seconds:$('jobHeavyTimeout')?.value||7200,pending_job_timeout_seconds:$('jobPendingTimeout')?.value||900,is_remote:$('profileRemote')?.checked}; }\n function renderProfileDiagnostics(d={}){ const box=$('profileDiagnosticsResult'); if(!box) return; const status=d.status || (d.ok?'online':'error'); const paths=d.base_paths||{}; const wp=d.write_permissions||{}; const disk=d.free_disk||{}; const firstDisk=Object.values(disk)[0]||{}; const cards=[['Status',status],['rTorrent',d.version||'-'],['Library',d.library_version||'-'],['Response',d.response_time_ms!=null?`${d.response_time_ms} ms`:'-'],['Default path',paths.default_directory||'-'],['CWD',paths.cwd||'-'],['Write',Object.values(wp)[0]||'-'],['Free disk',firstDisk.free_h||firstDisk.error||'-']]; box.innerHTML=`
${cards.map(([k,v])=>`
${esc(k)}${esc(v)}
`).join('')}
${d.error?`
${esc(d.error)}
`:''}`; }\n async function testProfilePayload(payload=null){ const p=payload||profileFormPayload(); const res=await post('/api/profiles/test', p); renderProfileDiagnostics(res.diagnostics||{}); return res.diagnostics||{}; }\n\n function resetProfileForm(){ if($('profileId')) $('profileId').value=''; if($('profileName')) $('profileName').value=''; if($('profileUrl')) $('profileUrl').value=''; if($('profileTimeout')) $('profileTimeout').value='5'; if($('profileParallel')) $('profileParallel').value='5'; if($('profileRemote')) $('profileRemote').checked=false; if($('profileFormTitle')) $('profileFormTitle').textContent='Add rTorrent profile'; if($('saveProfileBtn')) $('saveProfileBtn').innerHTML=' Add profile'; $('cancelProfileEditBtn')?.classList.add('d-none'); }\n function editProfileForm(profile){ if(!profile) return; if($('profileId')) $('profileId').value=profile.id; if($('profileName')) $('profileName').value=profile.name||''; if($('profileUrl')) $('profileUrl').value=profile.scgi_url||''; if($('profileTimeout')) $('profileTimeout').value=profile.timeout_seconds||5; if($('profileParallel')) $('profileParallel').value=profile.max_parallel_jobs||5; if($('profileRemote')) $('profileRemote').checked=!!profile.is_remote; fillJobSettings(profile); if($('profileFormTitle')) $('profileFormTitle').textContent='Edit rTorrent profile'; if($('saveProfileBtn')) $('saveProfileBtn').innerHTML=' Save profile'; $('cancelProfileEditBtn')?.classList.remove('d-none'); $('profileName')?.focus(); }\n async function activateProfileAndRefresh(id, label=''){\n // Note: Profile activation now refreshes all profile-scoped client state without requiring a browser reload.\n if(!id) return;\n setBusy(true, 'Switching profile...');\n try{\n await post(`/api/profiles/${id}/activate`,{});\n activeProfileId=id;\n window.PYTORRENT.activeProfile=Number(id);\n if($('activeProfileName') && label) $('activeProfileName').textContent=label;\n bootstrap.Modal.getInstance($('profilePickerModal'))?.hide();\n defaultDownloadPath=null;\n lastUserDiskFetchAt=0;\n userDiskFetchSeq += 1;\n userDiskFetchInFlight=false;\n clearRtorrentStartingState();\n hasTorrentSnapshot=false;\n torrentSummary=null;\n trackerSummary={hashes:{}, trackers:[], scanned:0, errors:[]};\n trackerSummaryStatus='idle';\n trackerSummarySignature='';\n torrents.clear();\n selected.clear();\n selectedHash=null;\n scheduleRender(true);\n await loadPreferences().catch(()=>{});\n await Promise.allSettled([\n applyDefaultDownloadPath(true),\n refreshUserDiskUsage(true),\n loadSmartQueue(),\n loadDownloadPlanner(),\n loadPollerSettings(),\n ]);\n socket.emit('select_profile',{profile_id:Number(id)});\n toast('Profile switched','success');\n }catch(e){\n toast(e.message||'Profile switch failed','danger');\n }finally{\n setBusy(false);\n }\n }\n\n // Note: The rTorrent list lives in Tools modal; refresh it when that modal is shown instead of referencing a missing modal id.\n $('profilePickerModal')?.addEventListener('show.bs.modal',async()=>{\n try{\n const j=await (await fetch('/api/profiles')).json();\n const select=$('profileSelect');\n if(select) select.innerHTML=(j.profiles||[]).map(p=>``).join('') || '';\n }catch(e){}\n }); $('profileList')?.addEventListener('click',async e=>{const btn=e.target.closest('[data-del-profile],[data-use-profile],[data-edit-profile],[data-test-saved-profile]'); const del=btn?.dataset.delProfile,use=btn?.dataset.useProfile,edit=btn?.dataset.editProfile,test=btn?.dataset.testSavedProfile;if(test){ const oldHtml=btn.innerHTML; btn.disabled=true; btn.innerHTML=' testing'; const box=$('profileDiagnosticsResult'); if(box) box.innerHTML='
Testing saved profile...
'; try{ const r=await (await fetch(`/api/profiles/${test}/diagnostics`)).json(); renderProfileDiagnostics(r.diagnostics||{}); }catch(e){ if(box) box.innerHTML=`
${esc(e.message)}
`; toast(e.message,'danger'); } finally{ btn.disabled=false; btn.innerHTML=oldHtml; } return; } if(edit){editProfileForm(profileCache.get(String(edit)));return;} if(del){setBusy(true);await fetch(`/api/profiles/${del}`,{method:'DELETE'});setBusy(false);refreshProfiles();location.reload();} if(use){await activateProfileAndRefresh(use, profileCache.get(String(use))?.name || 'rTorrent');}}); $('cancelProfileEditBtn')?.addEventListener('click',resetProfileForm); $('testProfileBtn')?.addEventListener('click',async()=>{ const btn=$('testProfileBtn'); const oldHtml=btn?.innerHTML; if(btn){ btn.disabled=true; btn.innerHTML=' Testing SCGI...'; } const box=$('profileDiagnosticsResult'); if(box) box.innerHTML='
Testing SCGI connection...
'; setBusy(true); try{ const d=await testProfilePayload(); toast(d.ok?'SCGI test OK':'SCGI test failed', d.ok?'success':'danger'); }catch(e){ toast(e.message,'danger'); if(box) box.innerHTML=`
${esc(e.message)}
`; } finally{setBusy(false); if(btn){ btn.disabled=false; btn.innerHTML=oldHtml||' Test SCGI'; }} }); $('profileExportBtn')?.addEventListener('click',async()=>{ const j=await (await fetch('/api/profiles/export')).json(); const blob=new Blob([JSON.stringify(j,null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='pytorrent-profiles.json'; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); }); $('profileImportBtn')?.addEventListener('click',()=>$('profileImportFile')?.click()); $('profileImportFile')?.addEventListener('change',async e=>{ const file=e.target.files?.[0]; if(!file) return; try{ const payload=JSON.parse(await file.text()); await post('/api/profiles/import',payload); toast('Profiles imported','success'); refreshProfiles(); }catch(err){ toast(err.message,'danger'); } e.target.value=''; }); $('saveProfileBtn')?.addEventListener('click',async()=>{setBusy(true);const id=$('profileId')?.value;const payload=profileFormPayload();const j=await post(id?`/api/profiles/${id}`:'/api/profiles',payload,id?'PUT':'POST').catch(e=>toast(e.message,'danger'));setBusy(false);if(j?.profile)location.reload();}); $('saveJobSettingsBtn')?.addEventListener('click',saveJobSettings); $('reloadJobSettingsBtn')?.addEventListener('click',loadJobSettings); $('profileSelect')?.addEventListener('change',async e=>{const id=e.target.value;if(!id)return;const opt=e.target.selectedOptions?.[0];await activateProfileAndRefresh(id, opt?.textContent || 'rTorrent');});\n // Note: Opens the existing rTorrent form directly from the empty first-run state.\n document.addEventListener('click',e=>{ if(!e.target.closest('#setupProfileBtn')) return; activateToolTab('rtorrents'); new bootstrap.Modal($('toolsModal')).show(); setTimeout(()=>$('profileName')?.focus(),150); });\n // Note: On a fresh install there is no rTorrent snapshot to wait for, so open the app and show setup immediately.\n function showFirstRunSetup(){\n if(hasActiveProfile || firstRunSetupShown) return;\n firstRunSetupShown = true;\n $('connBadge').className='badge text-bg-warning';\n $('connBadge').textContent='setup required';\n setInitialLoader('Configure rTorrent','Add the first rTorrent profile to start loading torrents.');\n renderNoProfileState();\n hideInitialLoader();\n setTimeout(()=>{ activateToolTab('rtorrents'); new bootstrap.Modal($('toolsModal')).show(); }, 120);\n }\n $('themeToggle')?.addEventListener('click',async()=>{const cur=document.documentElement.dataset.bsTheme==='dark'?'light':'dark';document.documentElement.dataset.bsTheme=cur;await post('/api/preferences',{theme:cur}).catch(()=>{});}); $('mobileToggle')?.addEventListener('click',()=>{document.body.classList.toggle('mobile-mode-manual');syncMobileMode();}); window.addEventListener('resize',()=>syncMobileMode(),{passive:true}); syncMobileMode();\n"; diff --git a/pytorrent/static/js/rss.js b/pytorrent/static/js/rss.js new file mode 100644 index 0000000..c857d4c --- /dev/null +++ b/pytorrent/static/js/rss.js @@ -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=`
Feeds
${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||''),` `]))}
Rules
${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),` `]))}
RSS log
${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 '
No saved rows in this table.
';\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=>`
${esc(t.name)}${esc(t.rows)} row(s) \u00b7 ${(t.columns||[]).length} column(s)${backupPreviewDetails(t)}
`).join('');\n return `
Backup preview
Created: ${esc(preview.created_at||'-')} \u00b7 ${preview.automatic?'automatic':'manual'} \u00b7 sensitive values hidden
${rows || '
Backup has no previewable settings.
'}
`;\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',`
Download
`]),'backup-table');\n }\n\n"; diff --git a/pytorrent/static/js/smartQueue.js b/pytorrent/static/js/smartQueue.js new file mode 100644 index 0000000..7059ae9 --- /dev/null +++ b/pytorrent/static/js/smartQueue.js @@ -0,0 +1 @@ +export const smartQueueSource = " function smartHistoryDetails(row){ try{ return typeof row.details_json==='string'?JSON.parse(row.details_json||'{}'):(row.details_json||{}); }catch(e){ return {}; } }\n function smartQueueToastMessage(r){ const pending=r.start_pending_confirmation?.length||0; const requested=r.start_requested?.length||0; const stopFailed=r.stop_failed?.length||0; const startFailed=r.start_failed?.length||0; const limit=r.max_active_downloads||r.settings?.max_active_downloads||''; const activeBefore=r.active_before; const activeAfter=r.active_after_stop ?? r.active_after_expected; const activeTail=activeBefore!==undefined?`, active ${esc(activeBefore)}->${esc(activeAfter ?? '?')}${limit?`/${esc(limit)}`:''}`:''; const cap=r.rtorrent_cap?.updated?`, cap ${r.rtorrent_cap.current}->${r.rtorrent_cap.new}`:''; const waiting=r.waiting_labeled||0; const stalled=r.stalled_labeled?.length||0; const ignoredSpeed=(r.ignore_speed||r.settings?.ignore_speed)?Number(r.ignored_speed_count||0):0; const tail=pending?`, pending confirm ${pending}`:requested?`, requested ${requested}`:''; const waitTail=waiting?`, waiting labeled ${waiting}`:''; const stalledTail=stalled?`, stalled ${stalled}`:''; const ignoredSpeedTail=(r.ignore_speed||r.settings?.ignore_speed)?`, ignored speed ${ignoredSpeed}`:''; const failTail=`${stopFailed?`, stop failed ${stopFailed}`:''}${startFailed?`, start failed ${startFailed}`:''}`; return `Smart Queue: stopped ${r.stopped?.length||r.paused?.length||0}, started ${r.started?.length||r.resumed?.length||0}${activeTail}${tail}${waitTail}${stalledTail}${ignoredSpeedTail}${failTail}${cap}`; }\n function buildSmartQueueNerdStats(hist=[], totalHistory=0){\n // Note: Small Smart Queue telemetry for automation nerds; it reads history only and does not affect queue behavior.\n const stats=hist.reduce((acc,h)=>{\n const details=smartHistoryDetails(h);\n const stopped=Number(h.paused_count||0);\n const started=Number(h.resumed_count||0);\n const checked=Number(h.checked_count||0);\n const over=Number(details.over_limit||0);\n const stopFailed=Array.isArray(details.stop_failed)?details.stop_failed.length:0;\n acc.checked += checked;\n acc.stopped += stopped;\n acc.started += started;\n acc.overLimit += over;\n acc.stopFailed += stopFailed;\n if(over>0) acc.overEvents += 1;\n return acc;\n },{checked:0,stopped:0,started:0,overLimit:0,overEvents:0,stopFailed:0});\n const latest=hist[0]||null;\n return {...stats,total:Number(totalHistory||hist.length||0),sample:hist.length,latestEvent:latest?.event||'-',latestAt:latest?.created_at||''};\n }\n\n function renderSmartQueueNerdStats(stats){\n // Note: Compact cards keep the extra diagnostics readable above Automation history without changing the history table.\n if(!stats) return '
No Smart Queue stats yet.
';\n const cards=[\n ['Runs',stats.total,`${stats.sample} loaded`],\n ['Checked',stats.checked,'torrent scans'],\n ['Stopped',stats.stopped,'queue trims'],\n ['Started',stats.started,'queue fills'],\n ['Over limit',stats.overEvents,`${stats.overLimit} total over`],\n ['Stop failed',stats.stopFailed,'rTorrent rejects'],\n ['Latest',stats.latestEvent,stats.latestAt?dateCell(stats.latestAt):'no timestamp'],\n ];\n return `
${cards.map(([label,value,hint])=>`
${esc(label)}${esc(value)}${hint}
`).join('')}
`;\n }\n function formatDurationLeft(seconds){ seconds=Math.max(0,Math.floor(Number(seconds||0))); if(!seconds) return \"ready\"; const m=Math.floor(seconds/60), s=seconds%60; return m?`${m}m ${String(s).padStart(2,\"0\")}s`:`${s}s`; }\n function updateCooldownBadge(id, seconds){\n const el=$(id); if(!el) return;\n const value=Math.max(0,Math.floor(Number(seconds||0)));\n el.dataset.seconds=String(value);\n el.textContent=`next: ${formatDurationLeft(value)}`;\n }\n function tickCooldowns(){\n document.querySelectorAll(\".cooldown-live\").forEach(el=>{\n let v=Math.max(0,Number(el.dataset.seconds||0));\n if(v>0){ v-=1; el.dataset.seconds=String(v); }\n el.textContent=`next: ${formatDurationLeft(v)}`;\n });\n }\n setInterval(tickCooldowns,1000);\n\n function smartQueueTorrentLabel(t){\n const bits=[t.name || t.hash, t.label ? `label: ${t.label}` : '', t.status || '', t.size_h || ''].filter(Boolean);\n return bits.join(' \u00b7 ');\n }\n function smartQueueExcludedSet(){\n return new Set([...document.querySelectorAll('.smart-exclusion-choice:checked')].map(input=>input.value).filter(Boolean));\n }\n function renderSmartQueueExclusionChoices(exclusions=[]){\n const list=$('smartExclusionChoiceList');\n if(!list) return;\n const excluded=new Set((exclusions||[]).map(x=>String(x.torrent_hash||'')));\n selectedHashes().forEach(hash=>excluded.add(String(hash)));\n const rows=[...torrents.values()].sort((a,b)=>String(a.name||'').localeCompare(String(b.name||'')));\n const fallback=(exclusions||[])\n .filter(x=>x.torrent_hash && !torrents.has(x.torrent_hash))\n .map(x=>({hash:x.torrent_hash,name:`Missing from current list: ${x.torrent_hash}`,label:x.reason||'manual exception'}));\n const all=[...rows, ...fallback];\n list.innerHTML=all.length ? all.map(t=>{\n const hash=String(t.hash||'');\n const checked=excluded.has(hash) ? 'checked' : '';\n return ``;\n }).join('') : '
No torrents are loaded for this profile.
';\n filterSmartQueueExclusionChoices();\n }\n function filterSmartQueueExclusionChoices(){\n const query=($('smartExclusionSearch')?.value||'').trim().toLowerCase();\n document.querySelectorAll('.smart-exclusion-choice-row').forEach(row=>{\n row.classList.toggle('d-none', query && !row.textContent.toLowerCase().includes(query));\n });\n }\n async function openSmartQueueExclusionModal(){\n await loadSmartQueue();\n const modalEl=$('smartExclusionModal');\n if(!modalEl) return;\n const current=await fetch('/api/smart-queue?history_limit=1').then(r=>r.json()).catch(()=>({exclusions:[]}));\n renderSmartQueueExclusionChoices(current.exclusions||[]);\n $('smartExclusionSearch')?.focus();\n bootstrap.Modal.getOrCreateInstance(modalEl).show();\n }\n async function saveSmartQueueExclusionChoices(){\n const current=await fetch('/api/smart-queue?history_limit=1').then(r=>r.json()).catch(()=>({exclusions:[]}));\n const before=new Set((current.exclusions||[]).map(x=>String(x.torrent_hash||'')));\n const after=smartQueueExcludedSet();\n const add=[...after].filter(hash=>!before.has(hash));\n const remove=[...before].filter(hash=>!after.has(hash));\n if(!add.length && !remove.length){\n bootstrap.Modal.getInstance($('smartExclusionModal'))?.hide();\n return toast('Smart Queue exceptions unchanged','secondary');\n }\n setBusy(true);\n try{\n for(const hash of add) await post('/api/smart-queue/exclusion',{hash,excluded:true,reason:'manual'});\n for(const hash of remove) await post('/api/smart-queue/exclusion',{hash,excluded:false,reason:'manual'});\n bootstrap.Modal.getInstance($('smartExclusionModal'))?.hide();\n toast('Smart Queue exceptions saved','success');\n await loadSmartQueue();\n }catch(e){\n toast(e.message,'danger');\n }finally{\n setBusy(false);\n }\n }\n async function loadSmartQueue(){\n if($('smartManager')) $('smartManager').innerHTML=loadingMarkup('Loading Smart Queue...');\n if($('smartHistory')) $('smartHistory').innerHTML=loadingMarkup('Loading Smart Queue history...');\n const historyLimit=smartHistoryExpanded?100:10;\n const j=await (await fetch(`/api/smart-queue?history_limit=${historyLimit}`)).json();\n if(!j.ok) return;\n const st=j.settings||{}, ex=j.exclusions||[], hist=j.history||[];\n const totalHistory=Number(j.history_total ?? hist.length);\n if($('smartEnabled')) $('smartEnabled').checked=!!st.enabled;\n if($('smartMaxActive')) $('smartMaxActive').value=st.max_active_downloads||5;\n if($('smartStalled')) $('smartStalled').value=st.stalled_seconds||300;\n if($('smartStopBatch')) $('smartStopBatch').value=st.stop_batch_size||50;\n if($('smartStartGrace')) $('smartStartGrace').value=st.start_grace_seconds||900;\n if($('smartProtectActiveBelowCap')) $('smartProtectActiveBelowCap').checked=st.protect_active_below_cap!==0;\n if($('smartAutoStopIdle')) $('smartAutoStopIdle').checked=!!st.auto_stop_idle;\n if($('smartMinSpeed')) $('smartMinSpeed').value=Math.round((st.min_speed_bytes||0)/1024);\n if($('smartMinSeeds')) $('smartMinSeeds').value=st.min_seeds||1;\n if($('smartMinPeers')) $('smartMinPeers').value=st.min_peers||0;\n if($('smartIgnoreSeedPeer')) $('smartIgnoreSeedPeer').checked=!!st.ignore_seed_peer;\n if($('smartIgnoreSpeed')) $('smartIgnoreSpeed').checked=!!st.ignore_speed;\n if($('smartCooldown')) $('smartCooldown').value=st.cooldown_minutes||10;\n const refillMode=!Number(st.refill_enabled ?? 1) ? 'off' : (Number(st.refill_interval_minutes||0)>0 ? 'custom' : 'auto');\n if($('smartRefillMode')) $('smartRefillMode').value=refillMode;\n if($('smartRefillInterval')) $('smartRefillInterval').value=Number(st.refill_interval_minutes||0)>0 ? st.refill_interval_minutes : 5;\n updateSmartRefillControls();\n updateCooldownBadge('smartCooldownBadge', Number(j.cooldown_remaining_seconds||0));\n if($('smartCooldownHint')) $('smartCooldownHint').textContent=st.enabled ? `Automatic run every ${st.cooldown_minutes||10} minute(s). Manual check ignores cooldown.` : 'Smart Queue is disabled; timer starts after it is enabled and runs once.';\n if($('smartRefillHint')) $('smartRefillHint').textContent=smartRefillHintText(refillMode, Number(st.refill_interval_minutes||0), Number(j.refill_remaining_seconds||0));\n if($('smartManager')){\n const nameForHash=hash=>torrents.get(hash)?.name || hash;\n $('smartManager').innerHTML=ex.length\n ? responsiveTable(['Torrent','Hash','Reason','Created','Action'],ex.map(x=>[esc(nameForHash(x.torrent_hash)),esc(x.torrent_hash),esc(x.reason||''),dateCell(x.created_at),``]),'smart-exclusions-table')\n : '
No Smart Queue exceptions. Use Manage exceptions to choose torrents ignored by Smart Queue.
';\n }\n if($('smartHistory')){\n const body=hist.length\n ? responsiveTable(['Time','Event','Checked','Active','Limit','Over','Stopped','Requested','Verified','Pending','Stalled'],hist.map(h=>{\n // Note: Pending and Stalled are separate audit columns so delayed starts and stopped stalled torrents are visible independently.\n const d=smartHistoryDetails(h);\n const activeBefore=d.active_before ?? '-';\n const activeAfter=d.active_after_expected ?? d.active_after_stop ?? '-';\n const limit=d.max_active_downloads ?? '-';\n const requested=Number(d.start_requested_count ?? (d.start_requested||[]).length ?? 0);\n const verified=Number(d.active_verified_count ?? (d.active_verified||[]).length ?? 0);\n const pending=Number(d.pending_confirmation_count ?? (d.start_pending_confirmation||[]).length ?? 0);\n const stalledDetected=Number(d.stalled_detected||0);\n const stalledStopped=Number(d.stalled_stopped||0);\n const stalledProtected=Number(d.protected_stalled||0);\n const stalledText=stalledDetected?`${stalledStopped}/${stalledDetected}${stalledProtected?` protected ${stalledProtected}`:''}`:'-';\n return [dateCell(h.created_at),esc(h.event||d.decision||'-'),esc(h.checked_count||d.checked||0),esc(`${activeBefore}->${activeAfter}`),esc(limit),esc(d.over_limit||0),esc(h.paused_count||0),esc(requested),esc(verified),esc(pending||'-'),esc(stalledText)];\n }),'smart-history-table')\n : '
No Smart Queue operations yet.
';\n const canToggle=totalHistory>10;\n const toggle=canToggle?``:'';\n const clear=totalHistory?``:'';\n $('smartHistory').innerHTML=`${body}${toggle}${clear}`;\n }\n }\n function smartRefillHintText(mode, minutes, remainingSeconds){\n // Note: Refill mode controls only the lightweight slot top-up during cooldown, not the full Smart Queue pass.\n if(mode==='off') return 'Refill is disabled. Smart Queue will only fill slots during full checks or manual checks.';\n if(mode==='custom'){\n const wait=Number(remainingSeconds||0)>0 ? ` Next refill in ${formatDurationLeft(remainingSeconds)}.` : '';\n return `Refill runs at most every ${Math.max(1, Number(minutes||5))} minute(s) while Smart Queue is in cooldown.${wait}`;\n }\n return 'Refill uses the current automatic poller cadence during cooldown, usually about every 2 minutes.';\n }\n function updateSmartRefillControls(){\n const mode=$('smartRefillMode')?.value||'auto';\n const interval=$('smartRefillInterval');\n if(interval) interval.disabled=mode!=='custom';\n }\n async function setSmartException(hashes, excluded, reason='manual'){ const list=[...new Set(hashes||[])].filter(Boolean); if(!list.length) return toast('No torrents selected','warning'); setBusy(true); try{ for(const h of list) await post('/api/smart-queue/exclusion',{hash:h,excluded,reason}); toast(excluded?'Smart Queue exception added':'Smart Queue exception removed','success'); await loadSmartQueue(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }\n async function saveSmartQueue(){ await post('/api/smart-queue',{enabled:$('smartEnabled')?.checked,max_active_downloads:$('smartMaxActive')?.value,stalled_seconds:$('smartStalled')?.value,stop_batch_size:$('smartStopBatch')?.value||50,start_grace_seconds:$('smartStartGrace')?.value||900,protect_active_below_cap:$('smartProtectActiveBelowCap')?.checked,auto_stop_idle:$('smartAutoStopIdle')?.checked,min_speed_bytes:Math.round(Number($('smartMinSpeed')?.value||0)*1024),min_seeds:$('smartMinSeeds')?.value,min_peers:$('smartMinPeers')?.value,ignore_seed_peer:$('smartIgnoreSeedPeer')?.checked,ignore_speed:$('smartIgnoreSpeed')?.checked,cooldown_minutes:$('smartCooldown')?.value||10,refill_mode:$('smartRefillMode')?.value||'auto',refill_interval_minutes:$('smartRefillInterval')?.value||5}); toast('Smart Queue saved','success'); await loadSmartQueue(); }\n\n function renderGeneratedToken(token){\n const box=$('authTokenInline');\n if(!box) return;\n // Note: Generated tokens are shown inline to avoid stacking another modal over the Users panel.\n box.classList.remove('d-none');\n box.innerHTML=`
New API tokenThis token is shown once. Copy it now before refreshing the page.
`;\n $('authTokenInlineCopy')?.addEventListener('click',()=>copyText(token).then(()=>toast('API token copied','success')).catch(()=>toast('Copy failed','danger')));\n $('authTokenInlineClose')?.addEventListener('click',()=>box.classList.add('d-none'));\n }\n function tokenRow(t,userId){\n const last=t.last_used_at ? humanDateCell(t.last_used_at) : 'never';\n return `
${esc(t.name||'API token')}${esc(t.token_prefix||'')} \u00b7 created ${humanDateCell(t.created_at)} \u00b7 last used ${last}
`;\n }\n async function showAuthTokens(userId){\n try{\n const j=await (await fetch(`/api/auth/users/${userId}/tokens`)).json();\n if(!j.ok) throw new Error(j.error||'Cannot load API tokens');\n const box=$('authTokenInline');\n if(!box) return;\n // Note: Token lists stay inline in Users to keep user management fast and avoid nested modals.\n const tokens=j.tokens||[];\n box.classList.remove('d-none');\n box.innerHTML=`
API tokensActive and revoked tokens for this user. Secrets are never shown after creation.
${tokens.length ? tokens.map(t=>tokenRow(t,userId)).join('') : '
No API tokens.
'}`;\n $('authTokenInlineClose')?.addEventListener('click',()=>box.classList.add('d-none'));\n box.querySelectorAll('.auth-token-delete').forEach(btn=>btn.addEventListener('click',async()=>{ if(!confirm('Delete this API token?')) return; await deleteAuthToken(btn.dataset.userId, btn.dataset.tokenId); await showAuthTokens(btn.dataset.userId); }));\n }catch(e){ toast(e.message,'danger'); }\n }\n async function deleteAuthToken(userId, tokenId){\n // Note: Token revocation uses the existing DELETE API and refreshes both token and user counts.\n const j=await post(`/api/auth/users/${userId}/tokens/${tokenId}`, {}, 'DELETE');\n toast('API token deleted','success');\n await loadAuthUsers();\n return j;\n }\n async function loadAuthUsers(){\n if(!window.PYTORRENT.authEnabled || !$('authUsersManager')) return;\n const [usersRes, profilesRes]=await Promise.all([fetch('/api/auth/users'), fetch('/api/profiles')]);\n const usersJson=await usersRes.json();\n const profilesJson=await profilesRes.json();\n const profiles=profilesJson.profiles||[];\n if($('authProfile')) $('authProfile').innerHTML=``+profiles.map(p=>``).join('');\n const rows=(usersJson.users||[]).map(u=>{\n const perms=(u.permissions||[]).map(p=>`${p.profile_id?('profile '+p.profile_id):'all'}: ${p.access_level==='full'?'Full':'R/O'}`).join(', ') || (u.role==='admin'?'all: Full':'none');\n const tokenText=(u.api_tokens||0) ? `${u.api_tokens} active` : 'none';\n const actions=` `;\n return [esc(u.username),esc(u.role),u.is_active?'yes':'no',esc(perms),``,actions];\n });\n $('authUsersManager').innerHTML=rows.length?table(['User','Role','Active','Profile rights','API tokens','Actions'],rows):'
No users.
';\n }\n async function generateAuthToken(userId){\n const name=prompt('Token name', 'API token');\n if(name===null) return;\n try{\n const j=await post(`/api/auth/users/${userId}/tokens`, {name:name||'API token'});\n const token=j.token?.token||'';\n renderGeneratedToken(token);\n await copyText(token).then(()=>toast('API token copied','success')).catch(()=>toast('Copy the API token from the Users panel','warning'));\n await loadAuthUsers();\n }catch(e){ toast(e.message,'danger'); }\n }\n function resetAuthUserForm(){ ['authUserId','authUsername','authPassword'].forEach(id=>{ if($(id)) $(id).value=''; }); if($('authRole')) $('authRole').value='user'; if($('authProfile')) $('authProfile').value='0'; if($('authAccess')) $('authAccess').value='ro'; if($('authActive')) $('authActive').checked=true; $('authUserCancelBtn')?.classList.add('d-none'); }\n function editAuthUser(user){ if(!user) return; if($('authUserId')) $('authUserId').value=user.id||''; if($('authUsername')) $('authUsername').value=user.username||''; if($('authPassword')) $('authPassword').value=''; if($('authRole')) $('authRole').value=user.role||'user'; if($('authActive')) $('authActive').checked=!!user.is_active; const perm=(user.permissions||[])[0]||{profile_id:0,access_level:'ro'}; if($('authProfile')) $('authProfile').value=String(perm.profile_id||0); if($('authAccess')) $('authAccess').value=perm.access_level||'ro'; $('authUserCancelBtn')?.classList.remove('d-none'); }\n async function saveAuthUser(){\n const id=$('authUserId')?.value||'';\n const role=$('authRole')?.value||'user';\n const payload={username:$('authUsername')?.value||'',password:$('authPassword')?.value||'',role,is_active:!!$('authActive')?.checked,permissions:role==='admin'?[]:[{profile_id:Number($('authProfile')?.value||0),access_level:$('authAccess')?.value||'ro'}]};\n try{ await post(id?`/api/auth/users/${id}`:'/api/auth/users',payload,id?'PUT':'POST'); toast('User saved','success'); resetAuthUserForm(); await loadAuthUsers(); }catch(e){ toast(e.message,'danger'); }\n }\n function normalizeRtConfigValue(value, type='text'){\n const raw=String(value ?? '').trim();\n if(type==='bool') return ['1','true','yes','on'].includes(raw.toLowerCase()) ? '1' : '0';\n if(type==='number'){\n if(raw==='') return '0';\n const normalized=Number(raw.replace(',', '.'));\n return Number.isFinite(normalized) ? String(Math.trunc(normalized)) : raw;\n }\n return raw;\n }\n function rtConfigInputValue(input){\n const type=input.dataset.type || rtConfigFieldTypes.get(input.dataset.key) || 'text';\n const value=type==='bool' && input.type==='checkbox' ? (input.checked?'1':'0') : input.value;\n return normalizeRtConfigValue(value, type);\n }\n function rtConfigOriginalValue(input){\n const key=input.dataset.key;\n return normalizeRtConfigValue(input.dataset.original ?? rtConfigOriginal.get(key), input.dataset.type || rtConfigFieldTypes.get(key) || 'text');\n }\n function collectRtConfigChanges(){\n const values={};\n document.querySelectorAll('.rt-config-input').forEach(input=>{\n if(input.disabled) return;\n const cur=rtConfigInputValue(input);\n const orig=rtConfigOriginalValue(input);\n if(cur!==orig) values[input.dataset.key]=cur;\n });\n return values;\n }\n function collectRtConfigClearKeys(){\n const keys=[];\n document.querySelectorAll('.rt-config-input').forEach(input=>{\n if(input.disabled || input.dataset.saved!=='true') return;\n const cur=rtConfigInputValue(input);\n const orig=rtConfigOriginalValue(input);\n if(cur===orig) keys.push(input.dataset.key);\n });\n return keys;\n }\n function updateRtConfigDirty(){\n const changed=collectRtConfigChanges();\n const clearKeys=collectRtConfigClearKeys();\n document.querySelectorAll('.rt-config-input').forEach(input=>{\n const row=input.closest('.rt-config-row');\n if(row) row.classList.toggle('changed', Object.prototype.hasOwnProperty.call(changed,input.dataset.key));\n });\n const configChanges=Object.keys(changed).length;\n const applyChanged=!!$('rtConfigApplyOnStart') && $('rtConfigApplyOnStart').checked!==rtConfigOriginalApplyOnStart;\n const total=configChanges + clearKeys.length + (applyChanged ? 1 : 0);\n if($('rtConfigChangedCount')) $('rtConfigChangedCount').textContent=total?`${total} changed`:'No changes';\n if($('rtConfigGenerateBtn')) $('rtConfigGenerateBtn').disabled=!configChanges;\n if($('rtConfigSaveBtn')) $('rtConfigSaveBtn').disabled=!total;\n }\n async function loadRtConfig(){\n const box=$('rtConfigManager');\n if(!box)return;\n box.innerHTML=' Loading config...';\n try{\n const j=await (await fetch('/api/rtorrent-config')).json();\n if(!j.ok) throw new Error(j.error||'Config load failed');\n const fields=j.config?.fields||[];\n rtConfigOriginal=new Map();\n rtConfigFieldTypes=new Map();\n rtConfigOriginalApplyOnStart=!!j.config?.apply_on_start;\n let lastGroup='';\n const html=fields.map(f=>{\n const group=f.group||'Other';\n const head=group!==lastGroup?`
${esc(group)}
`:'';\n lastGroup=group;\n const disabled=(!f.ok||f.readonly)?'disabled':'';\n const type=['bool','number'].includes(f.type)?f.type:'text';\n const originalValue=normalizeRtConfigValue(f.baseline_value ?? f.current_value ?? f.value, type);\n const displayValue=normalizeRtConfigValue(f.saved ? f.saved_value : (f.value ?? f.current_value), type);\n rtConfigOriginal.set(f.key, originalValue);\n rtConfigFieldTypes.set(f.key, type);\n const note=f.ok?(f.readonly?' \u00b7 read only':(f.saved?' \u00b7 saved override \u00b7 reference kept':'')):' \u00b7 unavailable';\n const valueNote=f.saved?`Reference: ${esc(originalValue)} \u2192 saved: ${esc(displayValue)}`:'';\n const originalAttr=esc(originalValue);\n const input=type==='bool'\n ? `${displayValue==='1'?'On':'Off'}`\n : ``;\n return `${head}`;\n }).join('');\n box.innerHTML=`
${html}
`;\n if($('rtConfigApplyOnStart')) $('rtConfigApplyOnStart').checked=rtConfigOriginalApplyOnStart;\n updateRtConfigDirty();\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n async function saveRtConfig(){\n const values=collectRtConfigChanges();\n const clear_keys=collectRtConfigClearKeys();\n clear_keys.forEach(key=>{\n const input=document.querySelector(`.rt-config-input[data-key=\"${CSS.escape(key)}\"]`);\n if(input) values[key]=rtConfigOriginalValue(input);\n });\n setBusy(true);\n try{\n const j=await post('/api/rtorrent-config',{values,clear_keys,apply_on_start:!!$('rtConfigApplyOnStart')?.checked,apply_now:true});\n toast(`rTorrent config saved (${j.result?.updated?.length||0})`,'success');\n await loadRtConfig();\n }catch(e){\n toast(e.message,'danger');\n } finally{\n setBusy(false);\n }\n }\n async function resetRtConfig(){\n // Note: Reset clears only saved UI overrides, then reloads the live state from rTorrent.\n if(!confirm('Clear all saved rTorrent UI overrides and reload current rTorrent values?')) return;\n setBusy(true);\n try{\n const j=await post('/api/rtorrent-config/reset',{});\n toast(`rTorrent config reset (${j.config?.reset_removed||0} override(s) removed)`,'success');\n await loadRtConfig();\n }catch(e){\n toast(e.message,'danger');\n } finally{\n setBusy(false);\n }\n }\n async function generateRtConfig(){ const values=collectRtConfigChanges(); try{ const res=await fetch('/api/rtorrent-config/generate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({values})}); const j=await res.json(); if(!j.ok) throw new Error(j.error||'Generate failed'); if($('rtConfigOutput')) $('rtConfigOutput').value=j.config_text||''; toast('Config generated','success'); }catch(e){ toast(e.message,'danger'); } }\n\n function bootstrapThemeUrl(theme){ /* Note: Themes use the URL map generated by the backend, so they also work offline. */ const key=theme||\"default\"; return window.PYTORRENT?.bootstrapThemeUrls?.[key] || window.PYTORRENT?.bootstrapThemeUrls?.default || \"\"; }\n function applyBootstrapTheme(theme){ bootstrapTheme = theme || \"default\"; const link=$(\"bootstrapThemeStylesheet\"); if(link) link.href = bootstrapThemeUrl(bootstrapTheme); if($(\"bootstrapThemeSelect\")) $(\"bootstrapThemeSelect\").value = bootstrapTheme; }\n function applyFontFamily(font){ fontFamily = font || \"default\"; document.documentElement.dataset.appFont = fontFamily; if($(\"fontFamilySelect\")) $(\"fontFamilySelect\").value = fontFamily; }\n function clampInterfaceScale(value){ value = Number(value || 100); if(!Number.isFinite(value)) value = 100; return Math.max(80, Math.min(140, Math.round(value / 5) * 5)); }\n function applyInterfaceScale(value){ interfaceScale = clampInterfaceScale(value); document.documentElement.style.setProperty(\"--ui-scale\", String(interfaceScale / 100)); if($(\"interfaceScaleRange\")) $(\"interfaceScaleRange\").value = interfaceScale; if($(\"interfaceScaleValue\")) $(\"interfaceScaleValue\").textContent = `${interfaceScale}%`; scheduleRender(false); }\n async function saveAppearancePreferences(){ applyBootstrapTheme($(\"bootstrapThemeSelect\")?.value || \"default\"); applyFontFamily($(\"fontFamilySelect\")?.value || \"default\"); applyInterfaceScale($(\"interfaceScaleRange\")?.value || interfaceScale); try{ await post(\"/api/preferences\",{bootstrap_theme:bootstrapTheme,font_family:fontFamily,interface_scale:interfaceScale}); toast(\"Appearance preferences saved\",\"success\"); }catch(e){ toast(e.message,\"danger\"); } }\n if($(\"titleSpeedEnabled\")) $(\"titleSpeedEnabled\").checked=titleSpeedEnabled;\n\n function setupPeersRefresh(tab=activeTab()){ clearInterval(peersRefreshTimer); peersRefreshTimer=null; if($('peersRefreshSelect')) $('peersRefreshSelect').value=String(peersRefreshSeconds||0); if(tab==='peers' && peersRefreshSeconds>0){ peersRefreshTimer=setInterval(()=>{ if(activeTab()==='peers' && selectedHash) loadDetails('peers'); }, peersRefreshSeconds*1000); } }\n function syncMobileMode(){ const auto=window.matchMedia&&window.matchMedia(\"(max-width: 900px)\").matches; document.body.classList.toggle(\"mobile-mode\", auto || document.body.classList.contains(\"mobile-mode-manual\")); scheduleRender(true); }\n\n\n let automationRulesCache=[];\n let automationConditions=[];\n let automationEffects=[];\n\n function automationCondition(){\n const type=$('autoConditionType')?.value||'completed';\n const cond={type, negate:!!$('autoCondNegate')?.checked};\n if(type==='no_seeds'){ cond.seeds=Number($('autoCondSeeds')?.value||0); cond.minutes=Number($('autoCondMinutes')?.value||0); }\n if(type==='ratio_gte') cond.ratio=Number($('autoCondRatio')?.value||1);\n // Note: Progress conditions compare the torrent completion percentage stored in the live torrent row.\n if(type==='progress_gte'||type==='progress_lte') cond.progress=Number($('autoCondProgress')?.value||0);\n if(type==='label_missing'||type==='label_has') cond.label=$('autoCondLabel')?.value||'';\n if(type==='status') cond.status=$('autoCondStatus')?.value||'Seeding';\n if(type==='path_contains') cond.text=$('autoCondText')?.value||'';\n return cond;\n }\n\n function automationEffect(){\n const type=$('autoEffectType')?.value||'add_label';\n const eff={type};\n if(type==='move'){\n eff.path=$('autoEffectPath')?.value||'';\n eff.move_data=!!$('autoMoveData')?.checked;\n eff.recheck=!!$('autoMoveRecheck')?.checked;\n eff.keep_seeding=!!$('autoMoveKeepSeeding')?.checked;\n }\n if(type==='add_label'||type==='remove_label') eff.label=$('autoEffectLabel')?.value||'';\n if(type==='set_labels') eff.labels=$('autoEffectLabels')?.value||'';\n return eff;\n }\n\n function updateAutomationForm(){\n const ct=$('autoConditionType')?.value||'';\n document.querySelectorAll('[data-auto-cond]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoCond.split(',').includes(ct)));\n const et=$('autoEffectType')?.value||'';\n document.querySelectorAll('[data-auto-effect]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoEffect.split(',').includes(et)));\n }\n\n function conditionText(c={}){\n const base=c.type==='no_seeds'?`seeds <= ${c.seeds||0} for ${c.minutes||0} min`:c.type==='ratio_gte'?`ratio >= ${c.ratio}`:c.type==='progress_gte'?`progress >= ${c.progress||0}%`:c.type==='progress_lte'?`progress <= ${c.progress||0}%`:c.type==='label_missing'?`missing label ${c.label||''}`:c.type==='label_has'?`has label ${c.label||''}`:c.type==='status'?`status = ${c.status||''}`:c.type==='path_contains'?`path contains ${c.text||''}`:'completed';\n return c.negate?`NOT (${base})`:base;\n }\n function effectText(e={}){\n if(e.type==='move'){\n const flags=[];\n if(e.move_data) flags.push('move data');\n if(e.recheck) flags.push('recheck');\n if(e.keep_seeding) flags.push('keep seeding');\n return `move to ${e.path||'default path'}${flags.length?` (${flags.join(', ')})`:''}`;\n }\n return e.type==='add_label'?`add label ${e.label||''}`:e.type==='remove_label'?`remove label ${e.label||''}`:e.type==='set_labels'?`set labels ${e.labels||''}`:e.type;\n }\n function ruleSummary(r){\n const cs=(r.conditions||[]).map(conditionText).join(' + ')||'no conditions';\n const es=(r.effects||[]).map(effectText).join(' \u2192 ')||'no actions';\n return `${cs} \u2192 ${es}`;\n }\n\n function renderAutomationBuilder(){\n const cBox=$('automationConditionList');\n if(cBox) cBox.innerHTML=automationConditions.length?automationConditions.map((c,i)=>`IF ${esc(conditionText(c))}`).join(''):'No conditions added yet.';\n const eBox=$('automationEffectList');\n if(eBox) eBox.innerHTML=automationEffects.length?automationEffects.map((e,i)=>`${i+1} ${esc(effectText(e))}`).join(''):'No actions added yet.';\n }\n function resetAutomationForm(){\n if($('autoEditId')) $('autoEditId').value='';\n if($('autoName')) $('autoName').value='';\n if($('autoEnabled')) $('autoEnabled').checked=true;\n if($('autoCooldown')) $('autoCooldown').value='60';\n automationConditions=[]; automationEffects=[];\n $('automationCancelEditBtn')?.classList.add('d-none');\n if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Save rule';\n renderAutomationBuilder(); updateAutomationForm();\n }\n function editAutomationRule(rule){\n if(!rule) return;\n if($('autoEditId')) $('autoEditId').value=rule.id||'';\n if($('autoName')) $('autoName').value=rule.name||'';\n if($('autoEnabled')) $('autoEnabled').checked=!!rule.enabled;\n if($('autoCooldown')) $('autoCooldown').value=rule.cooldown_minutes ?? 60;\n automationConditions=Array.isArray(rule.conditions)?JSON.parse(JSON.stringify(rule.conditions)):[];\n automationEffects=Array.isArray(rule.effects)?JSON.parse(JSON.stringify(rule.effects)):[];\n $('automationCancelEditBtn')?.classList.remove('d-none');\n if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Update rule';\n renderAutomationBuilder();\n }\n\n function summarizeActionObject(a={}){\n if(a.error) return `${esc(a.error)}`;\n const count=a.count || a.result?.count || a.result?.results?.length || '';\n const parts=[];\n if(a.type) parts.push(a.type);\n if(count) parts.push(`${count} torrent(s)`);\n if(a.path) parts.push(a.path);\n if(a.label) parts.push(`label ${a.label}`);\n if(a.labels) parts.push(`labels ${a.labels}`);\n if(a.move_data) parts.push('move data');\n if(a.recheck) parts.push('recheck');\n if(a.keep_seeding) parts.push('keep seeding');\n return `${esc(parts.join(' \u00b7 ')||'action')}`;\n }\n function automationHistoryActions(raw){\n let actions=[];\n try{ actions=JSON.parse(raw||'[]'); }catch(e){ return `
${esc(raw||'')}
`; }\n if(!Array.isArray(actions)) actions=[actions];\n const summary=actions.map(summarizeActionObject).join(' ');\n const details=esc(JSON.stringify(actions,null,2));\n // Note: Large automation payloads are collapsed so JSON never stretches the modal width.\n return `
${summary||'No actions'}
${details}
`;\n }\n\n function renderAutomationHistory(hist=[]){\n if(!$('automationHistory')) return;\n const toolbar='
';\n const rows=hist.map(h=>[humanDateCell(h.created_at),esc(h.rule_name||''),esc(h.torrent_name||h.torrent_hash||''),automationHistoryActions(h.actions_json||'')]);\n // Note: Automation history uses the shared responsive table wrapper so it stays inside narrow mobile modals.\n const body=hist.length?responsiveTable(['Time','Rule','Torrent / batch','Actions'],rows,'automation-history-table'):'
No automation history yet.
';\n $('automationHistory').innerHTML=toolbar+body;\n }\n\n async function clearAutomationHistory(){\n if(!confirm('Clear automation history?')) return;\n setBusy(true);\n try{ const j=await fetch('/api/automations/history',{method:'DELETE'}).then(r=>r.json()); if(!j.ok) throw new Error(j.error||'Clear automation history failed'); toast(`Automation logs deleted: ${j.deleted||0}`,'success'); renderAutomationHistory(j.history||[]); }\n catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n async function exportAutomations(){\n try{ const j=await (await fetch('/api/automations/export')).json(); if(!j.ok) throw new Error(j.error||'Automation export failed'); downloadJson(`pytorrent-automation-rules-${new Date().toISOString().slice(0,10)}.json`, j.export||j); toast(`Exported ${j.count||0} automation rule(s)`,'success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n\n async function importAutomations(file){\n if(!file) return;\n try{ const payload=JSON.parse(await file.text()); const j=await post('/api/automations/import',payload); toast(`Imported ${j.imported||0} automation rule(s)`,'success'); await loadAutomations(); }\n catch(e){ toast(e.message||'Automation import failed','danger'); }\n finally{ if($('automationImportFile')) $('automationImportFile').value=''; }\n }\n\n async function loadAutomations(){\n const j=await fetch('/api/automations').then(r=>r.json());\n const rules=j.rules||[], hist=j.history||[];\n automationRulesCache=rules;\n if($('automationManager')) $('automationManager').innerHTML=rules.length?rules.map(r=>{\n const enabled=!!r.enabled;\n const toggleTitle=enabled?'Disable automation':'Enable automation';\n const toggleIcon=enabled?'fa-toggle-on':'fa-toggle-off';\n const toggleClass=enabled?'btn-outline-warning':'btn-outline-success';\n return `
${esc(r.name)} ${enabled?'on':'off'}
${esc(ruleSummary(r))} \u00b7 cooldown ${esc(r.cooldown_minutes||0)} min
`;\n }).join(''):'
No automation rules.
';\n renderAutomationHistory(hist);\n }\n\n async function toggleAutomationRule(rule){\n if(!rule) return;\n const payload={...rule, enabled:!rule.enabled};\n // Note: Toggle keeps the rule definition unchanged and only switches automatic execution on or off.\n setBusy(true);\n try{ await post('/api/automations',payload); toast(payload.enabled?'Automation enabled':'Automation disabled','success'); await loadAutomations(); }\n catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n async function saveAutomation(){\n const currentCond=automationCondition();\n const currentEff=automationEffect();\n const conditions=automationConditions.length?automationConditions:[currentCond];\n const effects=automationEffects.length?automationEffects:[currentEff];\n const payload={id:Number($('autoEditId')?.value||0)||undefined,name:$('autoName')?.value||'Automation rule',enabled:!!$('autoEnabled')?.checked,cooldown_minutes:Number($('autoCooldown')?.value||60),conditions,effects};\n setBusy(true);\n try{ await post('/api/automations',payload); toast(payload.id?'Automation rule updated':'Automation rule saved','success'); resetAutomationForm(); await loadAutomations(); }\n catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n\n\n\n function cleanupCountCard(label, value, note=''){\n return `
${esc(label)}${esc(value ?? 0)}${note?`${esc(note)}`:''}
`;\n }\n function renderCleanup(data={}){\n const box=$('cleanupManager'); if(!box) return;\n const retention=data.retention_days||{};\n const db=data.database||{};\n const cache=data.cache||{};\n const cards=[\n cleanupCountCard('Job logs total', data.jobs_total, `retention ${retention.jobs||'-'} days`),\n cleanupCountCard('Job logs clearable', data.jobs_clearable, 'done / failed / cancelled'),\n cleanupCountCard('Smart Queue logs', data.smart_queue_history_total, `retention ${retention.smart_queue_history||'-'} days`),\n cleanupCountCard('Planner logs', data.planner_history_total, `retention ${retention.planner_history||'-'} days`),\n cleanupCountCard('Automation logs', data.automation_history_total, `retention ${retention.automation_history||'-'} days`),\n cleanupCountCard('Profile cache rows', cache.profile_rows ?? 0, 'tracker + torrent stats cache'),\n cleanupCountCard('Runtime cache', cache.runtime_items ?? 0, 'memory-only profile cache'),\n cleanupCountCard('Database size', db.size_h||db.size||'-', db.path||'')\n ];\n box.innerHTML=`
${cards.join('')}
Profile cacheClears only the active profile runtime/DB cache. It does not remove torrents, rules, settings or logs.
Logs and historyPending and running jobs are preserved. Automation cleanup removes only history, not saved rules.
`;\n }\n async function loadCleanup(){\n const box=$('cleanupManager'); if(!box) return;\n box.innerHTML=' Loading cleanup data...';\n try{\n const j=await (await fetch('/api/cleanup/summary')).json();\n if(!j.ok) throw new Error(j.error||'Cleanup summary failed');\n renderCleanup(j.cleanup||{});\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n async function runCleanupAction(endpoint, label){\n if(!confirm(`${label}?`)) return;\n setBusy(true);\n try{\n const j=await post(endpoint,{});\n const deleted=typeof j.deleted==='object' ? Object.entries(j.deleted).map(([k,v])=>`${k}: ${v}`).join(', ') : String(j.deleted ?? 0);\n toast(`Cleanup done (${deleted})`,'success');\n renderCleanup(j.cleanup||{});\n if(endpoint.includes('/jobs')){ jobsPage=0; loadJobs(0).catch(()=>{}); }\n if(endpoint.includes('/smart-queue') || endpoint.includes('/all')) loadSmartQueue().catch(()=>{});\n if(endpoint.includes('/planner') || endpoint.includes('/all')) loadPlannerPreview().catch(()=>{});\n if(endpoint.includes('/automations') || endpoint.includes('/all')) loadAutomations().catch(()=>{});\n }catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n function diagCard(label,value,extra=''){ return `
${esc(label)}${esc(value ?? '-')}
`; }\n\n // Note: Centralizes footer visibility so Preferences can hide items without removing existing status logic.\n function applyFooterPreferences(){\n document.querySelectorAll('[data-footer-item]').forEach(el=>{\n const key=el.dataset.footerItem;\n el.classList.toggle('footer-pref-hidden', footerItems[key] === false);\n });\n }\n function renderFooterPreferences(){\n const box=$('footerPreferences');\n if(!box) return;\n box.innerHTML=FOOTER_ITEM_DEFS.map(([key,label])=>``).join('');\n }\n async function saveFooterPreferences(){\n document.querySelectorAll('.footer-pref-toggle').forEach(cb=>{ footerItems[cb.dataset.footerKey] = !!cb.checked; });\n applyFooterPreferences();\n renderFooterPreferences();\n try{ await post('/api/preferences',{footer_items_json:footerItems}); toast('Footer preferences saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n function compactSpeedText(value){\n // Note: The footer has limited space, so it removes spaces only from speed labels.\n return String(value || '0 B/s').replace(/\\s+(?=[KMGT]?i?B\\/s$|B\\/s$)/, '');\n }\n function speedPairText(down, up){\n // Note: Consistent DL/UL pair formatting is used in the footer and diagnostics.\n return `${compactSpeedText(down)} / ${compactSpeedText(up)}`;\n }\n function peakDateText(value){\n // Note: Shortens the ISO timestamp from the database into a readable tooltip label.\n return value ? String(value).replace('T',' ').replace(/\\+00:00$/, ' UTC') : '-';\n }\n function updateSpeedPeaks(peaks={}){\n // Note: Shows the session and all-time record next to current speeds in the footer.\n const session=peaks.session||{};\n const allTime=peaks.all_time||{};\n const sessionText=speedPairText(session.down_h, session.up_h);\n const allTimeText=speedPairText(allTime.down_h, allTime.up_h);\n if($('statPeakSession')) $('statPeakSession').textContent=sessionText;\n if($('statPeakAllTime')) $('statPeakAllTime').textContent=allTimeText;\n const box=$('statusSpeedPeaks');\n if(box){\n box.title=`Peak speed DL/UL\\nSession: ${sessionText}\\nSession DL at: ${peakDateText(session.down_at)}\\nSession UL at: ${peakDateText(session.up_at)}\\nAll-time: ${allTimeText}\\nAll-time DL at: ${peakDateText(allTime.down_at)}\\nAll-time UL at: ${peakDateText(allTime.up_at)}`;\n }\n }\n function browserSpeedSnapshot(){\n // Note: Browser title speed can fall back to the live torrent snapshot when system_stats is delayed or reports zero.\n let down=0, up=0;\n torrents.forEach(t=>{\n down += Number(t.down_rate || 0);\n up += Number(t.up_rate || 0);\n });\n return {down, up, down_h: humanRateLabel(down), up_h: humanRateLabel(up)};\n }\n function humanRateLabel(value){\n const units=['B/s','KiB/s','MiB/s','GiB/s','TiB/s'];\n let n=Math.max(0, Number(value || 0));\n let i=0;\n while(n>=1024 && i=10 || i===0 ? Math.round(n) : n.toFixed(1)} ${units[i]}`;\n }\n function numericSpeed(value){\n // Note: Accepts both raw bytes/s and human labels, so zero checks work for \"0\", \"0 B/s\" and \"0.0 KiB/s\".\n if(typeof value === 'number') return Math.max(0, value);\n const text=String(value ?? '').trim();\n if(!text) return 0;\n const match=text.match(/^([0-9]+(?:\\.[0-9]+)?)\\s*(B\\/s|KiB\\/s|MiB\\/s|GiB\\/s|TiB\\/s)?$/i);\n if(!match) return 0;\n const units=['B/s','KiB/s','MiB/s','GiB/s','TiB/s'];\n const unit=(match[2] || 'B/s').replace(/kib/i,'KiB').replace(/mib/i,'MiB').replace(/gib/i,'GiB').replace(/tib/i,'TiB').replace(/b\\/s/i,'B/s');\n return Number(match[1] || 0) * Math.pow(1024, Math.max(0, units.indexOf(unit)));\n }\n function applyLiveSpeedStats(stats={}){\n // Note: Fast-poller speed updates drive the tab title and peak speed UI without waiting for system_stats.\n const downRaw=Number(stats.down_rate || 0);\n const upRaw=Number(stats.up_rate || 0);\n const downH=stats.down_rate_h || humanRateLabel(downRaw);\n const upH=stats.up_rate_h || humanRateLabel(upRaw);\n if($('statDl')) $('statDl').textContent=downH || '0 B/s';\n if($('statUl')) $('statUl').textContent=upH || '0 B/s';\n if($('mobileSpeedDl')) $('mobileSpeedDl').textContent=downH || '0 B/s';\n if($('mobileSpeedUl')) $('mobileSpeedUl').textContent=upH || '0 B/s';\n if(stats.speed_peaks) updateSpeedPeaks(stats.speed_peaks);\n updateBrowserSpeedTitle(downH, upH, downRaw, upRaw);\n }\n function updateBrowserSpeedTitle(downH, upH, downRaw=null, upRaw=null){\n // Note: Keeps the browser tab title accurate even when system_stats is delayed or reports a stale zero.\n const fallback=browserSpeedSnapshot();\n const downValue=downRaw == null ? numericSpeed(downH) : Number(downRaw || 0);\n const upValue=upRaw == null ? numericSpeed(upH) : Number(upRaw || 0);\n const useFallbackDown=(downH == null || (downValue <= 0 && fallback.down>0));\n const useFallbackUp=(upH == null || (upValue <= 0 && fallback.up>0));\n lastBrowserSpeed.down=useFallbackDown ? fallback.down_h : (downH || '0 B/s');\n lastBrowserSpeed.up=useFallbackUp ? fallback.up_h : (upH || '0 B/s');\n const speedTitle=`DL ${lastBrowserSpeed.down} / UL ${lastBrowserSpeed.up}`;\n document.title=titleSpeedEnabled ? `${speedTitle} - ${BASE_TITLE}` : BASE_TITLE;\n try{ window.status=titleSpeedEnabled ? speedTitle : ''; }catch(e){}\n }\n async function saveTitleSpeedPreference(){\n // Note: The change applies immediately and is saved as a user preference.\n titleSpeedEnabled=!!$('titleSpeedEnabled')?.checked;\n updateBrowserSpeedTitle();\n try{ await post('/api/preferences',{title_speed_enabled:titleSpeedEnabled}); toast('Browser title speed saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n async function saveTrackerFaviconsPreference(){\n // Note: Tracker favicon toggle changes only icon rendering; tracker filter counts and actions stay untouched.\n trackerFaviconsEnabled=!!$('trackerFaviconsEnabled')?.checked;\n renderTrackerFilters();\n try{ await post('/api/preferences',{tracker_favicons_enabled:trackerFaviconsEnabled}); toast('Tracker favicon preference saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n function updateFooterClock(){\n const el=$('statClock');\n if(el) el.textContent=new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit', second:'2-digit'});\n }\n function updateSocketStatus(s={}){\n const el=$('statSockets');\n if(!el) return;\n const open=s.open_sockets;\n const max=s.max_open_sockets;\n el.textContent=open == null ? '-' : (max == null ? String(open) : `${open}/${max}`);\n const box=$('statusSockets');\n if(box) box.title=open == null ? 'Open sockets unavailable from this rTorrent build' : `Open rTorrent sockets${max == null ? '' : ' / max'}: ${el.textContent}`;\n }\n\n function portStatusLabel(st){ return st==='open'?'open':st==='closed'?'closed':st==='disabled'?'disabled':st==='error'?'error':'unknown'; }\n function portStatusClass(st){ return st==='open'?'port-ok':st==='closed'?'port-bad':'port-secondary'; }\n function portStatusIcon(st){ return st==='open'?'fa-circle-check':st==='closed'?'fa-circle-xmark':'fa-circle-question'; }\n function portStatusBadge(data={},attrs='',withPort=false){ const st=portStatusLabel(data.status); const active=data.open_port||data.port; const port=active?String(active):'-'; const label=withPort?`Port ${port} ${st}`:st; return ` ${esc(label)}`; }\n function portCheckedAt(data={}){ if(data.checked_at) return String(data.checked_at).replace('T',' ').replace(/\\+00:00$/,' UTC'); if(data.checked_at_epoch) return new Date(Number(data.checked_at_epoch)*1000).toLocaleString(); return ''; }\n function portCheckDetails(data={}){ const bits=[]; if(data.open_port) bits.push(`Open port: ${data.open_port}`); else if(data.port) bits.push(`First port: ${data.port}`); if(Array.isArray(data.ports)&&data.ports.length>1) bits.push(`Candidates: ${data.ports.join(', ')}`); if(Array.isArray(data.checked_ports)&&data.checked_ports.length) bits.push(`Checked: ${data.checked_ports.join(', ')}`); if(data.ports_truncated) bits.push('Port list truncated to safety limit'); if(data.public_ip) bits.push(`Public IP: ${data.public_ip}`); if(data.remote) bits.push('Remote profile'); if(data.source) bits.push(`Source: ${data.source}`); const checked=portCheckedAt(data); if(checked) bits.push(`Last check: ${checked}`); if(data.cached) bits.push('Cached result'); if(data.error) bits.push(data.error); if(data.fallback_error) bits.push(data.fallback_error); return bits; }\n function renderPortCheck(data={}){\n if($('portCheckEnabled')) $('portCheckEnabled').checked=!!data.enabled;\n const details=portCheckDetails(data);\n const title=details.join(' \u00b7 ') || 'Port check disabled';\n if($('portCheckBadge')) $('portCheckBadge').outerHTML=portStatusBadge(data,'id=\"portCheckBadge\" ');\n if($('portCheckInfo')) $('portCheckInfo').textContent=details.join(' \u00b7 ') || 'Uses YouGetSignal first. Manual check bypasses the 6h cache.';\n if($('statusPortCheck')){\n $('statusPortCheck').classList.toggle('d-none', !data.enabled);\n $('statusPortCheck').title=title;\n }\n if($('statusPortCheckBadge')) $('statusPortCheckBadge').outerHTML=portStatusBadge(data,'id=\"statusPortCheckBadge\" ',true);\n }\n async function loadPreferences(){\n try{\n const j=await (await fetch(`/api/preferences?_=${Date.now()}`, {cache:'no-store'})).json();\n const prefs=j.preferences||{};\n portCheckEnabled=!!Number(prefs.port_check_enabled ?? portCheckEnabled);\n automationToastsEnabled=Number(prefs.automation_toasts_enabled ?? (automationToastsEnabled?1:0))!==0;\n smartQueueToastsEnabled=Number(prefs.smart_queue_toasts_enabled ?? (smartQueueToastsEnabled?1:0))!==0;\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||'';\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ diskMonitorPaths=[]; }\n bootstrapTheme=prefs.bootstrap_theme||bootstrapTheme;\n fontFamily=prefs.font_family||fontFamily;\n interfaceScale=Number(prefs.interface_scale||interfaceScale||100);\n try{ footerItems={...DEFAULT_FOOTER_ITEMS,...JSON.parse(prefs.footer_items_json||'{}')}; }catch(_){ footerItems={...DEFAULT_FOOTER_ITEMS}; }\n }catch(e){ console.warn('Preference load failed', e); }\n if($('portCheckEnabled')) $('portCheckEnabled').checked=portCheckEnabled; if($('automationToastsEnabled')) $('automationToastsEnabled').checked=automationToastsEnabled; if($('smartQueueToastsEnabled')) $('smartQueueToastsEnabled').checked=smartQueueToastsEnabled; if($('diskMonitorMode')) $('diskMonitorMode').value=diskMonitorMode; if($('diskMonitorSelectedPath')) $('diskMonitorSelectedPath').value=diskMonitorSelectedPath; renderDiskMonitorPaths(); applyBootstrapTheme(bootstrapTheme); applyFontFamily(fontFamily); applyInterfaceScale(interfaceScale); renderFooterPreferences(); applyFooterPreferences(); await loadPortCheck(false); }\n function updateDiskMonitorUi(){\n // Note: Disk monitor radio switches are mirrored into the shared diskMonitorMode state.\n const mode=['default','selected','aggregate'].includes(diskMonitorMode)?diskMonitorMode:'default';\n if($('diskMonitorMode')) $('diskMonitorMode').value=mode;\n document.querySelectorAll('.disk-monitor-mode').forEach(input=>{ input.checked=input.value===mode; });\n const selectedDisabled=mode!=='selected' || !diskMonitorPaths.length;\n if($('diskMonitorSelectedPath')) $('diskMonitorSelectedPath').disabled=selectedDisabled;\n document.querySelectorAll('.disk-path-select').forEach(btn=>{ btn.disabled=mode==='aggregate'; btn.classList.toggle('active', btn.dataset.path===diskMonitorSelectedPath && mode==='selected'); });\n const hint=$('diskMonitorSelectedHint');\n if(hint){\n hint.textContent=mode==='aggregate' ? 'Aggregate mode uses all monitored paths, so one-path selection is locked.' : mode==='default' ? 'Default mode uses the rTorrent path, custom selection is optional.' : diskMonitorPaths.length ? 'This path drives the footer progress bar.' : 'Add at least one monitored path to use selected mode.';\n }\n }\n function renderDiskMonitorPaths(){\n const select=$('diskMonitorSelectedPath');\n if(select){\n const fallback=diskMonitorPaths.length?'Choose monitored path':'No custom paths yet';\n select.innerHTML=``+diskMonitorPaths.map(p=>``).join('');\n select.value=diskMonitorSelectedPath||'';\n }\n const box=$('diskMonitorPaths');\n if(box){\n box.innerHTML=diskMonitorPaths.length?diskMonitorPaths.map(p=>`
${esc(p)}${p===diskMonitorSelectedPath?'Selected for footer progress':'Used in aggregate tooltip and available for selected mode'}
`).join(''):'
No extra disk paths. Add a path above to monitor another storage directory.
';\n }\n updateDiskMonitorUi();\n }\n async function saveNotificationPrefs(){ automationToastsEnabled=!!$('automationToastsEnabled')?.checked; smartQueueToastsEnabled=!!$('smartQueueToastsEnabled')?.checked; try{ await post('/api/preferences',{automation_toasts_enabled:automationToastsEnabled,smart_queue_toasts_enabled:smartQueueToastsEnabled}); toast('Notification preferences saved','success'); }catch(e){ toast(e.message,'danger'); } }\n async function saveDiskMonitorPrefs(){\n // Note: Disk monitor mode is controlled by radio switches, so keep the in-memory mode instead of reading a removed select.\n const checkedMode=document.querySelector('.disk-monitor-mode:checked')?.value;\n diskMonitorMode=['default','selected','aggregate'].includes(checkedMode) ? checkedMode : (['default','selected','aggregate'].includes(diskMonitorMode) ? diskMonitorMode : 'default');\n diskMonitorSelectedPath=$('diskMonitorSelectedPath')?.value||diskMonitorSelectedPath||'';\n try{\n const res=await post('/api/preferences',{disk_monitor_paths_json:diskMonitorPaths,disk_monitor_mode:diskMonitorMode,disk_monitor_selected_path:diskMonitorSelectedPath});\n const prefs=res.preferences||{};\n // Note: Sync saved values back from the API so the footer uses the persisted disk source, not a stale UI guess.\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||diskMonitorSelectedPath||'';\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ }\n renderDiskMonitorPaths();\n await refreshUserDiskUsage(true);\n toast('Disk monitor saved','success');\n }catch(e){ toast(e.message,'danger'); }\n }\n async function savePortCheckPref(){ portCheckEnabled=!!$('portCheckEnabled')?.checked; try{ await post('/api/preferences',{port_check_enabled:portCheckEnabled}); toast('Preferences saved','success'); await loadPortCheck(false); }catch(e){ toast(e.message,'danger'); } }\n async function loadPortCheck(force=false){ try{ const res=force?await post('/api/port-check',{}):await (await fetch('/api/port-check')).json(); if(!res.ok) throw new Error(res.error||'Port check failed'); renderPortCheck(res.port_check||{}); }catch(e){ renderPortCheck({status:'error',enabled:portCheckEnabled,error:e.message}); } }\n async function loadAppStatus(){\n const box=$('appStatusManager'); if(!box) return;\n box.innerHTML=' Loading diagnostics...';\n try{\n const [status,poller,planner,smart]=await Promise.all([\n fetch('/api/app/status').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({})),\n fetch('/api/download-planner/preview').then(r=>r.json()).catch(()=>({})),\n fetch('/api/smart-queue?history_limit=100').then(r=>r.json()).catch(()=>({ok:false}))\n ]);\n if(!status.ok) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, profile=st.profile||{}, pc=st.port_check||{}, cleanup=st.cleanup||{}, db=cleanup.database||{};\n const peaks=st.speed_peaks||{}, peakSession=peaks.session||{}, peakAllTime=peaks.all_time||{};\n const rt=poller.runtime||{}, ps=poller.settings||{}, pv=planner.preview||{}, smartStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null;\n const panes=[\n ['process','Process', diagnosticsSection('pyTorrent process', [diagCard('PID', py.pid), diagCard('Uptime', `${py.uptime_seconds||0}s`), diagCard('Memory RSS', py.memory_rss_h||py.memory_rss), diagCard('Threads', py.threads), diagCard('CPU', `${py.cpu_percent ?? '-'}%`), diagCard('Python', py.python||'-')])],\n ['connection','Connection', diagnosticsSection('Profile and rTorrent', [diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('SCGI connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('SCGI first byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('SCGI total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'), diagCard('Request bytes', scgi.request_bytes), diagCard('Response bytes', scgi.response_bytes), diagCard('XML bytes', scgi.xml_bytes), diagCard('rTorrent version', scgi.client_version||'-')])],\n ['poller','Poller', diagnosticsSection('Adaptive poller', [diagCard('Adaptive', ps.adaptive_enabled===false?'off':'on'), diagCard('Mode', rt.adaptive_mode||'-'), diagCard('Effective interval', `${rt.effective_interval_seconds??'-'}s`), diagCard('Minimum interval', `${rt.configured_min_interval_seconds??'-'}s`), diagCard('Tick duration', `${rt.duration_ms||rt.last_tick_ms||0} ms`), diagCard('Tick gap', `${rt.last_tick_gap_ms||0} ms`), diagCard('Payload', fmtBytes(rt.emitted_payload_size||0)), diagCard('rTorrent calls', rt.rtorrent_call_count||0), diagCard('Skipped emissions', rt.skipped_emissions||0), diagCard('Ticks', rt.tick_count||0)])],\n ['planner','Planner', diagnosticsSection('Planner', [diagCard('Matched rule', pv.matched_rule||'-'), diagCard('Next change', pv.next_change_at||'-'), diagCard('Planner state', pv.enabled===false?'disabled':'enabled')])],\n ['storage','Storage / jobs', diagnosticsSection('Database and cleanup', [diagCard('DB size', db.size_h||'-'), diagCard('Jobs total', py.jobs_total ?? '-'), diagCard('Worker threads', py.worker_threads ?? '-'), diagCard('Job logs clearable', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-')])],\n ['network','Network / speed', diagnosticsSection('Port and speed', [diagCard('Port check', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':''), diagCard('Incoming port', pc.port||'-'), diagCard('Port check source', pc.source||(pc.enabled?'unknown':'disabled')), diagCard('Peak session DL/UL', speedPairText(peakSession.down_h, peakSession.up_h)), diagCard('Peak all-time DL/UL', speedPairText(peakAllTime.down_h, peakAllTime.up_h))])],\n ['smart','Smart Queue', `
Smart Queue decisions
${renderSmartQueueNerdStats(smartStats)}
`]\n ];\n const tabs=`
    ${panes.map((p,i)=>`
  • `).join('')}
`;\n if($('appStatusTabs')) $('appStatusTabs').innerHTML=tabs;\n box.innerHTML=`${panes.map((p,i)=>`
${p[2]}
`).join('')}${scgi.error?`
${esc(scgi.error)}
`:''}`;\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n\n\n const TORRENT_STATS_PANE_STORAGE_KEY = 'pytorrent.torrentStatsPane.v1';\n function torrentStatsCard(label, value, note=''){\n return `
${esc(label)}${esc(value ?? '-')}${note?`${esc(note)}`:''}
`;\n }\n function activeTorrentStatsPane(){\n const value=localStorage.getItem(TORRENT_STATS_PANE_STORAGE_KEY)||'overview';\n return ['overview','storage','sources','speed','cache'].includes(value) ? value : 'overview';\n }\n function setTorrentStatsPane(pane){\n const box=$('torrentStatsManager');\n if(!box) return;\n localStorage.setItem(TORRENT_STATS_PANE_STORAGE_KEY, pane);\n box.querySelectorAll('[data-torrentstats-pane]').forEach(x=>x.classList.toggle('active',x.dataset.torrentstatsPane===pane));\n box.querySelectorAll('[data-torrentstats-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.torrentstatsPanel!==pane));\n }\n function renderTorrentStats(stats={}){\n const box=$('torrentStatsManager');\n if(!box) return;\n const age=Number(stats.age_seconds||0);\n const updated=stats.updated_at ? String(stats.updated_at).replace('T',' ').replace(/\\+00:00$/,' UTC') : '-';\n const active=activeTorrentStatsPane();\n const panes=[\n ['overview','Overview', [\n torrentStatsCard('Torrents', stats.torrent_count, `${stats.complete_count||0} complete / ${stats.incomplete_count||0} incomplete`),\n torrentStatsCard('Sampled', stats.sampled_torrents ?? 0, stats.stale?'cache is stale':'cache is fresh')\n ]],\n ['storage','Storage', [\n torrentStatsCard('Torrent size', stats.total_torrent_size_h || fmtBytes(stats.total_torrent_size)),\n torrentStatsCard('Files size', stats.total_file_size_h || fmtBytes(stats.total_file_size), `${stats.file_count||0} files`)\n ]],\n ['sources','Seeds / peers', [\n torrentStatsCard('Seeds / peers', `${stats.seeds_total||0} / ${stats.peers_total||0}`, 'current sum from last sample')\n ]],\n ['speed','Speed', [\n torrentStatsCard('Speed DL / UL', `${stats.down_rate_total_h||'0 B/s'} / ${stats.up_rate_total_h||'0 B/s'}`)\n ]],\n ['cache','Cache', [\n torrentStatsCard('Updated', updated),\n torrentStatsCard('Age', `${age}s`)\n ]]\n ];\n if($('torrentStatsMeta')) $('torrentStatsMeta').textContent=`Updated: ${updated}, age: ${age}s`;\n const errors=Array.isArray(stats.errors)&&stats.errors.length ? `
File metadata warnings: ${esc(stats.errors.length)} torrent(s). ${esc(stats.error||'')}
` : '';\n box.innerHTML=`
    ${panes.map(p=>`
  • `).join('')}
${panes.map(p=>`
${p[2].join('')}
`).join('')}${errors}`;\n }\n async function loadTorrentStats(force=false){\n const box=$('torrentStatsManager');\n if(!box) return;\n box.innerHTML=' Loading torrent statistics...';\n try{\n const j=await (await fetch(`/api/torrent-stats${force?'?force=1':''}`)).json();\n if(!j.ok) throw new Error(j.error||'Torrent statistics failed');\n renderTorrentStats(j.stats||{});\n if(force) toast('Torrent statistics refreshed','success');\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n\n\n function addToolTab(tool, icon, label, beforeTool='appstatus'){\n if(document.querySelector(`.tool-tab[data-tool=\"${tool}\"]`)) return;\n const nav=document.querySelector('#toolsModal .nav.nav-pills');\n if(!nav) return;\n const li=document.createElement('li');\n li.className='nav-item';\n li.innerHTML=``;\n const before=document.querySelector(`#toolsModal .tool-tab[data-tool=\"${beforeTool}\"]`)?.closest('.nav-item');\n nav.insertBefore(li,before||null);\n li.querySelector('.tool-tab')?.addEventListener('click',()=>activateToolTab(tool));\n }\n function inlineSwitch(id,label='Enable',extraClass=''){\n return ``;\n }\n function plannerToggleRow(id,title,description){\n return `
${title}${description}
${inlineSwitch(id)}
`;\n }\n function plannerSpeedCard(prefix,title,sub){\n return `
\n ${title}\n ${sub}\n
Unlimited
\n
\n \n \n \n \n \n \n
\n
\n \n \n \n \n
\n Slider uses Mbit/s. Numeric fields store B/s for rTorrent.\n
`;\n }\n"; diff --git a/pytorrent/static/js/smartQueue.js.orig b/pytorrent/static/js/smartQueue.js.orig new file mode 100644 index 0000000..5f74329 --- /dev/null +++ b/pytorrent/static/js/smartQueue.js.orig @@ -0,0 +1 @@ +export const smartQueueSource = ' function smartHistoryDetails(row){ try{ return typeof row.details_json===\'string\'?JSON.parse(row.details_json||\'{}\'):(row.details_json||{}); }catch(e){ return {}; } }\n function smartQueueToastMessage(r){ const pending=r.start_pending_confirmation?.length||0; const requested=r.start_requested?.length||0; const stopFailed=r.stop_failed?.length||0; const startFailed=r.start_failed?.length||0; const limit=r.max_active_downloads||r.settings?.max_active_downloads||\'\'; const activeBefore=r.active_before; const activeAfter=r.active_after_stop ?? r.active_after_expected; const activeTail=activeBefore!==undefined?`, active ${esc(activeBefore)}->${esc(activeAfter ?? \'?\')}${limit?`/${esc(limit)}`:\'\'}`:\'\'; const cap=r.rtorrent_cap?.updated?`, cap ${r.rtorrent_cap.current}->${r.rtorrent_cap.new}`:\'\'; const waiting=r.waiting_labeled||0; const stalled=r.stalled_labeled?.length||0; const ignoredSeedPeer=(r.ignore_seed_peer||r.settings?.ignore_seed_peer)?Number(r.ignored_seed_peer_count||0):0; const ignoredSpeed=(r.ignore_speed||r.settings?.ignore_speed)?Number(r.ignored_speed_count||0):0; const tail=pending?`, pending confirm ${pending}`:requested?`, requested ${requested}`:\'\'; const waitTail=waiting?`, waiting labeled ${waiting}`:\'\'; const stalledTail=stalled?`, stalled ${stalled}`:\'\'; const startSkipped=Number(r.start_source_skipped||0); const startSkippedTail=(!r.allow_start_without_sources&&!r.settings?.allow_start_without_sources&&startSkipped)?`, start skipped no sources ${startSkipped}`:\'\'; const ignoredSeedTail=(r.ignore_seed_peer||r.settings?.ignore_seed_peer)?`, ignored missing seeds/peers ${ignoredSeedPeer}`:\'\'; const ignoredSpeedTail=(r.ignore_speed||r.settings?.ignore_speed)?`, ignored speed ${ignoredSpeed}`:\'\'; const failTail=`${stopFailed?`, stop failed ${stopFailed}`:\'\'}${startFailed?`, start failed ${startFailed}`:\'\'}`; return `Smart Queue: stopped ${r.stopped?.length||r.paused?.length||0}, started ${r.started?.length||r.resumed?.length||0}${activeTail}${tail}${waitTail}${stalledTail}${startSkippedTail}${ignoredSeedTail}${ignoredSpeedTail}${failTail}${cap}`; }\n function buildSmartQueueNerdStats(hist=[], totalHistory=0){\n // Note: Small Smart Queue telemetry for automation nerds; it reads history only and does not affect queue behavior.\n const stats=hist.reduce((acc,h)=>{\n const details=smartHistoryDetails(h);\n const stopped=Number(h.paused_count||0);\n const started=Number(h.resumed_count||0);\n const checked=Number(h.checked_count||0);\n const over=Number(details.over_limit||0);\n const stopFailed=Array.isArray(details.stop_failed)?details.stop_failed.length:0;\n acc.checked += checked;\n acc.stopped += stopped;\n acc.started += started;\n acc.overLimit += over;\n acc.stopFailed += stopFailed;\n if(over>0) acc.overEvents += 1;\n return acc;\n },{checked:0,stopped:0,started:0,overLimit:0,overEvents:0,stopFailed:0});\n const latest=hist[0]||null;\n return {...stats,total:Number(totalHistory||hist.length||0),sample:hist.length,latestEvent:latest?.event||\'-\',latestAt:latest?.created_at||\'\'};\n }\n\n function renderSmartQueueNerdStats(stats){\n // Note: Compact cards keep the extra diagnostics readable above Automation history without changing the history table.\n if(!stats) return \'
No Smart Queue stats yet.
\';\n const cards=[\n [\'Runs\',stats.total,`${stats.sample} loaded`],\n [\'Checked\',stats.checked,\'torrent scans\'],\n [\'Stopped\',stats.stopped,\'queue trims\'],\n [\'Started\',stats.started,\'queue fills\'],\n [\'Over limit\',stats.overEvents,`${stats.overLimit} total over`],\n [\'Stop failed\',stats.stopFailed,\'rTorrent rejects\'],\n [\'Latest\',stats.latestEvent,stats.latestAt?dateCell(stats.latestAt):\'no timestamp\'],\n ];\n return `
${cards.map(([label,value,hint])=>`
${esc(label)}${esc(value)}${hint}
`).join(\'\')}
`;\n }\n function formatDurationLeft(seconds){ seconds=Math.max(0,Math.floor(Number(seconds||0))); if(!seconds) return "ready"; const m=Math.floor(seconds/60), s=seconds%60; return m?`${m}m ${String(s).padStart(2,"0")}s`:`${s}s`; }\n function updateCooldownBadge(id, seconds){\n const el=$(id); if(!el) return;\n const value=Math.max(0,Math.floor(Number(seconds||0)));\n el.dataset.seconds=String(value);\n el.textContent=`next: ${formatDurationLeft(value)}`;\n }\n function tickCooldowns(){\n document.querySelectorAll(".cooldown-live").forEach(el=>{\n let v=Math.max(0,Number(el.dataset.seconds||0));\n if(v>0){ v-=1; el.dataset.seconds=String(v); }\n el.textContent=`next: ${formatDurationLeft(v)}`;\n });\n }\n setInterval(tickCooldowns,1000);\n async function loadSmartQueue(){\n if($(\'smartManager\')) $(\'smartManager\').innerHTML=loadingMarkup(\'Loading Smart Queue...\');\n if($(\'smartHistory\')) $(\'smartHistory\').innerHTML=loadingMarkup(\'Loading Smart Queue history...\');\n const historyLimit=smartHistoryExpanded?100:10;\n const j=await (await fetch(`/api/smart-queue?history_limit=${historyLimit}`)).json();\n if(!j.ok) return;\n const st=j.settings||{}, ex=j.exclusions||[], hist=j.history||[];\n const totalHistory=Number(j.history_total ?? hist.length);\n if($(\'smartEnabled\')) $(\'smartEnabled\').checked=!!st.enabled;\n if($(\'smartMaxActive\')) $(\'smartMaxActive\').value=st.max_active_downloads||5;\n if($(\'smartStalled\')) $(\'smartStalled\').value=st.stalled_seconds||300;\n if($(\'smartStopBatch\')) $(\'smartStopBatch\').value=st.stop_batch_size||50;\n if($(\'smartStartGrace\')) $(\'smartStartGrace\').value=st.start_grace_seconds||900;\n if($(\'smartProtectActiveBelowCap\')) $(\'smartProtectActiveBelowCap\').checked=st.protect_active_below_cap!==0;\n if($(\'smartMinSpeed\')) $(\'smartMinSpeed\').value=Math.round((st.min_speed_bytes||0)/1024);\n if($(\'smartMinSeeds\')) $(\'smartMinSeeds\').value=st.min_seeds||1;\n if($(\'smartMinPeers\')) $(\'smartMinPeers\').value=st.min_peers||0;\n if($(\'smartAllowStartWithoutSources\')) $(\'smartAllowStartWithoutSources\').checked=!!st.allow_start_without_sources;\n if($(\'smartIgnoreSeedPeer\')) $(\'smartIgnoreSeedPeer\').checked=!!st.ignore_seed_peer;\n if($(\'smartIgnoreSpeed\')) $(\'smartIgnoreSpeed\').checked=!!st.ignore_speed;\n if($(\'smartCooldown\')) $(\'smartCooldown\').value=st.cooldown_minutes||10;\n const refillMode=!Number(st.refill_enabled ?? 1) ? \'off\' : (Number(st.refill_interval_minutes||0)>0 ? \'custom\' : \'auto\');\n if($(\'smartRefillMode\')) $(\'smartRefillMode\').value=refillMode;\n if($(\'smartRefillInterval\')) $(\'smartRefillInterval\').value=Number(st.refill_interval_minutes||0)>0 ? st.refill_interval_minutes : 5;\n updateSmartRefillControls();\n updateCooldownBadge(\'smartCooldownBadge\', Number(j.cooldown_remaining_seconds||0));\n if($(\'smartCooldownHint\')) $(\'smartCooldownHint\').textContent=st.enabled ? `Automatic run every ${st.cooldown_minutes||10} minute(s). Manual check ignores cooldown.` : \'Smart Queue is disabled; timer starts after it is enabled and runs once.\';\n if($(\'smartRefillHint\')) $(\'smartRefillHint\').textContent=smartRefillHintText(refillMode, Number(st.refill_interval_minutes||0), Number(j.refill_remaining_seconds||0));\n if($(\'smartManager\')){\n $(\'smartManager\').innerHTML=ex.length\n ? responsiveTable([\'Hash\',\'Reason\',\'Created\',\'Action\'],ex.map(x=>[esc(x.torrent_hash),esc(x.reason||\'\'),dateCell(x.created_at),``]),\'smart-exclusions-table\')\n : \'
No Smart Queue exceptions. Select torrents and use Exclude selected to keep them outside the queue.
\';\n }\n if($(\'smartHistory\')){\n const body=hist.length\n ? responsiveTable([\'Time\',\'Event\',\'Checked\',\'Active\',\'Limit\',\'Over\',\'Stopped\',\'Requested\',\'Verified\',\'Pending\',\'Start failed\',\'No effect\',\'Stop failed\'],hist.map(h=>{ const d=smartHistoryDetails(h); return [dateCell(h.created_at),esc(h.event),esc(h.checked_count||0),esc(d.active_before??\'-\'),esc(d.max_active_downloads??\'-\'),esc(d.over_limit??0),esc(h.paused_count||0),esc((d.start_requested||d.started||[]).length||h.resumed_count||0),esc((d.active_verified||[]).length||0),esc((d.start_pending_confirmation||[]).length||0),esc((d.start_failed||[]).length||0),esc((d.start_no_effect||[]).length||0),esc((d.stop_failed||[]).length||0)]; }),\'smart-history-table\')\n : \'
No Smart Queue operations yet.
\';\n const canToggle=totalHistory>10;\n const toggle=canToggle?``:\'\';\n $(\'smartHistory\').innerHTML=`${body}${toggle}`;\n }\n }\n function smartRefillHintText(mode, minutes, remainingSeconds){\n // Note: Refill mode controls only the lightweight slot top-up during cooldown, not the full Smart Queue pass.\n if(mode===\'off\') return \'Refill is disabled. Smart Queue will only fill slots during full checks or manual checks.\';\n if(mode===\'custom\'){\n const wait=Number(remainingSeconds||0)>0 ? ` Next refill in ${formatDurationLeft(remainingSeconds)}.` : \'\';\n return `Refill runs at most every ${Math.max(1, Number(minutes||5))} minute(s) while Smart Queue is in cooldown.${wait}`;\n }\n return \'Refill uses the current automatic poller cadence during cooldown, usually about every 2 minutes.\';\n }\n function updateSmartRefillControls(){\n const mode=$(\'smartRefillMode\')?.value||\'auto\';\n const interval=$(\'smartRefillInterval\');\n if(interval) interval.disabled=mode!==\'custom\';\n }\n async function setSmartException(hashes, excluded, reason=\'manual\'){ const list=[...new Set(hashes||[])].filter(Boolean); if(!list.length) return toast(\'No torrents selected\',\'warning\'); setBusy(true); try{ for(const h of list) await post(\'/api/smart-queue/exclusion\',{hash:h,excluded,reason}); toast(excluded?\'Smart Queue exception added\':\'Smart Queue exception removed\',\'success\'); await loadSmartQueue(); }catch(e){toast(e.message,\'danger\');} finally{setBusy(false);} }\n async function saveSmartQueue(){ await post(\'/api/smart-queue\',{enabled:$(\'smartEnabled\')?.checked,max_active_downloads:$(\'smartMaxActive\')?.value,stalled_seconds:$(\'smartStalled\')?.value,stop_batch_size:$(\'smartStopBatch\')?.value||50,start_grace_seconds:$(\'smartStartGrace\')?.value||900,protect_active_below_cap:$(\'smartProtectActiveBelowCap\')?.checked,min_speed_bytes:Math.round(Number($(\'smartMinSpeed\')?.value||0)*1024),min_seeds:$(\'smartMinSeeds\')?.value,min_peers:$(\'smartMinPeers\')?.value,allow_start_without_sources:$(\'smartAllowStartWithoutSources\')?.checked,ignore_seed_peer:$(\'smartIgnoreSeedPeer\')?.checked,ignore_speed:$(\'smartIgnoreSpeed\')?.checked,cooldown_minutes:$(\'smartCooldown\')?.value||10,refill_mode:$(\'smartRefillMode\')?.value||\'auto\',refill_interval_minutes:$(\'smartRefillInterval\')?.value||5}); toast(\'Smart Queue saved\',\'success\'); await loadSmartQueue(); }\n\n async function loadAuthUsers(){\n if(!window.PYTORRENT.authEnabled || !$(\'authUsersManager\')) return;\n const [usersRes, profilesRes]=await Promise.all([fetch(\'/api/auth/users\'), fetch(\'/api/profiles\')]);\n const usersJson=await usersRes.json();\n const profilesJson=await profilesRes.json();\n const profiles=profilesJson.profiles||[];\n if($(\'authProfile\')) $(\'authProfile\').innerHTML=``+profiles.map(p=>``).join(\'\');\n const rows=(usersJson.users||[]).map(u=>{\n const perms=(u.permissions||[]).map(p=>`${p.profile_id?(\'profile \'+p.profile_id):\'all\'}: ${p.access_level===\'full\'?\'Full\':\'R/O\'}`).join(\', \') || (u.role===\'admin\'?\'all: Full\':\'none\');\n return [esc(u.username),esc(u.role),u.is_active?\'yes\':\'no\',esc(perms),` `];\n });\n $(\'authUsersManager\').innerHTML=rows.length?table([\'User\',\'Role\',\'Active\',\'Profile rights\',\'Actions\'],rows):\'
No users.
\';\n }\n function resetAuthUserForm(){ [\'authUserId\',\'authUsername\',\'authPassword\'].forEach(id=>{ if($(id)) $(id).value=\'\'; }); if($(\'authRole\')) $(\'authRole\').value=\'user\'; if($(\'authProfile\')) $(\'authProfile\').value=\'0\'; if($(\'authAccess\')) $(\'authAccess\').value=\'ro\'; if($(\'authActive\')) $(\'authActive\').checked=true; $(\'authUserCancelBtn\')?.classList.add(\'d-none\'); }\n function editAuthUser(user){ if(!user) return; if($(\'authUserId\')) $(\'authUserId\').value=user.id||\'\'; if($(\'authUsername\')) $(\'authUsername\').value=user.username||\'\'; if($(\'authPassword\')) $(\'authPassword\').value=\'\'; if($(\'authRole\')) $(\'authRole\').value=user.role||\'user\'; if($(\'authActive\')) $(\'authActive\').checked=!!user.is_active; const perm=(user.permissions||[])[0]||{profile_id:0,access_level:\'ro\'}; if($(\'authProfile\')) $(\'authProfile\').value=String(perm.profile_id||0); if($(\'authAccess\')) $(\'authAccess\').value=perm.access_level||\'ro\'; $(\'authUserCancelBtn\')?.classList.remove(\'d-none\'); }\n async function saveAuthUser(){\n const id=$(\'authUserId\')?.value||\'\';\n const role=$(\'authRole\')?.value||\'user\';\n const payload={username:$(\'authUsername\')?.value||\'\',password:$(\'authPassword\')?.value||\'\',role,is_active:!!$(\'authActive\')?.checked,permissions:role===\'admin\'?[]:[{profile_id:Number($(\'authProfile\')?.value||0),access_level:$(\'authAccess\')?.value||\'ro\'}]};\n try{ await post(id?`/api/auth/users/${id}`:\'/api/auth/users\',payload,id?\'PUT\':\'POST\'); toast(\'User saved\',\'success\'); resetAuthUserForm(); await loadAuthUsers(); }catch(e){ toast(e.message,\'danger\'); }\n }\n function normalizeRtConfigValue(value, type=\'text\'){\n const raw=String(value ?? \'\').trim();\n if(type===\'bool\') return [\'1\',\'true\',\'yes\',\'on\'].includes(raw.toLowerCase()) ? \'1\' : \'0\';\n if(type===\'number\'){\n if(raw===\'\') return \'0\';\n const normalized=Number(raw.replace(\',\', \'.\'));\n return Number.isFinite(normalized) ? String(Math.trunc(normalized)) : raw;\n }\n return raw;\n }\n function rtConfigInputValue(input){\n const type=input.dataset.type || rtConfigFieldTypes.get(input.dataset.key) || \'text\';\n const value=type===\'bool\' && input.type===\'checkbox\' ? (input.checked?\'1\':\'0\') : input.value;\n return normalizeRtConfigValue(value, type);\n }\n function rtConfigOriginalValue(input){\n const key=input.dataset.key;\n return normalizeRtConfigValue(input.dataset.original ?? rtConfigOriginal.get(key), input.dataset.type || rtConfigFieldTypes.get(key) || \'text\');\n }\n function collectRtConfigChanges(){\n const values={};\n document.querySelectorAll(\'.rt-config-input\').forEach(input=>{\n if(input.disabled) return;\n const cur=rtConfigInputValue(input);\n const orig=rtConfigOriginalValue(input);\n if(cur!==orig) values[input.dataset.key]=cur;\n });\n return values;\n }\n function collectRtConfigClearKeys(){\n const keys=[];\n document.querySelectorAll(\'.rt-config-input\').forEach(input=>{\n if(input.disabled || input.dataset.saved!==\'true\') return;\n const cur=rtConfigInputValue(input);\n const orig=rtConfigOriginalValue(input);\n if(cur===orig) keys.push(input.dataset.key);\n });\n return keys;\n }\n function updateRtConfigDirty(){\n const changed=collectRtConfigChanges();\n const clearKeys=collectRtConfigClearKeys();\n document.querySelectorAll(\'.rt-config-input\').forEach(input=>{\n const row=input.closest(\'.rt-config-row\');\n if(row) row.classList.toggle(\'changed\', Object.prototype.hasOwnProperty.call(changed,input.dataset.key));\n });\n const configChanges=Object.keys(changed).length;\n const applyChanged=!!$(\'rtConfigApplyOnStart\') && $(\'rtConfigApplyOnStart\').checked!==rtConfigOriginalApplyOnStart;\n const total=configChanges + clearKeys.length + (applyChanged ? 1 : 0);\n if($(\'rtConfigChangedCount\')) $(\'rtConfigChangedCount\').textContent=total?`${total} changed`:\'No changes\';\n if($(\'rtConfigGenerateBtn\')) $(\'rtConfigGenerateBtn\').disabled=!configChanges;\n if($(\'rtConfigSaveBtn\')) $(\'rtConfigSaveBtn\').disabled=!total;\n }\n async function loadRtConfig(){\n const box=$(\'rtConfigManager\');\n if(!box)return;\n box.innerHTML=\' Loading config...\';\n try{\n const j=await (await fetch(\'/api/rtorrent-config\')).json();\n if(!j.ok) throw new Error(j.error||\'Config load failed\');\n const fields=j.config?.fields||[];\n rtConfigOriginal=new Map();\n rtConfigFieldTypes=new Map();\n rtConfigOriginalApplyOnStart=!!j.config?.apply_on_start;\n let lastGroup=\'\';\n const html=fields.map(f=>{\n const group=f.group||\'Other\';\n const head=group!==lastGroup?`
${esc(group)}
`:\'\';\n lastGroup=group;\n const disabled=(!f.ok||f.readonly)?\'disabled\':\'\';\n const type=[\'bool\',\'number\'].includes(f.type)?f.type:\'text\';\n const originalValue=normalizeRtConfigValue(f.baseline_value ?? f.current_value ?? f.value, type);\n const displayValue=normalizeRtConfigValue(f.saved ? f.saved_value : (f.value ?? f.current_value), type);\n rtConfigOriginal.set(f.key, originalValue);\n rtConfigFieldTypes.set(f.key, type);\n const note=f.ok?(f.readonly?\' · read only\':(f.saved?\' · saved override · reference kept\':\'\')):\' · unavailable\';\n const valueNote=f.saved?`Reference: ${esc(originalValue)} → saved: ${esc(displayValue)}`:\'\';\n const originalAttr=esc(originalValue);\n const input=type===\'bool\'\n ? `${displayValue===\'1\'?\'On\':\'Off\'}`\n : ``;\n return `${head}`;\n }).join(\'\');\n box.innerHTML=`
${html}
`;\n if($(\'rtConfigApplyOnStart\')) $(\'rtConfigApplyOnStart\').checked=rtConfigOriginalApplyOnStart;\n updateRtConfigDirty();\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n async function saveRtConfig(){\n const values=collectRtConfigChanges();\n const clear_keys=collectRtConfigClearKeys();\n clear_keys.forEach(key=>{\n const input=document.querySelector(`.rt-config-input[data-key="${CSS.escape(key)}"]`);\n if(input) values[key]=rtConfigOriginalValue(input);\n });\n setBusy(true);\n try{\n const j=await post(\'/api/rtorrent-config\',{values,clear_keys,apply_on_start:!!$(\'rtConfigApplyOnStart\')?.checked,apply_now:true});\n toast(`rTorrent config saved (${j.result?.updated?.length||0})`,\'success\');\n await loadRtConfig();\n }catch(e){\n toast(e.message,\'danger\');\n } finally{\n setBusy(false);\n }\n }\n async function generateRtConfig(){ const values=collectRtConfigChanges(); try{ const res=await fetch(\'/api/rtorrent-config/generate\',{method:\'POST\',headers:{\'Content-Type\':\'application/json\'},body:JSON.stringify({values})}); const j=await res.json(); if(!j.ok) throw new Error(j.error||\'Generate failed\'); if($(\'rtConfigOutput\')) $(\'rtConfigOutput\').value=j.config_text||\'\'; toast(\'Config generated\',\'success\'); }catch(e){ toast(e.message,\'danger\'); } }\n\n function bootstrapThemeUrl(theme){ /* Note: Themes use the URL map generated by the backend, so they also work offline. */ const key=theme||"default"; return window.PYTORRENT?.bootstrapThemeUrls?.[key] || window.PYTORRENT?.bootstrapThemeUrls?.default || ""; }\n function applyBootstrapTheme(theme){ bootstrapTheme = theme || "default"; const link=$("bootstrapThemeStylesheet"); if(link) link.href = bootstrapThemeUrl(bootstrapTheme); if($("bootstrapThemeSelect")) $("bootstrapThemeSelect").value = bootstrapTheme; }\n function applyFontFamily(font){ fontFamily = font || "default"; document.documentElement.dataset.appFont = fontFamily; if($("fontFamilySelect")) $("fontFamilySelect").value = fontFamily; }\n function clampInterfaceScale(value){ value = Number(value || 100); if(!Number.isFinite(value)) value = 100; return Math.max(80, Math.min(140, Math.round(value / 5) * 5)); }\n function applyInterfaceScale(value){ interfaceScale = clampInterfaceScale(value); document.documentElement.style.setProperty("--ui-scale", String(interfaceScale / 100)); if($("interfaceScaleRange")) $("interfaceScaleRange").value = interfaceScale; if($("interfaceScaleValue")) $("interfaceScaleValue").textContent = `${interfaceScale}%`; scheduleRender(false); }\n async function saveAppearancePreferences(){ applyBootstrapTheme($("bootstrapThemeSelect")?.value || "default"); applyFontFamily($("fontFamilySelect")?.value || "default"); applyInterfaceScale($("interfaceScaleRange")?.value || interfaceScale); try{ await post("/api/preferences",{bootstrap_theme:bootstrapTheme,font_family:fontFamily,interface_scale:interfaceScale}); toast("Appearance preferences saved","success"); }catch(e){ toast(e.message,"danger"); } }\n if($("titleSpeedEnabled")) $("titleSpeedEnabled").checked=titleSpeedEnabled;\n\n function setupPeersRefresh(tab=activeTab()){ clearInterval(peersRefreshTimer); peersRefreshTimer=null; if($(\'peersRefreshSelect\')) $(\'peersRefreshSelect\').value=String(peersRefreshSeconds||0); if(tab===\'peers\' && peersRefreshSeconds>0){ peersRefreshTimer=setInterval(()=>{ if(activeTab()===\'peers\' && selectedHash) loadDetails(\'peers\'); }, peersRefreshSeconds*1000); } }\n function syncMobileMode(){ const auto=window.matchMedia&&window.matchMedia("(max-width: 900px)").matches; document.body.classList.toggle("mobile-mode", auto || document.body.classList.contains("mobile-mode-manual")); scheduleRender(true); }\n\n\n let automationRulesCache=[];\n let automationConditions=[];\n let automationEffects=[];\n\n function automationCondition(){\n const type=$(\'autoConditionType\')?.value||\'completed\';\n const cond={type, negate:!!$(\'autoCondNegate\')?.checked};\n if(type===\'no_seeds\'){ cond.seeds=Number($(\'autoCondSeeds\')?.value||0); cond.minutes=Number($(\'autoCondMinutes\')?.value||0); }\n if(type===\'ratio_gte\') cond.ratio=Number($(\'autoCondRatio\')?.value||1);\n // Note: Progress conditions compare the torrent completion percentage stored in the live torrent row.\n if(type===\'progress_gte\'||type===\'progress_lte\') cond.progress=Number($(\'autoCondProgress\')?.value||0);\n if(type===\'label_missing\'||type===\'label_has\') cond.label=$(\'autoCondLabel\')?.value||\'\';\n if(type===\'status\') cond.status=$(\'autoCondStatus\')?.value||\'Seeding\';\n if(type===\'path_contains\') cond.text=$(\'autoCondText\')?.value||\'\';\n return cond;\n }\n\n function automationEffect(){\n const type=$(\'autoEffectType\')?.value||\'add_label\';\n const eff={type};\n if(type===\'move\'){\n eff.path=$(\'autoEffectPath\')?.value||\'\';\n eff.move_data=!!$(\'autoMoveData\')?.checked;\n eff.recheck=!!$(\'autoMoveRecheck\')?.checked;\n eff.keep_seeding=!!$(\'autoMoveKeepSeeding\')?.checked;\n }\n if(type===\'add_label\'||type===\'remove_label\') eff.label=$(\'autoEffectLabel\')?.value||\'\';\n if(type===\'set_labels\') eff.labels=$(\'autoEffectLabels\')?.value||\'\';\n return eff;\n }\n\n function updateAutomationForm(){\n const ct=$(\'autoConditionType\')?.value||\'\';\n document.querySelectorAll(\'[data-auto-cond]\').forEach(el=>el.classList.toggle(\'d-none\', !el.dataset.autoCond.split(\',\').includes(ct)));\n const et=$(\'autoEffectType\')?.value||\'\';\n document.querySelectorAll(\'[data-auto-effect]\').forEach(el=>el.classList.toggle(\'d-none\', !el.dataset.autoEffect.split(\',\').includes(et)));\n }\n\n function conditionText(c={}){\n const base=c.type===\'no_seeds\'?`seeds <= ${c.seeds||0} for ${c.minutes||0} min`:c.type===\'ratio_gte\'?`ratio >= ${c.ratio}`:c.type===\'progress_gte\'?`progress >= ${c.progress||0}%`:c.type===\'progress_lte\'?`progress <= ${c.progress||0}%`:c.type===\'label_missing\'?`missing label ${c.label||\'\'}`:c.type===\'label_has\'?`has label ${c.label||\'\'}`:c.type===\'status\'?`status = ${c.status||\'\'}`:c.type===\'path_contains\'?`path contains ${c.text||\'\'}`:\'completed\';\n return c.negate?`NOT (${base})`:base;\n }\n function effectText(e={}){\n if(e.type===\'move\'){\n const flags=[];\n if(e.move_data) flags.push(\'move data\');\n if(e.recheck) flags.push(\'recheck\');\n if(e.keep_seeding) flags.push(\'keep seeding\');\n return `move to ${e.path||\'default path\'}${flags.length?` (${flags.join(\', \')})`:\'\'}`;\n }\n return e.type===\'add_label\'?`add label ${e.label||\'\'}`:e.type===\'remove_label\'?`remove label ${e.label||\'\'}`:e.type===\'set_labels\'?`set labels ${e.labels||\'\'}`:e.type;\n }\n function ruleSummary(r){\n const cs=(r.conditions||[]).map(conditionText).join(\' + \')||\'no conditions\';\n const es=(r.effects||[]).map(effectText).join(\' → \')||\'no actions\';\n return `${cs} → ${es}`;\n }\n\n function renderAutomationBuilder(){\n const cBox=$(\'automationConditionList\');\n if(cBox) cBox.innerHTML=automationConditions.length?automationConditions.map((c,i)=>`IF ${esc(conditionText(c))}`).join(\'\'):\'No conditions added yet.\';\n const eBox=$(\'automationEffectList\');\n if(eBox) eBox.innerHTML=automationEffects.length?automationEffects.map((e,i)=>`${i+1} ${esc(effectText(e))}`).join(\'\'):\'No actions added yet.\';\n }\n function resetAutomationForm(){\n if($(\'autoEditId\')) $(\'autoEditId\').value=\'\';\n if($(\'autoName\')) $(\'autoName\').value=\'\';\n if($(\'autoEnabled\')) $(\'autoEnabled\').checked=true;\n if($(\'autoCooldown\')) $(\'autoCooldown\').value=\'60\';\n automationConditions=[]; automationEffects=[];\n $(\'automationCancelEditBtn\')?.classList.add(\'d-none\');\n if($(\'automationSaveBtn\')) $(\'automationSaveBtn\').innerHTML=\' Save rule\';\n renderAutomationBuilder(); updateAutomationForm();\n }\n function editAutomationRule(rule){\n if(!rule) return;\n if($(\'autoEditId\')) $(\'autoEditId\').value=rule.id||\'\';\n if($(\'autoName\')) $(\'autoName\').value=rule.name||\'\';\n if($(\'autoEnabled\')) $(\'autoEnabled\').checked=!!rule.enabled;\n if($(\'autoCooldown\')) $(\'autoCooldown\').value=rule.cooldown_minutes ?? 60;\n automationConditions=Array.isArray(rule.conditions)?JSON.parse(JSON.stringify(rule.conditions)):[];\n automationEffects=Array.isArray(rule.effects)?JSON.parse(JSON.stringify(rule.effects)):[];\n $(\'automationCancelEditBtn\')?.classList.remove(\'d-none\');\n if($(\'automationSaveBtn\')) $(\'automationSaveBtn\').innerHTML=\' Update rule\';\n renderAutomationBuilder();\n }\n\n function summarizeActionObject(a={}){\n if(a.error) return `${esc(a.error)}`;\n const count=a.count || a.result?.count || a.result?.results?.length || \'\';\n const parts=[];\n if(a.type) parts.push(a.type);\n if(count) parts.push(`${count} torrent(s)`);\n if(a.path) parts.push(a.path);\n if(a.label) parts.push(`label ${a.label}`);\n if(a.labels) parts.push(`labels ${a.labels}`);\n if(a.move_data) parts.push(\'move data\');\n if(a.recheck) parts.push(\'recheck\');\n if(a.keep_seeding) parts.push(\'keep seeding\');\n return `${esc(parts.join(\' · \')||\'action\')}`;\n }\n function automationHistoryActions(raw){\n let actions=[];\n try{ actions=JSON.parse(raw||\'[]\'); }catch(e){ return `
${esc(raw||\'\')}
`; }\n if(!Array.isArray(actions)) actions=[actions];\n const summary=actions.map(summarizeActionObject).join(\' \');\n const details=esc(JSON.stringify(actions,null,2));\n // Note: Large automation payloads are collapsed so JSON never stretches the modal width.\n return `
${summary||\'No actions\'}
${details}
`;\n }\n\n function renderAutomationHistory(hist=[]){\n if(!$(\'automationHistory\')) return;\n const toolbar=\'
\';\n const rows=hist.map(h=>[humanDateCell(h.created_at),esc(h.rule_name||\'\'),esc(h.torrent_name||h.torrent_hash||\'\'),automationHistoryActions(h.actions_json||\'\')]);\n // Note: Automation history uses the shared responsive table wrapper so it stays inside narrow mobile modals.\n const body=hist.length?responsiveTable([\'Time\',\'Rule\',\'Torrent / batch\',\'Actions\'],rows,\'automation-history-table\'):\'
No automation history yet.
\';\n $(\'automationHistory\').innerHTML=toolbar+body;\n }\n\n async function clearAutomationHistory(){\n if(!confirm(\'Clear automation history?\')) return;\n setBusy(true);\n try{ const j=await fetch(\'/api/automations/history\',{method:\'DELETE\'}).then(r=>r.json()); if(!j.ok) throw new Error(j.error||\'Clear automation history failed\'); toast(`Automation logs deleted: ${j.deleted||0}`,\'success\'); renderAutomationHistory(j.history||[]); }\n catch(e){ toast(e.message,\'danger\'); }\n finally{ setBusy(false); }\n }\n\n async function exportAutomations(){\n try{ const j=await (await fetch(\'/api/automations/export\')).json(); if(!j.ok) throw new Error(j.error||\'Automation export failed\'); downloadJson(`pytorrent-automation-rules-${new Date().toISOString().slice(0,10)}.json`, j.export||j); toast(`Exported ${j.count||0} automation rule(s)`,\'success\'); }\n catch(e){ toast(e.message,\'danger\'); }\n }\n\n async function importAutomations(file){\n if(!file) return;\n try{ const payload=JSON.parse(await file.text()); const j=await post(\'/api/automations/import\',payload); toast(`Imported ${j.imported||0} automation rule(s)`,\'success\'); await loadAutomations(); }\n catch(e){ toast(e.message||\'Automation import failed\',\'danger\'); }\n finally{ if($(\'automationImportFile\')) $(\'automationImportFile\').value=\'\'; }\n }\n\n async function loadAutomations(){\n const j=await fetch(\'/api/automations\').then(r=>r.json());\n const rules=j.rules||[], hist=j.history||[];\n automationRulesCache=rules;\n if($(\'automationManager\')) $(\'automationManager\').innerHTML=rules.length?rules.map(r=>{\n const enabled=!!r.enabled;\n const toggleTitle=enabled?\'Disable automation\':\'Enable automation\';\n const toggleIcon=enabled?\'fa-toggle-on\':\'fa-toggle-off\';\n const toggleClass=enabled?\'btn-outline-warning\':\'btn-outline-success\';\n return `
${esc(r.name)} ${enabled?\'on\':\'off\'}
${esc(ruleSummary(r))} · cooldown ${esc(r.cooldown_minutes||0)} min
`;\n }).join(\'\'):\'
No automation rules.
\';\n renderAutomationHistory(hist);\n }\n\n async function toggleAutomationRule(rule){\n if(!rule) return;\n const payload={...rule, enabled:!rule.enabled};\n // Note: Toggle keeps the rule definition unchanged and only switches automatic execution on or off.\n setBusy(true);\n try{ await post(\'/api/automations\',payload); toast(payload.enabled?\'Automation enabled\':\'Automation disabled\',\'success\'); await loadAutomations(); }\n catch(e){ toast(e.message,\'danger\'); }\n finally{ setBusy(false); }\n }\n\n async function saveAutomation(){\n const currentCond=automationCondition();\n const currentEff=automationEffect();\n const conditions=automationConditions.length?automationConditions:[currentCond];\n const effects=automationEffects.length?automationEffects:[currentEff];\n const payload={id:Number($(\'autoEditId\')?.value||0)||undefined,name:$(\'autoName\')?.value||\'Automation rule\',enabled:!!$(\'autoEnabled\')?.checked,cooldown_minutes:Number($(\'autoCooldown\')?.value||60),conditions,effects};\n setBusy(true);\n try{ await post(\'/api/automations\',payload); toast(payload.id?\'Automation rule updated\':\'Automation rule saved\',\'success\'); resetAutomationForm(); await loadAutomations(); }\n catch(e){toast(e.message,\'danger\');}\n finally{setBusy(false);}\n }\n\n\n\n function cleanupCountCard(label, value, note=\'\'){\n return `
${esc(label)}${esc(value ?? 0)}${note?`${esc(note)}`:\'\'}
`;\n }\n function renderCleanup(data={}){\n const box=$(\'cleanupManager\'); if(!box) return;\n const retention=data.retention_days||{};\n const db=data.database||{};\n const cards=[\n cleanupCountCard(\'Job logs total\', data.jobs_total, `retention ${retention.jobs||\'-\'} days`),\n cleanupCountCard(\'Job logs clearable\', data.jobs_clearable, \'done / failed / cancelled\'),\n cleanupCountCard(\'Smart Queue logs\', data.smart_queue_history_total, `retention ${retention.smart_queue_history||\'-\'} days`),\n cleanupCountCard(\'Automation logs\', data.automation_history_total, `retention ${retention.automation_history||\'-\'} days`),\n cleanupCountCard(\'Database size\', db.size_h||db.size||\'-\', db.path||\'\')\n ];\n box.innerHTML=`
${cards.join(\'\')}
Job cleanup preserves pending and running jobs. Automation cleanup removes only history, not rules.
`;\n }\n async function loadCleanup(){\n const box=$(\'cleanupManager\'); if(!box) return;\n box.innerHTML=\' Loading cleanup data...\';\n try{\n const j=await (await fetch(\'/api/cleanup/summary\')).json();\n if(!j.ok) throw new Error(j.error||\'Cleanup summary failed\');\n renderCleanup(j.cleanup||{});\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n async function runCleanupAction(endpoint, label){\n if(!confirm(`${label}?`)) return;\n setBusy(true);\n try{\n const j=await post(endpoint,{});\n const deleted=typeof j.deleted===\'object\' ? Object.entries(j.deleted).map(([k,v])=>`${k}: ${v}`).join(\', \') : String(j.deleted ?? 0);\n toast(`Cleanup done (${deleted})`,\'success\');\n renderCleanup(j.cleanup||{});\n if(endpoint.includes(\'/jobs\')){ jobsPage=0; loadJobs(0).catch(()=>{}); }\n if(endpoint.includes(\'/smart-queue\')) loadSmartQueue().catch(()=>{});\n if(endpoint.includes(\'/automations\')) loadAutomations().catch(()=>{});\n }catch(e){ toast(e.message,\'danger\'); }\n finally{ setBusy(false); }\n }\n\n function diagCard(label,value,extra=\'\'){ return `
${esc(label)}${esc(value ?? \'-\')}
`; }\n\n // Note: Centralizes footer visibility so Preferences can hide items without removing existing status logic.\n function applyFooterPreferences(){\n document.querySelectorAll(\'[data-footer-item]\').forEach(el=>{\n const key=el.dataset.footerItem;\n el.classList.toggle(\'footer-pref-hidden\', footerItems[key] === false);\n });\n }\n function renderFooterPreferences(){\n const box=$(\'footerPreferences\');\n if(!box) return;\n box.innerHTML=FOOTER_ITEM_DEFS.map(([key,label])=>``).join(\'\');\n }\n async function saveFooterPreferences(){\n document.querySelectorAll(\'.footer-pref-toggle\').forEach(cb=>{ footerItems[cb.dataset.footerKey] = !!cb.checked; });\n applyFooterPreferences();\n renderFooterPreferences();\n try{ await post(\'/api/preferences\',{footer_items_json:footerItems}); toast(\'Footer preferences saved\',\'success\'); }\n catch(e){ toast(e.message,\'danger\'); }\n }\n function compactSpeedText(value){\n // Note: The footer has limited space, so it removes spaces only from speed labels.\n return String(value || \'0 B/s\').replace(/\\s+(?=[KMGT]?i?B\\/s$|B\\/s$)/, \'\');\n }\n function speedPairText(down, up){\n // Note: Consistent DL/UL pair formatting is used in the footer and diagnostics.\n return `${compactSpeedText(down)} / ${compactSpeedText(up)}`;\n }\n function peakDateText(value){\n // Note: Shortens the ISO timestamp from the database into a readable tooltip label.\n return value ? String(value).replace(\'T\',\' \').replace(/\\+00:00$/, \' UTC\') : \'-\';\n }\n function updateSpeedPeaks(peaks={}){\n // Note: Shows the session and all-time record next to current speeds in the footer.\n const session=peaks.session||{};\n const allTime=peaks.all_time||{};\n const sessionText=speedPairText(session.down_h, session.up_h);\n const allTimeText=speedPairText(allTime.down_h, allTime.up_h);\n if($(\'statPeakSession\')) $(\'statPeakSession\').textContent=sessionText;\n if($(\'statPeakAllTime\')) $(\'statPeakAllTime\').textContent=allTimeText;\n const box=$(\'statusSpeedPeaks\');\n if(box){\n box.title=`Peak speed DL/UL\\nSession: ${sessionText}\\nSession DL at: ${peakDateText(session.down_at)}\\nSession UL at: ${peakDateText(session.up_at)}\\nAll-time: ${allTimeText}\\nAll-time DL at: ${peakDateText(allTime.down_at)}\\nAll-time UL at: ${peakDateText(allTime.up_at)}`;\n }\n }\n function updateBrowserSpeedTitle(downH, upH){\n // Note: Shows DL/UL in the tab title in ruTorrent style; window.status is a fallback attempt for older browsers.\n if(downH != null) lastBrowserSpeed.down=downH || \'0 B/s\';\n if(upH != null) lastBrowserSpeed.up=upH || \'0 B/s\';\n const speedTitle=`DL ${lastBrowserSpeed.down} / UL ${lastBrowserSpeed.up}`;\n document.title=titleSpeedEnabled ? `${speedTitle} - ${BASE_TITLE}` : BASE_TITLE;\n try{ window.status=titleSpeedEnabled ? speedTitle : \'\'; }catch(e){}\n }\n async function saveTitleSpeedPreference(){\n // Note: The change applies immediately and is saved as a user preference.\n titleSpeedEnabled=!!$(\'titleSpeedEnabled\')?.checked;\n updateBrowserSpeedTitle();\n try{ await post(\'/api/preferences\',{title_speed_enabled:titleSpeedEnabled}); toast(\'Browser title speed saved\',\'success\'); }\n catch(e){ toast(e.message,\'danger\'); }\n }\n async function saveTrackerFaviconsPreference(){\n // Note: Tracker favicon toggle changes only icon rendering; tracker filter counts and actions stay untouched.\n trackerFaviconsEnabled=!!$(\'trackerFaviconsEnabled\')?.checked;\n renderTrackerFilters();\n try{ await post(\'/api/preferences\',{tracker_favicons_enabled:trackerFaviconsEnabled}); toast(\'Tracker favicon preference saved\',\'success\'); }\n catch(e){ toast(e.message,\'danger\'); }\n }\n function updateFooterClock(){\n const el=$(\'statClock\');\n if(el) el.textContent=new Date().toLocaleTimeString([], {hour:\'2-digit\', minute:\'2-digit\', second:\'2-digit\'});\n }\n function updateSocketStatus(s={}){\n const el=$(\'statSockets\');\n if(!el) return;\n const open=s.open_sockets;\n const max=s.max_open_sockets;\n el.textContent=open == null ? \'-\' : (max == null ? String(open) : `${open}/${max}`);\n const box=$(\'statusSockets\');\n if(box) box.title=open == null ? \'Open sockets unavailable from this rTorrent build\' : `Open rTorrent sockets${max == null ? \'\' : \' / max\'}: ${el.textContent}`;\n }\n\n function portStatusLabel(st){ return st===\'open\'?\'open\':st===\'closed\'?\'closed\':st===\'disabled\'?\'disabled\':st===\'error\'?\'error\':\'unknown\'; }\n function portStatusClass(st){ return st===\'open\'?\'port-ok\':st===\'closed\'?\'port-bad\':\'port-secondary\'; }\n function portStatusIcon(st){ return st===\'open\'?\'fa-circle-check\':st===\'closed\'?\'fa-circle-xmark\':\'fa-circle-question\'; }\n function portStatusBadge(data={},attrs=\'\',withPort=false){ const st=portStatusLabel(data.status); const active=data.open_port||data.port; const port=active?String(active):\'-\'; const label=withPort?`Port ${port} ${st}`:st; return ` ${esc(label)}`; }\n function portCheckedAt(data={}){ if(data.checked_at) return String(data.checked_at).replace(\'T\',\' \').replace(/\\+00:00$/,\' UTC\'); if(data.checked_at_epoch) return new Date(Number(data.checked_at_epoch)*1000).toLocaleString(); return \'\'; }\n function portCheckDetails(data={}){ const bits=[]; if(data.open_port) bits.push(`Open port: ${data.open_port}`); else if(data.port) bits.push(`First port: ${data.port}`); if(Array.isArray(data.ports)&&data.ports.length>1) bits.push(`Candidates: ${data.ports.join(\', \')}`); if(Array.isArray(data.checked_ports)&&data.checked_ports.length) bits.push(`Checked: ${data.checked_ports.join(\', \')}`); if(data.ports_truncated) bits.push(\'Port list truncated to safety limit\'); if(data.public_ip) bits.push(`Public IP: ${data.public_ip}`); if(data.remote) bits.push(\'Remote profile\'); if(data.source) bits.push(`Source: ${data.source}`); const checked=portCheckedAt(data); if(checked) bits.push(`Last check: ${checked}`); if(data.cached) bits.push(\'Cached result\'); if(data.error) bits.push(data.error); if(data.fallback_error) bits.push(data.fallback_error); return bits; }\n function renderPortCheck(data={}){\n if($(\'portCheckEnabled\')) $(\'portCheckEnabled\').checked=!!data.enabled;\n const details=portCheckDetails(data);\n const title=details.join(\' · \') || \'Port check disabled\';\n if($(\'portCheckBadge\')) $(\'portCheckBadge\').outerHTML=portStatusBadge(data,\'id="portCheckBadge" \');\n if($(\'portCheckInfo\')) $(\'portCheckInfo\').textContent=details.join(\' · \') || \'Uses YouGetSignal first. Manual check bypasses the 6h cache.\';\n if($(\'statusPortCheck\')){\n $(\'statusPortCheck\').classList.toggle(\'d-none\', !data.enabled);\n $(\'statusPortCheck\').title=title;\n }\n if($(\'statusPortCheckBadge\')) $(\'statusPortCheckBadge\').outerHTML=portStatusBadge(data,\'id="statusPortCheckBadge" \',true);\n }\n async function loadPreferences(){\n try{\n const j=await (await fetch(\'/api/preferences\')).json();\n const prefs=j.preferences||{};\n portCheckEnabled=!!Number(prefs.port_check_enabled ?? portCheckEnabled);\n automationToastsEnabled=Number(prefs.automation_toasts_enabled ?? (automationToastsEnabled?1:0))!==0;\n smartQueueToastsEnabled=Number(prefs.smart_queue_toasts_enabled ?? (smartQueueToastsEnabled?1:0))!==0;\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||\'\';\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||\'[]\'); }catch(_){ diskMonitorPaths=[]; }\n bootstrapTheme=prefs.bootstrap_theme||bootstrapTheme;\n fontFamily=prefs.font_family||fontFamily;\n interfaceScale=Number(prefs.interface_scale||interfaceScale||100);\n try{ footerItems={...footerItems,...JSON.parse(prefs.footer_items_json||\'{}\')}; }catch(_){}\n }catch(e){ console.warn(\'Preference load failed\', e); }\n if($(\'portCheckEnabled\')) $(\'portCheckEnabled\').checked=portCheckEnabled; if($(\'automationToastsEnabled\')) $(\'automationToastsEnabled\').checked=automationToastsEnabled; if($(\'smartQueueToastsEnabled\')) $(\'smartQueueToastsEnabled\').checked=smartQueueToastsEnabled; if($(\'diskMonitorMode\')) $(\'diskMonitorMode\').value=diskMonitorMode; if($(\'diskMonitorSelectedPath\')) $(\'diskMonitorSelectedPath\').value=diskMonitorSelectedPath; renderDiskMonitorPaths(); applyBootstrapTheme(bootstrapTheme); applyFontFamily(fontFamily); applyInterfaceScale(interfaceScale); renderFooterPreferences(); applyFooterPreferences(); await loadPortCheck(false); }\n function updateDiskMonitorUi(){\n // Note: Disk monitor radio switches are mirrored into the shared diskMonitorMode state.\n const mode=[\'default\',\'selected\',\'aggregate\'].includes(diskMonitorMode)?diskMonitorMode:\'default\';\n if($(\'diskMonitorMode\')) $(\'diskMonitorMode\').value=mode;\n document.querySelectorAll(\'.disk-monitor-mode\').forEach(input=>{ input.checked=input.value===mode; });\n const selectedDisabled=mode!==\'selected\' || !diskMonitorPaths.length;\n if($(\'diskMonitorSelectedPath\')) $(\'diskMonitorSelectedPath\').disabled=selectedDisabled;\n document.querySelectorAll(\'.disk-path-select\').forEach(btn=>{ btn.disabled=mode===\'aggregate\'; btn.classList.toggle(\'active\', btn.dataset.path===diskMonitorSelectedPath && mode===\'selected\'); });\n const hint=$(\'diskMonitorSelectedHint\');\n if(hint){\n hint.textContent=mode===\'aggregate\' ? \'Aggregate mode uses all monitored paths, so one-path selection is locked.\' : mode===\'default\' ? \'Default mode uses the rTorrent path, custom selection is optional.\' : diskMonitorPaths.length ? \'This path drives the footer progress bar.\' : \'Add at least one monitored path to use selected mode.\';\n }\n }\n function renderDiskMonitorPaths(){\n const select=$(\'diskMonitorSelectedPath\');\n if(select){\n const fallback=diskMonitorPaths.length?\'Choose monitored path\':\'No custom paths yet\';\n select.innerHTML=``+diskMonitorPaths.map(p=>``).join(\'\');\n select.value=diskMonitorSelectedPath||\'\';\n }\n const box=$(\'diskMonitorPaths\');\n if(box){\n box.innerHTML=diskMonitorPaths.length?diskMonitorPaths.map(p=>`
${esc(p)}${p===diskMonitorSelectedPath?\'Selected for footer progress\':\'Used in aggregate tooltip and available for selected mode\'}
`).join(\'\'):\'
No extra disk paths. Add a path above to monitor another storage directory.
\';\n }\n updateDiskMonitorUi();\n }\n async function saveNotificationPrefs(){ automationToastsEnabled=!!$(\'automationToastsEnabled\')?.checked; smartQueueToastsEnabled=!!$(\'smartQueueToastsEnabled\')?.checked; try{ await post(\'/api/preferences\',{automation_toasts_enabled:automationToastsEnabled,smart_queue_toasts_enabled:smartQueueToastsEnabled}); toast(\'Notification preferences saved\',\'success\'); }catch(e){ toast(e.message,\'danger\'); } }\n async function saveDiskMonitorPrefs(){\n // Note: Disk monitor mode is controlled by radio switches, so keep the in-memory mode instead of reading a removed select.\n const checkedMode=document.querySelector(\'.disk-monitor-mode:checked\')?.value;\n diskMonitorMode=[\'default\',\'selected\',\'aggregate\'].includes(checkedMode) ? checkedMode : ([\'default\',\'selected\',\'aggregate\'].includes(diskMonitorMode) ? diskMonitorMode : \'default\');\n diskMonitorSelectedPath=$(\'diskMonitorSelectedPath\')?.value||diskMonitorSelectedPath||\'\';\n try{\n const res=await post(\'/api/preferences\',{disk_monitor_paths_json:diskMonitorPaths,disk_monitor_mode:diskMonitorMode,disk_monitor_selected_path:diskMonitorSelectedPath});\n const prefs=res.preferences||{};\n // Note: Sync saved values back from the API so the footer uses the persisted disk source, not a stale UI guess.\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||diskMonitorSelectedPath||\'\';\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||\'[]\'); }catch(_){ }\n renderDiskMonitorPaths();\n await refreshUserDiskUsage(true);\n toast(\'Disk monitor saved\',\'success\');\n }catch(e){ toast(e.message,\'danger\'); }\n }\n async function savePortCheckPref(){ portCheckEnabled=!!$(\'portCheckEnabled\')?.checked; try{ await post(\'/api/preferences\',{port_check_enabled:portCheckEnabled}); toast(\'Preferences saved\',\'success\'); await loadPortCheck(false); }catch(e){ toast(e.message,\'danger\'); } }\n async function loadPortCheck(force=false){ try{ const res=force?await post(\'/api/port-check\',{}):await (await fetch(\'/api/port-check\')).json(); if(!res.ok) throw new Error(res.error||\'Port check failed\'); renderPortCheck(res.port_check||{}); }catch(e){ renderPortCheck({status:\'error\',enabled:portCheckEnabled,error:e.message}); } }\n async function loadAppStatus(){\n const box=$(\'appStatusManager\'); if(!box) return;\n box.innerHTML=\' Loading diagnostics...\';\n try{\n const [j,smart]=await Promise.all([\n fetch(\'/api/app/status\').then(r=>r.json()),\n fetch(\'/api/smart-queue?history_limit=100\').then(r=>r.json()).catch(()=>({ok:false}))\n ]);\n if(!j.ok) throw new Error(j.error||\'Failed to load diagnostics\');\n const st=j.status||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, profile=st.profile||{}, pc=st.port_check||{}, cleanup=st.cleanup||{}, db=cleanup.database||{};\n const peaks=st.speed_peaks||{}, peakSession=peaks.session||{}, peakAllTime=peaks.all_time||{};\n const smartStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null;\n const cards=[\n diagCard(\'pyTorrent PID\', py.pid), diagCard(\'pyTorrent uptime\', `${py.uptime_seconds||0}s`), diagCard(\'Memory RSS\', py.memory_rss_h||py.memory_rss),\n diagCard(\'Threads\', py.threads), diagCard(\'CPU\', `${py.cpu_percent ?? \'-\'}%`), diagCard(\'Jobs total\', py.jobs_total),\n diagCard(\'Worker threads\', py.worker_threads), diagCard(\'Python\', py.python||\'-\'), diagCard(\'DB size\', db.size_h||\'-\'),\n diagCard(\'Active profile\', profile.name||profile.id||\'-\'), diagCard(\'API response time\', `${st.api_ms ?? \'-\'} ms`),\n diagCard(\'Peak session DL/UL\', speedPairText(peakSession.down_h, peakSession.up_h)), diagCard(\'Peak all-time DL/UL\', speedPairText(peakAllTime.down_h, peakAllTime.up_h)),\n diagCard(\'Job logs clearable\', cleanup.jobs_clearable ?? \'-\'), diagCard(\'Smart Queue logs\', cleanup.smart_queue_history_total ?? \'-\'), diagCard(\'Automation logs\', cleanup.automation_history_total ?? \'-\'),\n diagCard(\'Port check\', portStatusLabel(pc.status), pc.status===\'closed\'?\'diag-error\':\'\'), diagCard(\'Incoming port\', pc.port||\'-\'), diagCard(\'Port check source\', pc.source||(pc.enabled?\'unknown\':\'disabled\')),\n diagCard(\'SCGI status\', scgi.ok?\'OK\':\'ERROR\', scgi.ok?\'\':\'diag-error\'), diagCard(\'SCGI URL\', scgi.url||\'-\'), diagCard(\'SCGI connect\', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:\'-\'),\n diagCard(\'SCGI first byte\', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:\'-\'), diagCard(\'SCGI total\', scgi.total_ms!=null?`${scgi.total_ms} ms`:\'-\'),\n diagCard(\'Request bytes\', scgi.request_bytes), diagCard(\'Response bytes\', scgi.response_bytes), diagCard(\'XML bytes\', scgi.xml_bytes), diagCard(\'rTorrent version\', scgi.client_version||\'-\')\n ];\n const smartBlock=`
Smart Queue statistics
${renderSmartQueueNerdStats(smartStats)}`;\n box.innerHTML=`
${cards.join(\'\')}
${smartBlock}${scgi.error?`
${esc(scgi.error)}
`:\'\'}`;\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n\n function torrentStatsCard(label, value, note=\'\'){\n return `
${esc(label)}${esc(value ?? \'-\')}${note?`${esc(note)}`:\'\'}
`;\n }\n function renderTorrentStats(stats={}){\n const box=$(\'torrentStatsManager\');\n if(!box) return;\n const age=Number(stats.age_seconds||0);\n const updated=stats.updated_at ? String(stats.updated_at).replace(\'T\',\' \').replace(/\\+00:00$/,\' UTC\') : \'-\';\n const cards=[\n torrentStatsCard(\'Torrents\', stats.torrent_count, `${stats.complete_count||0} complete / ${stats.incomplete_count||0} incomplete`),\n torrentStatsCard(\'Torrent size\', stats.total_torrent_size_h || fmtBytes(stats.total_torrent_size)),\n torrentStatsCard(\'Files size\', stats.total_file_size_h || fmtBytes(stats.total_file_size), `${stats.file_count||0} files`),\n torrentStatsCard(\'Seeds / peers\', `${stats.seeds_total||0} / ${stats.peers_total||0}`, \'current sum from last sample\'),\n torrentStatsCard(\'Speed DL / UL\', `${stats.down_rate_total_h||\'0 B/s\'} / ${stats.up_rate_total_h||\'0 B/s\'}`),\n torrentStatsCard(\'Sampled\', stats.sampled_torrents ?? 0, stats.stale?\'cache is stale\':\'cache is fresh\')\n ];\n if($(\'torrentStatsMeta\')) $(\'torrentStatsMeta\').textContent=`Updated: ${updated}, age: ${age}s`;\n const errors=Array.isArray(stats.errors)&&stats.errors.length ? `
File metadata warnings: ${esc(stats.errors.length)} torrent(s). ${esc(stats.error||\'\')}
` : \'\';\n box.innerHTML=`
${cards.join(\'\')}
${errors}`;\n }\n async function loadTorrentStats(force=false){\n const box=$(\'torrentStatsManager\');\n if(!box) return;\n box.innerHTML=\' Loading torrent statistics...\';\n try{\n const j=await (await fetch(`/api/torrent-stats${force?\'?force=1\':\'\'}`)).json();\n if(!j.ok) throw new Error(j.error||\'Torrent statistics failed\');\n renderTorrentStats(j.stats||{});\n if(force) toast(\'Torrent statistics refreshed\',\'success\');\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n\n\n function addToolTab(tool, icon, label, beforeTool=\'appstatus\'){\n if(document.querySelector(`.tool-tab[data-tool="${tool}"]`)) return;\n const nav=document.querySelector(\'#toolsModal .nav.nav-pills\');\n if(!nav) return;\n const li=document.createElement(\'li\');\n li.className=\'nav-item\';\n li.innerHTML=``;\n const before=document.querySelector(`#toolsModal .tool-tab[data-tool="${beforeTool}"]`)?.closest(\'.nav-item\');\n nav.insertBefore(li,before||null);\n li.querySelector(\'.tool-tab\')?.addEventListener(\'click\',()=>activateToolTab(tool));\n }\n function inlineSwitch(id,label=\'Enable\',extraClass=\'\'){\n return ``;\n }\n function plannerToggleRow(id,title,description){\n return `
${title}${description}
${inlineSwitch(id)}
`;\n }\n function plannerSpeedCard(prefix,title,sub){\n return `
\n ${title}\n ${sub}\n
Unlimited
\n
\n \n \n \n \n \n \n
\n
\n \n \n \n \n
\n Slider uses Mbit/s. Numeric fields store B/s for rTorrent.\n
`;\n }\n'; diff --git a/pytorrent/static/js/state.js b/pytorrent/static/js/state.js new file mode 100644 index 0000000..dfc3bd4 --- /dev/null +++ b/pytorrent/static/js/state.js @@ -0,0 +1 @@ +export const stateSource = ' const $ = (id) => document.getElementById(id);\n const esc = (s) => String(s ?? "").replace(/[&<>\'"]/g, c => ({"&":"&","<":"<",">":">","\'":"'",\'"\':"""}[c]));\n const ROW_HEIGHT = 32, OVERSCAN = 14;\n const torrents = new Map();\n const browserViewPrefs = (()=>{ try{return JSON.parse(localStorage.getItem(\'pyTorrent.mobileViewPrefs\')||\'{}\')||{};}catch(e){return {};} })();\n const savedFilter = String(browserViewPrefs.activeFilter || window.PYTORRENT?.activeFilter || "all");\n // Note: Mobile has both "All" and "All trackers" options, so keep the exact selected option separate from the shared filter state.\n let mobileActiveFilterKey = String(browserViewPrefs.mobileFilterKey || savedFilter || "all");\n let visibleRows = [], selected = new Set(), selectedHash = null, lastSelectedHash = null, activeFilter = savedFilter.startsWith("tracker:") ? "all" : (savedFilter || "all");\n let activeTrackerFilter = savedFilter.startsWith("tracker:") ? savedFilter.slice(8) : "";\n const SORT_KEYS = new Set(["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"]);\n const savedSort = browserViewPrefs.sortState || window.PYTORRENT?.torrentSort || {};\n let sortState = {key: SORT_KEYS.has(savedSort.key) ? savedSort.key : "name", dir: Number(savedSort.dir) < 0 ? -1 : 1}, renderPending = false, renderVersion = 0, lastRenderSignature = "";\n const MOBILE_SORT_STEPS = [\n {key:"down_rate", dir:-1, label:"DL"},\n {key:"up_rate", dir:-1, label:"UL"},\n {key:"progress", dir:-1, label:"Progress"},\n {key:"ratio", dir:-1, label:"Ratio"},\n {key:"size", dir:-1, label:"Size"},\n {key:"seeds", dir:-1, label:"Seeds"},\n {key:"created", dir:-1, label:"Added"},\n {key:"name", dir:1, label:"Name"}\n ];\n let lastLimits = {down: 0, up: 0}, pendingBusy = 0, pathTarget = null, lastPathParent = "/";\n const traffic = [], systemUsage = [];\n const socket = (typeof io === "function") ? io({transports:["polling"], reconnection:true, reconnectionAttempts:Infinity, reconnectionDelay:700, reconnectionDelayMax:5000, timeout:8000}) : {connected:false,on(){},emit(){},io:{on(){}}};\n const COLUMN_DEFS = [["status","Status",false],["size","Size",false],["progress","Progressbar",false],["down_rate","DL",false],["up_rate","UL",false],["eta","ETA",false],["seeds","Seeds",false],["peers","Peers",false],["ratio","Ratio",false],["path","Path",false],["label","Label",false],["ratio_group","Ratio group",false],["down_total","Downloaded",true],["to_download","To download",true],["up_total","Uploaded",true],["created","Added",true],["priority","Priority",true],["state","State",true],["active","Active",true],["complete","Complete",true],["hashing","Hashing",true],["message","Message",true],["hash","Hash",true]];\n const DEFAULT_HIDDEN_COLUMNS = new Set(COLUMN_DEFS.filter(([, , hiddenByDefault]) => hiddenByDefault).map(([key]) => key));\n const savedColumns = window.PYTORRENT?.tableColumns || {};\n const DEFAULT_COLUMN_WIDTHS = {\n select: 34, name: 360, status: 110, size: 90, progress: 120,\n down_rate: 86, up_rate: 86, eta: 92, seeds: 70, peers: 70,\n ratio: 72, path: 300, label: 140, ratio_group: 130,\n down_total: 120, to_download: 120, up_total: 120, created: 150,\n priority: 80, state: 70, active: 70, complete: 82, hashing: 82,\n message: 220, hash: 280\n };\n const COLUMN_WIDTH_MIN = 44;\n const COLUMN_WIDTH_MAX = 720;\n const explicitlyShownColumns = new Set(savedColumns.shown || []);\n let hiddenColumns = new Set([...(savedColumns.hidden || []), ...[...DEFAULT_HIDDEN_COLUMNS].filter(key => !explicitlyShownColumns.has(key))]);\n // Note: Column widths are persisted with the existing column preferences payload, so no database migration is needed.\n function normalizeColumnWidths(value={}){\n const allowed = new Set([\'select\', ...COLUMN_DEFS.map(([key]) => key)]);\n const normalized = {...DEFAULT_COLUMN_WIDTHS};\n Object.entries(value || {}).forEach(([key, width])=>{\n if(allowed.has(key)) normalized[key] = clampNumber(width, COLUMN_WIDTH_MIN, COLUMN_WIDTH_MAX, DEFAULT_COLUMN_WIDTHS[key] || 120);\n });\n return normalized;\n }\n let columnWidths = normalizeColumnWidths(savedColumns.widths || {});\n if(browserViewPrefs.columnWidths) columnWidths = normalizeColumnWidths({...columnWidths, ...browserViewPrefs.columnWidths});\n const DEFAULT_MOBILE_COLUMNS = new Set(["status","progress","down_rate","up_rate","eta","seeds","peers","ratio","path"]);\n const MOBILE_COLUMN_DEFS = COLUMN_DEFS.map(([key,label]) => [key, label, DEFAULT_MOBILE_COLUMNS.has(key)]);\n function normalizeMobileColumns(value={}){\n const normalized = {...Object.fromEntries(MOBILE_COLUMN_DEFS.map(([key,,shown])=>[key, shown]))};\n Object.entries(value || {}).forEach(([key, shown])=>{\n if(key === "speed"){ normalized.down_rate = !!shown; normalized.up_rate = !!shown; }\n else if(key === "seed_peer"){ normalized.seeds = !!shown; normalized.peers = !!shown; }\n else if(key in normalized) normalized[key] = !!shown;\n });\n return normalized;\n }\n let mobileColumns = normalizeMobileColumns(savedColumns.mobile || {});\n if(browserViewPrefs.mobileColumns) mobileColumns = normalizeMobileColumns({...mobileColumns, ...browserViewPrefs.mobileColumns});\n let mobileSmartFiltersEnabled = browserViewPrefs.mobileSmartFiltersEnabled ?? savedColumns.mobileSmartFiltersEnabled ?? true;\n let knownLabels = [];\n let jobsPage = 0, jobsLimit = 25, jobsTotal = 0, smartHistoryExpanded = false, plannerHistoryExpanded = false;\n let automationSmartQueueStats = null;\n let peersRefreshTimer = null;\n let peersRefreshSeconds = Number(window.PYTORRENT?.peersRefreshSeconds || 0);\n let portCheckEnabled = !!Number(window.PYTORRENT?.portCheckEnabled || 0);\n let bootstrapTheme = window.PYTORRENT?.bootstrapTheme || "default";\n let fontFamily = window.PYTORRENT?.fontFamily || "default";\n let interfaceScale = Number(window.PYTORRENT?.interfaceScale || 100);\n let titleSpeedEnabled = !!Number(window.PYTORRENT?.titleSpeedEnabled || 0);\n let trackerFaviconsEnabled = !!Number(window.PYTORRENT?.trackerFaviconsEnabled || 0);\n let automationToastsEnabled = window.PYTORRENT?.automationToastsEnabled !== false && Number(window.PYTORRENT?.automationToastsEnabled ?? 1) !== 0;\n let smartQueueToastsEnabled = window.PYTORRENT?.smartQueueToastsEnabled !== false && Number(window.PYTORRENT?.smartQueueToastsEnabled ?? 1) !== 0;\n let diskMonitorPaths = Array.isArray(window.PYTORRENT?.diskMonitorPaths) ? [...window.PYTORRENT.diskMonitorPaths] : [];\n let diskMonitorMode = window.PYTORRENT?.diskMonitorMode || "default";\n let diskMonitorSelectedPath = window.PYTORRENT?.diskMonitorSelectedPath || "";\n let lastUserDiskFetchAt = 0;\n let userDiskFetchInFlight = false;\n let userDiskFetchSeq = 0;\n let activeProfileId = window.PYTORRENT?.activeProfile || null;\n let trackerSummary = {hashes:{}, trackers:[], scanned:0, errors:[]};\n let trackerSummaryStatus = \'idle\';\n let trackerSummarySignature = "";\n let trackerSummaryTimer = null;\n let lastLabelFiltersSignature = "";\n let lastTrackerFiltersSignature = "";\n let lastMobileFiltersSignature = "";\n const BASE_TITLE = document.title || "pyTorrent";\n const lastBrowserSpeed = {down: "0 B/s", up: "0 B/s"};\n const FOOTER_STATUS_STORAGE_KEY = "pytorrent.footerStatus.v1";\n const FOOTER_RT_METRIC_KEYS = new Set(["sockets", "rt_downloads", "rt_uploads", "rt_http", "rt_files", "rt_port"]);\n const FOOTER_ITEM_DEFS = [\n ["cpu", "CPU"], ["ram", "RAM"], ["usage_chart", "CPU/RAM chart"], ["disk", "Disk"],\n ["version", "rTorrent version"], ["speed_down", "Download speed"], ["speed_up", "Upload speed"],\n ["speed_peaks", "Peak speeds"], ["limits", "Speed limits"], ["totals", "Total transfer"], ["port_check", "Port check"],\n ["clock", "Clock"], ["sockets", "Open sockets"], ["rt_downloads", "Downloads (D)"], ["rt_uploads", "Uploads (U)"], ["rt_http", "HTTP (H)"], ["rt_files", "Files (F)"], ["rt_port", "Incoming port"], ["shown", "Shown torrents"], ["selected", "Selected torrents"], ["docs", "API docs"]\n ];\n const DEFAULT_FOOTER_ITEMS = Object.fromEntries(FOOTER_ITEM_DEFS.map(([key]) => [key, !FOOTER_RT_METRIC_KEYS.has(key)]));\n let footerItems = {...DEFAULT_FOOTER_ITEMS, ...(window.PYTORRENT?.footerItems || {})};\n let modalLabels = new Set(), defaultDownloadPath = null;\n let hasTorrentSnapshot = false, initialLoaderDone = false, rtConfigOriginal = new Map(), rtConfigFieldTypes = new Map(), rtConfigOriginalApplyOnStart = false;\n let rtorrentStartingMessage = \'\';\n let rtorrentStartingTimer = null, rtorrentStartingSince = 0;\n const RTORRENT_STALE_GRACE_MS = 30000;\n let torrentSummary = null;\n let profileCache = new Map();\n const hasActiveProfile = !!window.PYTORRENT?.activeProfile;\n let firstRunSetupShown = false;\n const activeOperations = new Map();\n // Note: Keeps live filter tooltips stable while the pointer is over a filter button.\n const filterTooltipState = new WeakMap();\n\n const toastGroups = new Map();\n const preferenceSaveTimers = new Map();\n function clampNumber(value, min, max, fallback){\n const num = Number(value);\n if(!Number.isFinite(num)) return fallback;\n return Math.max(min, Math.min(max, Math.round(num)));\n }\n function savePreferencePatch(payload, delay=350){\n const key = Object.keys(payload).sort().join(\'|\');\n clearTimeout(preferenceSaveTimers.get(key));\n preferenceSaveTimers.set(key, setTimeout(async()=>{\n try{ await post(\'/api/preferences\', payload); }catch(e){ console.warn(\'Preference save failed\', e); }\n finally{ preferenceSaveTimers.delete(key); }\n }, delay));\n }\n function currentActiveFilterPreference(){\n return activeTrackerFilter ? `tracker:${activeTrackerFilter}` : activeFilter;\n }\n function saveTorrentSortPreference(){\n // Note: Sorting is persisted together with the current filter so mobile tracker scope cannot fall back to All trackers after a quick sort change.\n saveBrowserViewPrefs();\n savePreferencePatch({torrent_sort_json:{key:sortState.key, dir:sortState.dir}, active_filter:currentActiveFilterPreference()}, 200);\n }\n function saveBrowserViewPrefs(extra={}){\n try{\n const prev=JSON.parse(localStorage.getItem(\'pyTorrent.mobileViewPrefs\')||\'{}\')||{};\n localStorage.setItem(\'pyTorrent.mobileViewPrefs\', JSON.stringify({...prev, activeFilter:currentActiveFilterPreference(), mobileFilterKey:mobileActiveFilterKey, sortState, mobileColumns, columnWidths, ...extra}));\n }catch(e){}\n }\n function saveActiveFilterPreference(){\n saveBrowserViewPrefs();\n savePreferencePatch({active_filter:currentActiveFilterPreference()}, 250);\n }\n function cleanColumnPrefsHidden(values){ return [...values].filter(key => key !== "progressbar"); }\n async function resetViewPreferences(){\n activeFilter = "all";\n activeTrackerFilter = "";\n mobileActiveFilterKey = "all";\n sortState = {key:"name", dir:1};\n mobileColumns = normalizeMobileColumns();\n hiddenColumns = new Set(DEFAULT_HIDDEN_COLUMNS);\n columnWidths = normalizeColumnWidths();\n const height = applyDetailPanelHeight(255);\n renderColumnManager();\n document.querySelectorAll(\'.filter\').forEach(x=>x.classList.toggle(\'active\', x.dataset.filter === \'all\'));\n if($(\'tableWrap\')) $(\'tableWrap\').scrollTop = 0;\n if($(\'mobileList\')) $(\'mobileList\').scrollTop = 0;\n try{\n await post(\'/api/preferences\', {active_filter:"all", torrent_sort_json:{key:"name", dir:1}, detail_panel_height:height, table_columns_json:JSON.stringify({hidden:cleanColumnPrefsHidden(DEFAULT_HIDDEN_COLUMNS), shown:[], mobile:mobileColumns, mobileSmartFiltersEnabled:true, widths:columnWidths})});\n toast(\'View preferences reset\',\'success\');\n }catch(e){ toast(e.message,\'danger\'); }\n scheduleRender(true);\n }\n function applyDetailPanelHeight(height){\n const safeHeight = clampNumber(height, 160, 720, 255);\n document.documentElement.style.setProperty(\'--detail-panel-height\', `${safeHeight}px`);\n const handle = $(\'detailResizeHandle\');\n if(handle) handle.setAttribute(\'aria-valuenow\', String(safeHeight));\n return safeHeight;\n }\n function saveDetailPanelHeight(height){\n const safeHeight = applyDetailPanelHeight(height);\n savePreferencePatch({detail_panel_height:safeHeight}, 250);\n }\n function setupDetailResizer(){\n const handle = $(\'detailResizeHandle\');\n const content = document.querySelector(\'.content\');\n if(!handle || !content) return;\n applyDetailPanelHeight(window.PYTORRENT?.detailPanelHeight || 255);\n let startY = 0, startHeight = 0;\n const onMove = (event) => {\n const pointerY = event.clientY ?? event.touches?.[0]?.clientY ?? startY;\n applyDetailPanelHeight(startHeight - (pointerY - startY));\n scheduleRender(false);\n };\n const onUp = () => {\n document.body.classList.remove(\'resizing-details\');\n document.removeEventListener(\'pointermove\', onMove);\n document.removeEventListener(\'pointerup\', onUp);\n const value = parseInt(getComputedStyle(document.documentElement).getPropertyValue(\'--detail-panel-height\'), 10);\n saveDetailPanelHeight(value);\n };\n handle.addEventListener(\'pointerdown\', (event) => {\n event.preventDefault();\n startY = event.clientY;\n startHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue(\'--detail-panel-height\'), 10) || 255;\n document.body.classList.add(\'resizing-details\');\n document.addEventListener(\'pointermove\', onMove);\n document.addEventListener(\'pointerup\', onUp);\n });\n }\n function toastKey(msg, type){ return `${type}::${String(msg ?? \'\')}`; }\n function isAutomationEvent(msg){ return msg?.automation === true || msg?.source === \'automation\'; }\n function shouldShowOperationToast(msg){\n // Note: Automation-created operation toasts follow the Automation toasts preference.\n return !isAutomationEvent(msg) || automationToastsEnabled;\n }\n function toast(msg, type="secondary") {\n // Note: Groups identical toasts fired together, so repeated automation/action events do not flood the UI.\n const h=$(\'toastHost\');\n if(!h) return;\n const text=String(msg ?? \'\');\n const key=toastKey(text,type);\n const existing=toastGroups.get(key);\n if(existing){\n existing.count += 1;\n const badge=existing.el.querySelector(\'.toast-count\');\n if(badge){ badge.textContent=`×${existing.count}`; badge.classList.remove(\'d-none\'); }\n clearTimeout(existing.timer);\n existing.timer=setTimeout(()=>{ existing.el.remove(); toastGroups.delete(key); },3500);\n return;\n }\n const el=document.createElement(\'div\');\n el.className=`toast-item text-bg-${type}`;\n el.innerHTML=`${esc(text)}×1`;\n h.appendChild(el);\n const entry={el,count:1,timer:null};\n entry.timer=setTimeout(()=>{ el.remove(); toastGroups.delete(key); },3500);\n toastGroups.set(key,entry);\n }\n function setBusy(on, label=\'Working...\'){ pendingBusy += on ? 1 : -1; if(pendingBusy<0) pendingBusy=0; const loader=$(\'globalLoader\'); if(loader){ loader.classList.toggle(\'d-none\', pendingBusy===0); const span=loader.querySelector(\'span:last-child\'); if(span) span.textContent=label; } $(\'busyBadge\')?.classList.toggle(\'d-none\', pendingBusy===0); }\n function setInitialLoader(title, text){ if(initialLoaderDone) return; if($(\'initialLoaderTitle\') && title) $(\'initialLoaderTitle\').textContent=title; if($(\'initialLoaderText\') && text) $(\'initialLoaderText\').textContent=text; }\n function hideInitialLoader(){ if(initialLoaderDone) return; initialLoaderDone=true; $(\'initialLoader\')?.classList.add(\'is-hidden\'); }\n function buttonBusy(btn,on){ if(!btn)return; btn.disabled=on; const label=btn.querySelector(\'.btn-label\'); if(label){ if(!label.dataset.orig) label.dataset.orig=label.innerHTML; label.innerHTML=on?`Working...`:label.dataset.orig; }}\n function activeTab(){ return document.querySelector(\'#detailTabs .nav-link.active\')?.dataset.tab || \'general\'; }\n function loadingMarkup(label=\'Loading data...\'){ return `
${esc(label)}
`; }\n // Note: Keeps empty-state colspans aligned with the desktop torrent table column count.\n function torrentColumnSpan(){ return 25; }\n function loadingTableRow(label=\'Loading torrents...\'){ return `${loadingMarkup(label)}`; }\n // Note: Handles fresh installations with no configured rTorrent profile, so the UI does not wait forever for a snapshot.\n function renderNoProfileState(){\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body = $(\'torrentBody\');\n if(body){\n body.innerHTML = `
No rTorrent profile configured.Add the first rTorrent profile to start loading torrents.
`;\n }\n if($(\'detailPane\')) $(\'detailPane\').innerHTML = \'Add rTorrent profile first.\';\n }\n function clearRtorrentStartingState(){\n rtorrentStartingMessage=\'\';\n rtorrentStartingSince=0;\n if(rtorrentStartingTimer){ clearTimeout(rtorrentStartingTimer); rtorrentStartingTimer=null; }\n }\n function rtorrentStartingHtml(error=\'\'){\n const details=error ? `${esc(error)}` : \'Port can already be open while XML-RPC/SCGI is still warming up. The list will load automatically after rTorrent answers.\';\n return `
rTorrent is starting or not responding yet.Waiting for torrent data from the active profile.${details}
`;\n }\n function scheduleRtorrentStartingState(error=\'\'){\n rtorrentStartingMessage = String(error || \'rTorrent is starting or not responding yet.\');\n if(!(hasTorrentSnapshot && torrents.size)){\n renderRtorrentStartingState(rtorrentStartingMessage, true);\n return;\n }\n if(!rtorrentStartingSince) rtorrentStartingSince = Date.now();\n if(rtorrentStartingTimer) return;\n rtorrentStartingTimer = setTimeout(() => {\n rtorrentStartingTimer = null;\n if(rtorrentStartingMessage) renderRtorrentStartingState(rtorrentStartingMessage, true);\n }, RTORRENT_STALE_GRACE_MS);\n }\n function renderRtorrentStartingState(error=\'\', force=false){\n rtorrentStartingMessage = String(error || \'rTorrent is starting or not responding yet.\');\n if(hasTorrentSnapshot && torrents.size && !force) return;\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body=$(\'torrentBody\');\n if(body) body.innerHTML = `${rtorrentStartingHtml(rtorrentStartingMessage)}`;\n const list=$(\'mobileList\');\n if(list) list.innerHTML = `
${rtorrentStartingHtml(rtorrentStartingMessage)}
`;\n if($(\'detailPane\')) $(\'detailPane\').innerHTML = \'rTorrent is starting. Details will appear after the first successful response.\';\n }\n function parseDate(value){ const raw=String(value||\'\').trim(); if(!raw) return null; const d=new Date(raw); return Number.isNaN(d.getTime()) ? null : {raw,d}; }\n function formatDate(value, mode=\'short\'){\n const parsed=parseDate(value);\n if(!parsed) return String(value||\'\');\n const opts=mode===\'full\'\n ? {year:\'numeric\',month:\'2-digit\',day:\'2-digit\',hour:\'2-digit\',minute:\'2-digit\',second:\'2-digit\'}\n : {month:\'2-digit\',day:\'2-digit\',hour:\'2-digit\',minute:\'2-digit\'};\n return new Intl.DateTimeFormat(\'pl-PL\', opts).format(parsed.d).replace(\',\', \'\');\n }\n function dateCell(value){ const parsed=parseDate(value); if(!parsed) return esc(value||\'\'); return `${esc(formatDate(value))}`; }\n // Note: Human-readable date cells keep full timestamps visible without squeezing table columns.\n function humanDateCell(value){ const parsed=parseDate(value); if(!parsed) return esc(value||\'\'); const full=formatDate(value,\'full\'); return `${esc(full)}`; }\n function compactCell(value, max=120){ const text=String(value||""); if(!text) return ""; const short=text.length>max ? `${text.slice(0, Math.floor(max*0.62))}…${text.slice(-Math.floor(max*0.28))}` : text; return `${esc(short)}`; }\n function progressBar(value, extraClass=\'\'){ const pct=Math.max(0,Math.min(100,Number(value||0))); const hue=Math.round((pct/100)*120); const light=30+Math.round((pct/100)*5); const bg=pct<=0?\'transparent\':pct>=100?\'var(--torrent-progress-complete)\':`hsl(${hue} 52% ${light}%)`; const done=pct>=100?\' is-complete\':\'\'; const cls=extraClass?` ${extraClass}`:\'\'; return `
${esc(pct)}%
`; }\n function progress(t){ return progressBar(t.progress); }\n'; diff --git a/pytorrent/static/js/torrentDetails.js b/pytorrent/static/js/torrentDetails.js new file mode 100644 index 0000000..83dfb1c --- /dev/null +++ b/pytorrent/static/js/torrentDetails.js @@ -0,0 +1 @@ +export const torrentDetailsSource = " function formatDateTime(seconds){ const n=Number(seconds||0); if(!n) return '-'; try{ return new Date(n*1000).toLocaleString(); }catch(e){ return '-'; } }\n function joinRemotePath(base,name){\n const b=String(base||'').trim();\n const n=String(name||'').trim();\n if(!b && !n) return '-';\n if(!n) return b || '-';\n if(!b) return n;\n return `${b.replace(/\\/+$/,'')}/${n.replace(/^\\/+/,'')}`;\n }\n function renderGeneral(){\n const t=torrents.get(selectedHash);\n if(!t){ $('detailPane').innerHTML='Select a torrent.'; return; }\n const labels=labelNames(t.label).map(l=>` ${esc(l)}`).join(' ') || '-';\n const ratioGroup=t.ratio_group ? `${esc(t.ratio_group)}` : 'Not assigned';\n const statusClass=t.status==='Seeding'?'success':t.status==='Downloading'?'primary':t.status==='Checking'?'warning':t.status==='Paused'?'secondary':t.status==='Stopped'?'dark':'secondary';\n const fullPath=joinRemotePath(t.path,t.name);\n const cards=[\n ['Size', esc(t.size_h||'-')],\n ['Downloaded', esc(t.down_total_h||'-')],\n ['Uploaded', esc(t.up_total_h||'-')],\n ['Ratio', esc(t.ratio??'-')],\n ['Download speed', esc(t.down_rate_h||'-')],\n ['Upload speed', esc(t.up_rate_h||'-')],\n ['Seeds / Peers', `${esc(t.seeds??0)} / ${esc(t.peers??0)}`],\n ['ETA', esc(t.eta_h||'-')],\n ['Added', esc(formatDateTime(t.created))],\n ['Priority', esc(t.priority??'-')],\n ].map(([label,value])=>`
${label}${value}
`).join('');\n $('detailPane').innerHTML=`\n
\n
\n
${esc(t.name||'-')}
${esc(t.status||'-')}
\n
Directory${esc(t.path||'-')}
\n
Full data path${esc(fullPath)}
\n
\n
Hash${esc(t.hash||'-')}
\n
\n
${cards}
\n
Labels${labels}
Ratio rule${ratioGroup}
Message${esc(t.message||'-')}
`;\n }\n const FILE_PRIORITY_LABELS = {0: \"Skip\", 1: \"Normal\", 2: \"High\"};\n function priorityClass(priority){ priority=Number(priority||0); return priority===2?\"text-bg-success\":priority===0?\"text-bg-secondary\":\"text-bg-primary\"; }\n function renderFilePrioritySelect(f){ const p=Number(f.priority||0); return ``; }\n function selectedFileIndexes(){ return [...document.querySelectorAll('#detailPane .file-check:checked')].map(cb=>Number(cb.dataset.index)); }\n function downloadSelectedFiles(){\n if(!selectedHash) return;\n const indexes=selectedFileIndexes();\n if(!indexes.length) return toast('No files selected','warning');\n if(indexes.length===1){ downloadResponse(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${indexes[0]}/download`,{},'file.bin','Preparing file...').catch(e=>toast(e.message,'danger')); return; }\n downloadZip(indexes);\n }\n async function downloadZip(indexes=null){\n if(!selectedHash) return;\n try{\n await downloadResponse(`/api/torrents/${encodeURIComponent(selectedHash)}/files/download.zip`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({indexes})},`${selectedHash.slice(0,12)}-files.zip`,'Preparing ZIP...');\n }catch(e){ toast(e.message,'danger'); }\n }\n function renderFiles(files){\n const pane=$('detailPane');\n const rows=(files||[]).map(f=>`${esc(f.path)}${esc(f.size_h)}${progressBar(f.progress ?? 0, 'file-progress')}${esc(FILE_PRIORITY_LABELS[Number(f.priority||0)]||f.priority)}${renderFilePrioritySelect(f)}`).join('');\n pane.innerHTML=`
Priority
Download
Changes are applied immediately in rTorrent.
${rows || ''}
PathSizeDonePrioritySet priorityGet
No files.
`;\n }\n function fileTreeNode(node){\n const children=(node.children||[]).map(fileTreeNode).join('');\n if(node.type==='file') return `
  • ${esc(node.name||node.path)} ${esc(node.size_h||'')}
  • `;\n return `
  • ${esc(node.name||'Files')} ${esc(node.size_h||'')}
      ${children}
  • `;\n }\n async function loadFileTree(){\n if(!selectedHash) return;\n const box=$('fileTreePanel');\n if(!box) return;\n box.classList.toggle('d-none');\n if(box.classList.contains('d-none')) return;\n box.innerHTML=' Loading tree...';\n try{ const j=await (await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/tree`)).json(); if(!j.ok) throw new Error(j.error||'Tree failed'); box.innerHTML=`
      ${fileTreeNode(j.tree||{})}
    `; }\n catch(e){ box.innerHTML=`
    ${esc(e.message)}
    `; }\n }\n async function setFilePriorities(items){\n if(!selectedHash || !items.length) return;\n setBusy(true);\n try{\n const res=await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/priority`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({files:items})});\n const j=await res.json();\n if(!j.ok || (j.errors&&j.errors.length)) throw new Error(j.errors?.[0]?.error || j.error || 'Priority update failed');\n toast(`Updated ${j.updated?.length||items.length} file priority item(s)`,'success');\n await loadDetails('files');\n }catch(e){ toast(e.message,'danger'); } finally{ setBusy(false); }\n }\n\n const CHUNK_DENSITY_OPTIONS = {\n compact: {label: 'Compact', maxCells: 2400},\n normal: {label: 'Normal', maxCells: 1400},\n detailed: {label: 'Detailed', maxCells: 700},\n };\n const CHUNK_FILTER_OPTIONS = [\n ['all', 'All'],\n ['problem', 'Missing + partial'],\n ['missing', 'Missing'],\n ['partial', 'Partial'],\n ['seen', 'Seen by peers'],\n ['complete', 'Complete'],\n ];\n let chunkFilterMode = localStorage.getItem('chunkFilterMode') || 'all';\n let chunkDensityMode = localStorage.getItem('chunkDensityMode') || 'normal';\n let lastChunkData = null;\n\n function chunkMaxCellsForDensity(){\n // Note: Density changes the API grouping level and the CSS cell size together.\n return CHUNK_DENSITY_OPTIONS[chunkDensityMode]?.maxCells || CHUNK_DENSITY_OPTIONS.normal.maxCells;\n }\n function chunkCellsForFilter(cells){\n const list = Array.isArray(cells) ? cells : [];\n if(chunkFilterMode === 'all') return list;\n if(chunkFilterMode === 'problem') return list.filter(cell => ['missing','partial'].includes(cell.status));\n return list.filter(cell => cell.status === chunkFilterMode);\n }\n function chunkStatusLabel(status){\n return ({complete:'Complete', partial:'Partial', missing:'Missing', seen:'Seen by peers'}[status] || 'Unknown');\n }\n function chunkCellTitle(cell){\n const first = cell.first_chunk ?? '-';\n const last = cell.last_chunk ?? first;\n const pct = Number(cell.percent||0).toFixed(1).replace(/\\.0$/,'');\n const completed = Number(cell.completed ?? 0);\n const total = Number(cell.total ?? cell.unit_count ?? 1);\n const grouped = cell.grouped ? `Grouped visual cell: ${cell.unit_count || 1} piece(s)` : 'Single piece';\n return [\n `Pieces: ${first}-${last}`,\n `Status: ${chunkStatusLabel(cell.status)}`,\n `Progress: ${pct}%`,\n `Complete pieces: ${completed}/${total}`,\n grouped,\n ].join(' | ');\n }\n function chunkCellMarkup(cell){\n const pct = Math.max(0, Math.min(100, Number(cell.percent || 0)));\n const cls = `chunk-cell chunk-${esc(cell.status || 'missing')}${cell.grouped ? ' is-grouped' : ''}`;\n return ``;\n }\n function renderChunkLegend(summary){\n const items=[['complete','Complete'],['partial','Partial'],['missing','Missing'],['seen','Seen by peers']];\n return items.map(([key,label])=>`${label} ${esc(summary?.[key]??0)}`).join('');\n }\n function renderChunkControls(){\n const filters = CHUNK_FILTER_OPTIONS.map(([value,label]) => ``).join('');\n const densities = Object.entries(CHUNK_DENSITY_OPTIONS).map(([value,cfg]) => ``).join('');\n return `
    `;\n }\n function selectedChunkRange(){\n const selected=[...document.querySelectorAll('#detailPane .chunk-cell.is-selected')].map(el=>({first:Number(el.dataset.firstChunk||0),last:Number(el.dataset.lastChunk||0)}));\n if(!selected.length) return null;\n return {first_chunk:Math.min(...selected.map(x=>x.first)),last_chunk:Math.max(...selected.map(x=>x.last)),count:selected.length};\n }\n function updateChunkSelectionInfo(){\n const info=$('chunkSelectionInfo');\n if(!info) return;\n const range=selectedChunkRange();\n const filteredCount=document.querySelectorAll('#detailPane .chunk-cell').length;\n const totalCount=lastChunkData?.cells?.length || 0;\n if(range){\n info.textContent=`Selected ${range.count} cell(s), pieces ${range.first_chunk}-${range.last_chunk}.`;\n return;\n }\n const filterText=chunkFilterMode === 'all' ? '' : ` Showing ${filteredCount}/${totalCount} cell(s).`;\n info.textContent=`Select one or more visual cells to prioritize files that overlap that range.${filterText}`;\n }\n function renderChunks(data){\n const pane=$('detailPane');\n const chunks=data||{};\n lastChunkData=chunks;\n const allCells=chunks.cells||[];\n const cells=chunkCellsForFilter(allCells);\n const grouped=chunks.grouped?'grouped for performance':'';\n const meta=[\n ['Piece size', chunks.chunk_size_h || '-'],\n ['Pieces', chunks.size_chunks ?? 0],\n ['Complete pieces', chunks.completed_chunks ?? 0],\n ['Hashed pieces', chunks.chunks_hashed ?? 0],\n ['Visual cells', chunks.visual_cells ?? allCells.length],\n ].map(([label,value])=>`
    ${esc(label)}${esc(value)}
    `).join('');\n pane.innerHTML=`\n
    \n
    \n
    Chunks ${grouped}
    \n
    \n \n \n \n
    \n
    \n
    ${meta}
    \n
    \n
    ${renderChunkLegend(chunks.summary||{})}
    \n ${renderChunkControls()}\n
    \n
    \n
    ${cells.map(chunkCellMarkup).join('') || '
    No chunk cells for this filter.
    '}
    \n
    `;\n updateChunkSelectionInfo();\n }\n async function runChunkAction(action,payload={}){\n if(!selectedHash) return toast('No torrent selected','warning');\n setBusy(true);\n try{\n const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/chunks/${action}`,payload);\n toast(j.message || `Chunk action ${action} done`,'success');\n await loadDetails('chunks');\n }catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n document.addEventListener('change', e=>{\n const filter=e.target.closest('#chunkFilterMode');\n if(filter){\n chunkFilterMode=filter.value || 'all';\n localStorage.setItem('chunkFilterMode', chunkFilterMode);\n if(lastChunkData && activeTab()==='chunks') renderChunks(lastChunkData);\n return;\n }\n const density=e.target.closest('#chunkDensityMode');\n if(density){\n chunkDensityMode=density.value || 'normal';\n localStorage.setItem('chunkDensityMode', chunkDensityMode);\n if(activeTab()==='chunks') loadDetails('chunks');\n }\n });\n function peerBadges(p){\n const badges=[];\n if(p.encrypted) badges.push('enc');\n if(p.incoming) badges.push('in');\n if(p.snubbed) badges.push('snub');\n if(p.banned) badges.push('ban');\n return badges.join(' ') || '-';\n }\n function renderPeers(peers){\n const rows=(peers||[]).map(p=>[flag(p.country_iso),`${esc(p.ip)}`,esc(p.country),esc(p.city),esc(p.client),progressBar(p.completed,'peer-progress'),esc(p.down_rate_h),esc(p.up_rate_h),esc(p.port),peerBadges(p)]);\n $('detailPane').innerHTML=table(['Flag','IP','Country','City','Client','%','DL','UL','Port','Flags'],rows);\n }\n function fmtTs(value){ const n=Number(value||0); if(!n) return '-'; try{return new Date(n*1000).toLocaleString();}catch(e){return String(n);} }\n function trackerSeedsPeers(t){ const hasScrape = t.seeds !== null || t.peers !== null; return hasScrape ? `${t.seeds ?? \"-\"} / ${t.peers ?? \"-\"}` : \"-\"; }\n function renderTrackers(trackers){\n // Note: Tracker URL editing is intentionally replaced by safe deletion; adding trackers remains unchanged.\n const pane=$('detailPane');\n const list=trackers||[];\n const canDelete=list.length>1;\n const rows=list.map(t=>{\n const idx=esc(t.index), url=esc(t.url);\n const deleteDisabled=canDelete ? '' : ' disabled title=\"At least one tracker must remain\"';\n return [`#${idx}`, `${url || '-'}`, t.enabled?'yes':'no', esc(trackerSeedsPeers(t)), esc(t.downloaded ?? '-'), fmtTs(t.last_announce), `
    `];\n });\n pane.innerHTML=`
    ${table(['#','URL','On','Seeds / Peers','Done','Last announce','Actions'], rows.length?rows:[[ '-','No trackers.','','','','','' ]])}`;\n }\n async function trackerAction(action,payload={}){\n if(!selectedHash) return toast('No torrent selected','warning');\n setBusy(true);\n try{\n const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/trackers/${action}`,payload);\n toast(j.message || `Tracker ${action} done`,'success');\n await loadDetails('trackers');\n }catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n async function loadDetails(tab){ const t=torrents.get(selectedHash); if($('peersRefreshBox')) $('peersRefreshBox').classList.toggle('d-none', tab!=='peers'); setupPeersRefresh(tab); if(!t)return; if(tab==='general') return renderGeneral(); if(tab==='log'){ $('detailPane').innerHTML=`
    ${esc(t.message||'No logs')}
    `; return; } const pane=$('detailPane'); pane.innerHTML=`
    Loading ${esc(tab)}...
    `; try{ const detailUrl = tab==='chunks' ? `/api/torrents/${encodeURIComponent(selectedHash)}/chunks?max_cells=${chunkMaxCellsForDensity()}` : `/api/torrents/${encodeURIComponent(selectedHash)}/${tab}`; const res=await fetch(detailUrl,{headers:{'Accept':'application/json'}}); const text=await res.text(); let json; try{ json=JSON.parse(text); }catch(parseErr){ throw new Error(`Invalid API response for ${tab}. HTTP ${res.status}`); } if(!res.ok || !json.ok) throw new Error(json.error||`HTTP ${res.status}`); if(tab!==activeTab()) return; if(tab==='files') renderFiles(json.files||[]); if(tab==='chunks') renderChunks(json.chunks||{}); if(tab==='peers') renderPeers(json.peers||[]); if(tab==='trackers') renderTrackers(json.trackers||[]); }catch(e){pane.innerHTML=`
    ${esc(e.message)}
    `;} }\n"; diff --git a/pytorrent/static/js/torrents.js b/pytorrent/static/js/torrents.js new file mode 100644 index 0000000..1b9df11 --- /dev/null +++ b/pytorrent/static/js/torrents.js @@ -0,0 +1 @@ +export const torrentsSource = " // Note: Displays status filter summaries calculated and cached by the backend API.\n const FILTER_COUNT_IDS = {all:'countAll', downloading:'countDownloading', seeding:'countSeeding', paused:'countPaused', checking:'countChecking', error:'countError', stopped:'countStopped', moving:'countMoving'};\n function formatFilterBytes(value){ return fmtBytes(value).replace(/\\.0 (?=GiB|TiB)/, ' '); }\n function filterMetaLine(bucket){\n if(!bucket || !Number(bucket.count||0)) return '';\n const disk=Number(bucket.disk_bytes ?? bucket.completed_bytes ?? 0);\n return `Data ${formatFilterBytes(disk)}`;\n }\n function filterNeedsDownloadDetails(type, bucket){\n if(!bucket || !Number(bucket.count||0)) return false;\n if(type==='downloading') return true;\n if(type!=='paused' && type!=='stopped') return false;\n const size=Number(bucket.size||0);\n const completed=Number(bucket.completed_bytes ?? bucket.disk_bytes ?? 0);\n const remaining=Number(bucket.remaining_bytes ?? Math.max(0, size-completed));\n const progress=Number(bucket.progress_percent ?? (size ? (completed / size) * 100 : 0));\n return size > 0 && remaining > 0 && progress < 100;\n }\n function filterTooltipLine(bucket, type){\n if(!bucket || !Number(bucket.count||0)) return '';\n const size=Number(bucket.size||0);\n const disk=Number(bucket.disk_bytes ?? bucket.completed_bytes ?? 0);\n const completed=Number(bucket.completed_bytes ?? disk);\n const remaining=Number(bucket.remaining_bytes ?? Math.max(0, size-completed));\n const progress=Number(bucket.progress_percent ?? (size ? (completed / size) * 100 : 0));\n const left=Number(bucket.remaining_percent ?? Math.max(0, 100-progress));\n const lines=[`Data: ${formatFilterBytes(disk)}`];\n if(filterNeedsDownloadDetails(type, bucket)){\n lines.push(`Total to download: ${formatFilterBytes(size)}`);\n lines.push(`Downloaded: ${formatFilterBytes(completed)} (${progress.toFixed(1)}%)`);\n lines.push(`Left: ${formatFilterBytes(remaining)} (${left.toFixed(1)}%)`);\n }\n return lines.join('\\n');\n }\n function applyFilterTooltip(button, tooltip, ariaLabel){\n if(tooltip){\n button.title = tooltip;\n button.setAttribute('aria-label', ariaLabel);\n } else {\n button.removeAttribute('title');\n button.removeAttribute('aria-label');\n }\n }\n function ensureStableFilterTooltip(button){\n if(filterTooltipState.has(button)) return filterTooltipState.get(button);\n const state = {hovering:false, pending:null};\n filterTooltipState.set(button, state);\n button.addEventListener('mouseenter', () => {\n state.hovering = true;\n state.pending = null;\n });\n button.addEventListener('mouseleave', () => {\n state.hovering = false;\n if(state.pending){\n applyFilterTooltip(button, state.pending.tooltip, state.pending.ariaLabel);\n state.pending = null;\n }\n });\n return state;\n }\n // Note: Freezes tooltip content during hover; the next hover receives the newest live summary.\n function setStableFilterTooltip(button, tooltip, ariaLabel){\n const state = ensureStableFilterTooltip(button);\n if(state.hovering){\n state.pending = {tooltip, ariaLabel};\n return;\n }\n applyFilterTooltip(button, tooltip, ariaLabel);\n }\n function movingOperationRows(){\n // Note: The Moving filter is based only on active move operations, not queued jobs.\n return [...torrents.values()].filter(t=>{\n const op=activeOperationFor(t);\n return op?.action==='move' && op?.state==='running';\n });\n }\n function movingFilterCount(){ return movingOperationRows().length; }\n function torrentMatchesFilterType(t, type){\n if(type==='all') return true;\n if(type==='downloading') return !isChecking(t) && !t.complete && t.state && !t.paused;\n if(type==='seeding') return !isChecking(t) && t.complete && t.state && !t.paused;\n if(type==='paused') return !!t.paused || t.status==='Paused';\n if(type==='checking') return isChecking(t);\n if(type==='error') return torrentHasError(t);\n if(type==='stopped') return !t.state && !isChecking(t);\n if(type==='moving'){\n const op=activeOperationFor(t);\n return op?.action==='move' && op?.state==='running';\n }\n return true;\n }\n function trackerScopedRows(){\n const rows=[...torrents.values()];\n return activeTrackerFilter ? rows.filter(t=>rowHasTracker(t, activeTrackerFilter)) : rows;\n }\n function summarizeFilterRows(rows, type){\n const matched=rows.filter(t=>torrentMatchesFilterType(t, type));\n const bucket={count:matched.length,size:0,disk_bytes:0,completed_bytes:0,remaining_bytes:0};\n matched.forEach(t=>{\n const size=Number(t.size||0);\n const progress=Number(t.progress||0);\n const completed=Number(t.completed_bytes ?? t.completed ?? t.down_total ?? (size && Number.isFinite(progress) ? size * Math.max(0, Math.min(100, progress)) / 100 : 0));\n bucket.size += size;\n bucket.completed_bytes += completed;\n bucket.disk_bytes += completed;\n bucket.remaining_bytes += Math.max(0, size-completed);\n });\n bucket.progress_percent = bucket.size ? (bucket.completed_bytes / bucket.size) * 100 : 0;\n bucket.remaining_percent = Math.max(0, 100-bucket.progress_percent);\n return bucket;\n }\n function filterSummaryBucket(type){\n if(type==='moving') return {count:movingFilterCount()};\n if(activeTrackerFilter) return summarizeFilterRows(trackerScopedRows(), type);\n return torrentSummary?.filters?.[type] || {count:0};\n }\n function setFilterSummary(type){\n const el=$(FILTER_COUNT_IDS[type]);\n if(!el) return;\n const bucket=filterSummaryBucket(type);\n const meta=type==='moving' ? '' : filterMetaLine(bucket, type);\n const tooltip=type==='moving' && bucket.count ? 'Active moving operations' : filterTooltipLine(bucket, type);\n el.innerHTML=`${esc(bucket.count||0)}${meta?`${esc(meta)}`:''}`;\n const button=el.closest('.filter');\n if(button){\n const ariaLabel = tooltip ? `${button.dataset.filter || type}: ${tooltip.replace(/\\n/g, ', ')}` : '';\n button.classList.toggle('d-none', type==='moving' && !Number(bucket.count||0));\n setStableFilterTooltip(button, tooltip, ariaLabel);\n }\n }\n function labelNames(value){ return String(value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean).filter((x,i,a)=>a.indexOf(x)===i); }\n function labelValue(labels){ return [...new Set((labels||[]).map(x=>String(x||'').trim()).filter(Boolean))].join(', '); }\n function rowHasLabel(t,label){ return labelNames(t.label).includes(label); }\n function trackerRowsForHash(hash){ return trackerSummary.hashes?.[hash] || []; }\n function rowHasTracker(t, domain){ return trackerRowsForHash(t.hash).some(x=>x.domain===domain); }\n function torrentHasError(t){ return !!torrentWarning(t); }\n function isChecking(t){ return t?.status==='Checking' || Number(t?.hashing||0)>0; }\n function rowVisible(t){ const q=($('searchBox')?.value||'').toLowerCase(); if(q && !torrentSearchText(t).includes(q)) return false; if(activeTrackerFilter && !rowHasTracker(t, activeTrackerFilter)) return false; if(FILTER_COUNT_IDS[activeFilter]) return torrentMatchesFilterType(t, activeFilter); if(activeFilter.startsWith('label:')) return rowHasLabel(t,activeFilter.slice(6)); if(activeFilter.startsWith('smart:')) return smartViewVisible(t,activeFilter); return true; }\n function compareRows(a,b){\n const k=sortState.key;\n if(k==='eta'){\n // Note: ETA is displayed as text but sorted by eta_seconds; unavailable ETA stays last in both directions.\n const av=Number(a.eta_seconds||0), bv=Number(b.eta_seconds||0);\n const aMissing=!Number.isFinite(av)||av<=0, bMissing=!Number.isFinite(bv)||bv<=0;\n if(aMissing&&bMissing) return String(a.name||'').localeCompare(String(b.name||''));\n if(aMissing) return 1;\n if(bMissing) return -1;\n return (av>bv?1:avNumber(bv||0))?1:(Number(av||0)0?\" \":\" \"; }\n function mobileSortDef(){ return MOBILE_SORT_STEPS.find(x=>x.key===sortState.key && x.dir===sortState.dir) || MOBILE_SORT_STEPS.find(x=>x.key===sortState.key) || MOBILE_SORT_STEPS[0]; }\n function mobileSortLabel(){ const def=mobileSortDef(); return `${def.label} ${sortState.dir>0?'\u2191':'\u2193'}`; }\n function cycleMobileSort(){\n const current=MOBILE_SORT_STEPS.findIndex(x=>x.key===sortState.key && x.dir===sortState.dir);\n const next=MOBILE_SORT_STEPS[(current+1) % MOBILE_SORT_STEPS.length];\n sortState={key:next.key, dir:next.dir};\n saveTorrentSortPreference();\n if($('tableWrap'))$('tableWrap').scrollTop=0;\n if($('mobileList'))$('mobileList').scrollTop=0;\n scheduleRender(true);\n }\n function setMobileFilterValue(value){\n const key=String(value||'all');\n mobileActiveFilterKey=key;\n if(key.startsWith('tracker:')){\n activeTrackerFilter=key.slice(8);\n activeFilter='all';\n }else{\n activeTrackerFilter='';\n activeFilter=key || 'all';\n }\n syncFilterButtons();\n saveActiveFilterPreference();\n if($('tableWrap'))$('tableWrap').scrollTop=0;\n if($('mobileList'))$('mobileList').scrollTop=0;\n scheduleRender(true);\n }\n function updateSortHeaders(){\n document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>{\n const title = th.querySelector('.column-title');\n const base = th.dataset.baseText || (title ? title.textContent.trim() : th.textContent.trim());\n th.dataset.baseText = base;\n if(title) title.innerHTML = `${esc(base)}${sortIcon(th.dataset.sort)}`;\n else th.innerHTML = `${esc(base)}${sortIcon(th.dataset.sort)}`;\n th.classList.toggle('sorted', sortState.key === th.dataset.sort);\n });\n }\n // Note: Refreshes sidebar counters from the cached API summary, not from browser-side aggregation.\n function syncFilterButtons(){\n // Note: Tracker is a parent scope; regular filters stay active inside the selected tracker.\n document.querySelectorAll('.filter').forEach(x=>{\n const key=x.dataset.filter||'';\n if(key.startsWith('tracker:')) x.classList.toggle('active', activeTrackerFilter===key.slice(8));\n else if(x.dataset.trackerScope==='all') x.classList.toggle('active', !activeTrackerFilter);\n else x.classList.toggle('active', key===activeFilter);\n });\n }\n function renderCounts(){\n // Note: When the last move operation finishes, the hidden filter does not leave an empty list active.\n if(activeFilter==='moving' && !movingFilterCount()){ activeFilter='all'; mobileActiveFilterKey='all'; }\n syncFilterButtons();\n Object.keys(FILTER_COUNT_IDS).forEach(setFilterSummary);\n $('statSelected').textContent=selected.size;\n }\n function bindSidebarFilterClicks(root){\n root?.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{\n const key=b.dataset.filter||'all';\n if(key.startsWith('tracker:')){ activeTrackerFilter=key.slice(8); mobileActiveFilterKey=key; }\n else if(b.dataset.trackerScope==='all'){ activeTrackerFilter=''; mobileActiveFilterKey='tracker:'; }\n else { activeTrackerFilter=''; activeFilter=key; mobileActiveFilterKey=key; }\n syncFilterButtons();\n saveActiveFilterPreference();\n if($('tableWrap')) $('tableWrap').scrollTop=0;\n if($('mobileList')) $('mobileList').scrollTop=0;\n scheduleRender(true);\n }));\n }\n function renderLabelFilters(force=false){\n const box=$('labelFilters');\n if(!box) return;\n const counts=new Map();\n trackerScopedRows().forEach(t=>labelNames(t.label).forEach(l=>counts.set(l,(counts.get(l)||0)+1)));\n const labels=[...counts.keys()].filter(l=>counts.get(l)>0).sort((a,b)=>a.localeCompare(b));\n if(activeFilter.startsWith('label:') && !counts.has(activeFilter.slice(6))){ activeFilter='all'; mobileActiveFilterKey='all'; }\n const sig=labels.map(l=>`${l}:${counts.get(l)}`).join('|');\n if(!force && sig===lastLabelFiltersSignature){ syncFilterButtons(); return; }\n lastLabelFiltersSignature=sig;\n box.innerHTML=labels.length?`
    Labels
    ${labels.map(l=>``).join('')}`:'';\n bindSidebarFilterClicks(box);\n }\n function trackerFavicon(tracker){\n const domain=typeof tracker==='string'?tracker:(tracker?.domain||'');\n if(!trackerFaviconsEnabled || !domain) return '';\n // Note: Normal rendering must use cached/static URLs only. Avoid refresh=1 here, otherwise scroll-triggered paints can re-warm icons repeatedly.\n const fallback=`/api/trackers/favicon/${encodeURIComponent(domain)}`;\n const src=(typeof tracker==='object' && tracker?.favicon_url) ? tracker.favicon_url : fallback;\n return `\"\"`;\n }\n function trackerFilterPlaceholder(){\n if(trackerSummaryStatus==='loading') return '
    Loading cached trackers...
    ';\n if(trackerSummaryStatus==='error') return '
    Tracker list unavailable
    ';\n if(Number(trackerSummary.pending||0)) return `
    Tracker cache: ${esc(trackerSummary.cached||0)}/${esc(trackerSummary.scanned||0)}
    `;\n if(hasTorrentSnapshot && torrents.size) return '
    No trackers found
    ';\n return '
    Waiting for torrents...
    ';\n }\n function renderTrackerFilters(force=false){\n const box=$('trackerFilters');\n if(!box) return;\n const trackers=trackerSummary.trackers || [];\n // Note: Keep the selected tracker while the async summary is loading or temporarily incomplete; otherwise sorting can reset mobile scope to All trackers.\n if(activeTrackerFilter && trackerSummaryStatus==='ready' && trackers.length && !trackers.some(t=>t.domain===activeTrackerFilter)) activeTrackerFilter='';\n const sig=[\n trackerSummaryStatus,\n trackerFaviconsEnabled ? 1 : 0,\n trackerSummary.pending || 0,\n trackerSummary.cached || 0,\n trackerSummary.scanned || 0,\n trackers.map(t=>`${t.domain}:${t.count||0}:${t.favicon_url||''}`).join('|')\n ].join('::');\n if(!force && sig===lastTrackerFiltersSignature){ syncFilterButtons(); return; }\n lastTrackerFiltersSignature=sig;\n // Note: Tracker filter section is always visible, so an empty or failed tracker scan does not look like a missing feature.\n const rows=trackers.length\n ? `` + trackers.map(t=>``).join('')\n : trackerFilterPlaceholder();\n box.innerHTML=`
    Trackers
    ${rows}`;\n bindSidebarFilterClicks(box);\n }\n async function refreshTrackerSummary(force=false){\n const hashes=[...torrents.keys()].sort();\n const sig=`${hashes.length}:${hashes[0]||''}:${hashes[hashes.length-1]||''}:${trackerFaviconsEnabled?1:0}`;\n if(!force && sig===trackerSummarySignature && !Number(trackerSummary.pending||0)) return;\n trackerSummarySignature=sig;\n if(!hashes.length){ trackerSummary={hashes:{},trackers:[],scanned:0,errors:[],pending:0,cached:0}; trackerSummaryStatus='empty'; renderTrackerFilters(); return; }\n trackerSummaryStatus=(trackerSummary.trackers||[]).length?'ready':'loading';\n renderTrackerFilters();\n try{\n // Note: Do not send 13k hashes in the URL; the backend uses a local snapshot and reads the cache in small chunks.\n const j=await (await fetch('/api/trackers/summary?scan_limit=0&warm=1&bg_limit=80')).json();\n if(!j.ok && !j.summary) throw new Error(j.error||'Tracker summary failed');\n trackerSummary=j.summary||{hashes:{},trackers:[],scanned:0,errors:[],pending:0,cached:0};\n trackerSummaryStatus=(trackerSummary.trackers||[]).length?'ready':Number(trackerSummary.pending||0)?'empty':'empty';\n renderTrackerFilters();\n scheduleRender(true);\n if(Number(trackerSummary.pending||0)>0){\n clearTimeout(trackerSummaryTimer);\n trackerSummaryTimer=setTimeout(()=>refreshTrackerSummary(true).catch(()=>{}), 5000);\n }\n }catch(e){ trackerSummaryStatus='error'; renderTrackerFilters(); console.warn('Tracker summary failed', e); }\n }\n function scheduleTrackerSummary(force=false){\n clearTimeout(trackerSummaryTimer);\n trackerSummaryTimer=setTimeout(()=>refreshTrackerSummary(force).catch(()=>{}), force?50:600);\n }\n function buildVisibleRows(){ visibleRows=[...torrents.values()].filter(rowVisible).sort(compareRows); $('statShown').textContent=visibleRows.length; }\n function visibleColumnKeys(){ return ['select', ...COLUMN_DEFS.map(([key])=>key)].filter(key => key === 'select' || !hiddenColumns.has(key)); }\n function applyColumnWidths(){\n // Note: Widths are applied to headers and virtualized body rows, keeping all cells aligned after live renders.\n const table = document.querySelector('.torrent-table');\n if(!table) return;\n let total = 0;\n visibleColumnKeys().forEach(key => { total += columnWidths[key] || DEFAULT_COLUMN_WIDTHS[key] || 120; });\n table.style.width = `${total}px`;\n table.style.minWidth = `${total}px`;\n document.querySelectorAll('.torrent-table [data-col]').forEach(el=>{\n const key = el.dataset.col;\n const width = columnWidths[key] || DEFAULT_COLUMN_WIDTHS[key] || 120;\n el.style.width = `${width}px`;\n el.style.minWidth = `${width}px`;\n el.style.maxWidth = `${width}px`;\n });\n }\n function applyColumnVisibility(){\n document.querySelectorAll('[data-col]').forEach(el=>el.classList.toggle('hidden-col', hiddenColumns.has(el.dataset.col)));\n applyColumnWidths();\n }\n function saveColumnWidthsPreference(){\n saveBrowserViewPrefs({columnWidths});\n savePreferencePatch({table_columns_json:columnPrefsPayload()}, 300);\n }\n function setupColumnResizers(){\n document.querySelectorAll('.torrent-table thead th[data-col]').forEach(th=>{\n const key = th.dataset.col;\n if(!key || key === 'select' || th.querySelector('.column-resize-handle')) return;\n const handle = document.createElement('span');\n handle.className = 'column-resize-handle';\n handle.title = 'Drag to resize column';\n handle.setAttribute('aria-hidden', 'true');\n th.appendChild(handle);\n let startX = 0, startWidth = 0, dragged = false;\n const onMove = (event) => {\n dragged = true;\n columnWidths[key] = clampNumber(startWidth + event.clientX - startX, COLUMN_WIDTH_MIN, COLUMN_WIDTH_MAX, startWidth);\n applyColumnWidths();\n };\n const onUp = () => {\n document.body.classList.remove('resizing-columns');\n document.removeEventListener('pointermove', onMove);\n document.removeEventListener('pointerup', onUp);\n if(dragged) saveColumnWidthsPreference();\n };\n handle.addEventListener('pointerdown', event=>{\n event.preventDefault();\n event.stopPropagation();\n dragged = false;\n startX = event.clientX;\n startWidth = columnWidths[key] || th.getBoundingClientRect().width || DEFAULT_COLUMN_WIDTHS[key] || 120;\n document.body.classList.add('resizing-columns');\n document.addEventListener('pointermove', onMove);\n document.addEventListener('pointerup', onUp);\n });\n handle.addEventListener('click', event=>event.stopPropagation());\n });\n }\n function syncActiveFilterSelection(){ syncFilterButtons(); }\n function actionLabel(action){\n const labels={start:'Starting',pause:'Pausing',stop:'Stopping',resume:'Resuming',recheck:'Checking',reannounce:'Reannouncing',remove:'Removing',move:'Moving',set_label:'Setting label',set_ratio_group:'Setting ratio'};\n return labels[action] || `Working: ${action}`;\n }\n function actionIcon(action){\n return ({start:'fa-play',pause:'fa-pause',stop:'fa-stop',resume:'fa-play',recheck:'fa-rotate',reannounce:'fa-bullhorn',remove:'fa-trash',move:'fa-folder-open',set_label:'fa-tag',set_ratio_group:'fa-scale-balanced'}[action]) || 'fa-gears';\n }\n function markTorrentOperation(hashes, action, jobId, state='queued'){\n const label=actionLabel(action);\n [...new Set(hashes||[])].filter(Boolean).forEach(hash=>activeOperations.set(hash,{action,jobId,state,label,updatedAt:Date.now()}));\n scheduleRender(true);\n }\n function markQueuedJobs(response, fallbackHashes, action){\n // Note: Supports API responses that split one large user action into multiple queued bulk parts.\n const jobs=Array.isArray(response?.jobs)?response.jobs:[];\n if(jobs.length){ jobs.forEach(job=>markTorrentOperation(job.hashes||[],action,job.job_id,'queued')); return; }\n markTorrentOperation(fallbackHashes,action,response?.job_id,'queued');\n }\n function clearJobOperation(jobId, hashes=[]){\n if(jobId){ [...activeOperations].forEach(([hash,op])=>{ if(op.jobId===jobId) activeOperations.delete(hash); }); }\n (hashes||[]).forEach(hash=>activeOperations.delete(hash));\n scheduleRender(true);\n }\n function activeOperationFor(t){ return activeOperations.get(t.hash) || null; }\n function statusMeta(t){\n const op=activeOperationFor(t);\n if(op) return {cls:'text-bg-info operation-status-badge', icon:actionIcon(op.action), color:'text-info', label:op.label};\n const status=String(t.status||'').toLowerCase();\n if(t.paused || status==='paused') return {cls:'text-bg-warning', icon:'fa-pause', color:'text-warning'};\n if(status==='checking' || Number(t.hashing||0)>0) return {cls:'text-bg-info', icon:'fa-rotate', color:'text-info'};\n if(status==='seeding') return {cls:'text-bg-success', icon:'fa-seedling', color:'text-success'};\n if(status==='downloading') return {cls:'text-bg-primary', icon:'fa-download', color:'text-primary'};\n if(status==='stopped') return {cls:'text-bg-secondary', icon:'fa-stop', color:'text-secondary'};\n return t.state ? {cls:'text-bg-success', icon:'fa-play', color:'text-success'} : {cls:'text-bg-secondary', icon:'fa-circle', color:'text-secondary'};\n }\n function statusBadge(t){ const m=statusMeta(t); return `${esc(m.label || t.status)}`; }\n function torrentWarning(t){ const msg=String(t.message||'').trim(); if(!msg) return null; const l=msg.toLowerCase(); const patterns=['error','failed','failure','timeout','timed out','tracker','could not','cannot','refused','unreachable','denied']; return patterns.some(p=>l.includes(p)) ? msg : null; }\n function torrentNameIcon(t){ const m=statusMeta(t); return ``; }\n function boolCell(value){ return Number(value||0) ? 'yes' : 'no'; }\n function renderRow(t){\n const labels=labelNames(t.label).map(l=>` ${esc(l)}`).join(' ');\n const warn=torrentWarning(t);\n const op=activeOperationFor(t);\n const classes=[selected.has(t.hash)?'selected':'', t.paused?'torrent-paused':'', op?'torrent-operating':'', warn?'torrent-warning':''].filter(Boolean).join(' ');\n const title=[t.name,warn,op?op.label:''].filter(Boolean).join('\\n');\n return ``+\n ``+\n `${warn?' ':''}${torrentNameIcon(t)} ${esc(t.name)}`+\n `${statusBadge(t)}`+\n `${esc(t.size_h)}`+\n `${progress(t)}`+\n `${esc(t.down_rate_h)}`+\n `${esc(t.up_rate_h)}`+\n `${esc(t.eta_h||\"-\")}`+\n `${esc(t.seeds)}`+\n `${esc(t.peers)}`+\n `${esc(t.ratio)}`+\n `${esc(t.path)}`+\n `${labels||'-'}`+\n `${esc(t.ratio_group||'')}`+\n `${esc(t.down_total_h||'-')}`+\n `${esc(t.to_download_h||'-')}`+\n `${esc(t.up_total_h||'-')}`+\n `${esc(formatDateTime(t.created))}`+\n `${esc(t.priority ?? '-')}`+\n `${boolCell(t.state)}`+\n `${boolCell(t.active)}`+\n `${boolCell(t.complete)}`+\n `${esc(t.hashing ?? 0)}`+\n `${compactCell(t.message||'', 80)}`+\n `${esc(t.hash||'')}`+\n ``;\n }\n function mobileFilterDefs(){\n const arr=trackerScopedRows();\n const defs=Object.keys(FILTER_COUNT_IDS).filter(k=>k!=='moving').map(k=>[k,k==='all'?'All':k==='downloading'?'Downloading':k==='seeding'?'Seeding':k==='paused'?'Paused':k==='checking'?'Checking':k==='error'?'With error':'Stopped',filterSummaryBucket(k).count||0]);\n const movingCount=movingFilterCount();\n if(movingCount) defs.push(['moving','Moving',movingCount]);\n const counts=new Map();\n arr.forEach(t=>labelNames(t.label).forEach(l=>counts.set(l,(counts.get(l)||0)+1)));\n [...counts.keys()].sort((a,b)=>a.localeCompare(b)).forEach(l=>defs.push([`label:${l}`,l,counts.get(l),'label']));\n defs.push(['tracker:','All trackers',torrents.size,'tracker']);\n const trackerOptions=new Map((trackerSummary.trackers||[]).map(t=>[t.domain,t]));\n // Note: Preserve the selected tracker option even when the cache has not returned it yet, so the mobile select does not jump to All trackers.\n if(activeTrackerFilter && !trackerOptions.has(activeTrackerFilter)) trackerOptions.set(activeTrackerFilter, {domain:activeTrackerFilter, count:arr.length});\n [...trackerOptions.values()].forEach(t=>defs.push([`tracker:${t.domain}`,t.domain,t.count,'tracker']));\n if(mobileSmartFiltersEnabled) SMART_VIEW_DEFS.forEach(([key,label])=>defs.push([key,label,arr.filter(t=>smartViewVisible(t,key)).length,'smart']));\n return defs;\n }\n function renderMobileFilters(){\n const bar=$('mobileFilterBar');\n if(!bar) return;\n const allVisible=visibleRows.length>0 && visibleRows.every(t=>selected.has(t.hash));\n const someVisible=visibleRows.some(t=>selected.has(t.hash));\n const defs=mobileFilterDefs();\n const currentSelect=$('mobileFilterSelect');\n const focused=currentSelect && document.activeElement===currentSelect;\n const sig=[focused ? 'focus' : activeFilter, activeTrackerFilter, sortState.key, sortState.dir, selected.size, allVisible ? 1 : 0, someVisible ? 1 : 0, mobileSmartFiltersEnabled ? 1 : 0, defs.map(d=>`${d[0]}:${d[2]}`).join('|')].join('::');\n if(focused) return;\n if(sig===lastMobileFiltersSignature) return;\n lastMobileFiltersSignature=sig;\n const selectedMobileKey = activeTrackerFilter ? `tracker:${activeTrackerFilter}` : (mobileActiveFilterKey === 'tracker:' ? 'tracker:' : activeFilter);\n // Note: Select exactly one mobile option; \"All\" and \"All trackers\" share the same data filter but must not both render as selected.\n const opts=defs.map(([key,label,count,type])=>{ const selectedOpt = key === selectedMobileKey; return ``; }).join('');\n const bulk=selected.size?``:'';\n // Note: Mobile bulk actions reuse the existing label modal and move picker, so desktop behavior stays unchanged.\n bar.innerHTML=`
    ${bulk}${selected.size} selected
    `;\n }\n function mobileColumnValue(t, key){\n if(key==='status') return statusBadge(t);\n if(key==='size') return `Size ${esc(t.size_h||'-')}`;\n if(key==='progress') return null;\n if(key==='down_rate') return `DL ${esc(t.down_rate_h||'-')}`;\n if(key==='up_rate') return `UL ${esc(t.up_rate_h||'-')}`;\n if(key==='eta') return `ETA ${esc(t.eta_h||'-')}`;\n if(key==='seeds') return `Seeds ${esc(t.seeds??0)}`;\n if(key==='peers') return `Peers ${esc(t.peers??0)}`;\n if(key==='ratio') return `Ratio ${esc(t.ratio??'-')}`;\n if(key==='label') return `Label ${esc(t.label||'-')}`;\n if(key==='ratio_group') return `Ratio group ${esc(t.ratio_group||'-')}`;\n if(key==='down_total') return `Downloaded ${esc(t.down_total_h||'-')}`;\n // Note: Complete torrents hide this mobile line because there is nothing left to download.\n if(key==='to_download') return t.to_download_h ? `To download ${esc(t.to_download_h)}` : null;\n if(key==='up_total') return `Uploaded ${esc(t.up_total_h||'-')}`;\n if(key==='created') return `Added ${esc(formatDateTime(t.created))}`;\n if(key==='priority') return `Priority ${esc(t.priority ?? '-')}`;\n if(key==='state') return `State ${esc(t.state ?? '-')}`;\n if(key==='active') return `Active ${esc(t.active ?? '-')}`;\n if(key==='complete') return `Complete ${esc(t.complete ?? '-')}`;\n if(key==='hashing') return `Hashing ${esc(t.hashing ?? 0)}`;\n if(key==='message') return `Message ${esc(t.message||'-')}`;\n if(key==='hash') return `Hash ${esc(t.hash||'-')}`;\n return null;\n }\n function mobileInfoLines(t){\n const primary=[], secondary=[];\n MOBILE_COLUMN_DEFS.forEach(([key])=>{\n if(!mobileColumns[key]) return;\n const value = mobileColumnValue(t, key);\n if(!value) return;\n if(['status','size','ratio','eta','seeds','peers'].includes(key)) primary.push(value);\n else if(key!=='path') secondary.push(value);\n });\n return {primary:primary.join(' \u00b7 '), secondary:secondary.join(' \u00b7 ')};\n }\n function renderMobile(){ const list=$('mobileList'); if(!list) return; const src=visibleRows.length?visibleRows:[...torrents.values()].filter(rowVisible).sort(compareRows); const rows=src.slice(0,250); renderMobileFilters(); list.innerHTML=rows.map(t=>{ const warn=torrentWarning(t); const op=activeOperationFor(t); const classes=[selected.has(t.hash)?'selected':'', op?'torrent-operating':'', warn?'torrent-warning':''].filter(Boolean).join(' '); const lines=mobileInfoLines(t); return `
    ${warn?' ':''}${torrentNameIcon(t)} ${esc(t.name)}
    ${lines.primary?`
    ${lines.primary}
    `:''}${lines.secondary?`
    ${lines.secondary}
    `:''}${mobileColumns.path?`
    ${esc(t.path)}
    `:''}
    ${mobileColumns.progress?`
    ${progress(t)}
    `:''}
    `; }).join('') || (hasTorrentSnapshot ? `
    No torrents.
    ` : loadingMarkup('Loading torrents...')); }\n function renderTable(){ updateBulkBar(); syncActiveFilterSelection(); renderCounts(); renderLabelFilters(); if(typeof renderHealthDashboard==='function') renderHealthDashboard(); if(typeof renderSmartViewsManager==='function') renderSmartViewsManager(); updateSortHeaders(); buildVisibleRows(); renderMobile(); const body=$('torrentBody'); if(!visibleRows.length){ body.innerHTML=hasTorrentSnapshot?`No torrents for this filter.`:loadingTableRow('Loading torrents...'); return; } const wrap=$('tableWrap'); const start=Math.max(0,Math.floor((wrap?.scrollTop||0)/ROW_HEIGHT)-OVERSCAN); const count=Math.ceil((wrap?.clientHeight||500)/ROW_HEIGHT)+OVERSCAN*2; const end=Math.min(visibleRows.length,start+count); const sig=`${renderVersion}:${start}:${end}:${visibleRows.length}:${sortState.key}:${sortState.dir}:${selected.size}:${activeFilter}:${activeTrackerFilter}:${$('searchBox')?.value||''}:${[...selected].slice(0,30).join(',')}`; if(sig===lastRenderSignature) return; lastRenderSignature=sig; const top=start*ROW_HEIGHT,bottom=Math.max(0,(visibleRows.length-end)*ROW_HEIGHT); body.innerHTML=(top?``:'')+visibleRows.slice(start,end).map(renderRow).join('')+(bottom?``:''); applyColumnVisibility(); }\n function scheduleRender(force=false){ if(force){lastRenderSignature='';renderVersion++;} if(renderPending)return; renderPending=true; requestAnimationFrame(()=>{renderPending=false;renderTable();}); }\n function patchRows(msg){ if(msg.summary) torrentSummary=msg.summary; (msg.removed||[]).forEach(h=>{torrents.delete(h);selected.delete(h);activeOperations.delete(h);if(selectedHash===h)selectedHash=null;}); (msg.added||[]).forEach(t=>torrents.set(t.hash,t)); (msg.updated||[]).forEach(p=>torrents.set(p.hash,{...(torrents.get(p.hash)||{}),...p})); if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle(); scheduleRender(true); if(selectedHash&&torrents.has(selectedHash)&&activeTab()==='general') renderGeneral(); }\n function selectedHashes(){ return [...selected]; }\n function updateBulkBar(){\n const bar=$(\"bulkBar\");\n if(!bar) return;\n const show=selected.size>1;\n bar.classList.toggle(\"d-none\", !show);\n bar.setAttribute('aria-hidden', show ? 'false' : 'true');\n const c=$(\"bulkSelectedCount\");\n if(c) c.textContent=selected.size;\n }\n function setSelectionRange(hash, keepExisting=false){ const current=visibleRows.findIndex(t=>t.hash===hash); const last=visibleRows.findIndex(t=>t.hash===lastSelectedHash); if(current<0 || last<0){ selected.add(hash); lastSelectedHash=hash; return; } if(!keepExisting) selected.clear(); const a=Math.min(current,last), b=Math.max(current,last); visibleRows.slice(a,b+1).forEach(t=>selected.add(t.hash)); selectedHash=hash; }\n"; diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css new file mode 100644 index 0000000..a15970e --- /dev/null +++ b/pytorrent/static/styles.css @@ -0,0 +1,4181 @@ +:root { + --app-font-family: + Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; + --ui-scale: 1; + --topbar: calc(50px * var(--ui-scale)); + --statusbar: calc(34px * var(--ui-scale)); + --mobile-filterbar-height: 132px; + --sidebar: calc(270px * var(--ui-scale)); + --torrent-progress-complete: #198754; +} +[data-bs-theme="dark"] { + --bs-body-bg: #05070a; + --bs-body-bg-rgb: 5, 7, 10; + --bs-body-color: #d6dde8; + --bs-secondary-bg: #0a0f16; + --bs-secondary-bg-rgb: 10, 15, 22; + --bs-tertiary-bg: #0e141d; + --bs-border-color: #1d2734; + --bs-secondary-color: #8d98aa; + --bs-primary-bg-subtle: #0d2238; + --bs-primary-text-emphasis: #9ecbff; + --torrent-progress-complete: #2f9e75; +} + +html[data-app-font="adwaita-mono"] { + --app-font-family: + "Adwaita Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, + "Liberation Mono", monospace; +} +html[data-app-font="inter"] { + --app-font-family: + Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; +} +html[data-app-font="system-ui"] { + --app-font-family: + system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; +} +html[data-app-font="source-sans-3"] { + --app-font-family: + "Source Sans 3", "Source Sans Pro", system-ui, -apple-system, Segoe UI, + Roboto, Arial, sans-serif; +} +html[data-app-font="jetbrains-mono"] { + --app-font-family: + "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, + "Liberation Mono", monospace; +} +html, +body { + height: 100%; +} +body { + overflow: hidden; + overflow-x: hidden; + font-size: calc(13px * var(--ui-scale)); + min-height: 100vh; + min-height: 100dvh; + padding: calc(8px * var(--ui-scale)); + background: #05070a; + font-family: var(--app-font-family); +} +.app-shell { + height: calc(100vh - (16px * var(--ui-scale))); + height: calc(100dvh - (16px * var(--ui-scale))); + display: grid; + grid-template-rows: var(--topbar) 1fr var(--statusbar); + background: var(--bs-body-bg); + border: 1px solid var(--bs-border-color); + border-radius: 12px; + overflow: hidden; + box-shadow: 0 12px 45px rgba(0, 0, 0, 0.38); +} +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.42rem 0.7rem; + min-height: var(--topbar); + background: var(--bs-secondary-bg); +} +.toolbar-left, +.toolbar-right { + display: flex; + align-items: center; + gap: 0.45rem; + min-width: 0; +} +.toolbar-left { + flex: 0 1 auto; + overflow: hidden; +} +.toolbar-right { + flex: 1 1 0; + justify-content: flex-end; + margin-left: auto; +} +.brand { + font-weight: 800; + font-size: 1.05rem; + letter-spacing: 0.2px; + white-space: nowrap; + line-height: 32px; +} +.profile-picker-btn { + max-width: 180px; +} +.profile-picker-btn span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.profile-select { + width: 100%; +} +.search { + width: min(38vw, 420px); + min-width: clamp(160px, 20vw, 220px); + max-width: 420px; + flex: 0 1 420px; +} +.mobile-speed-stats { + display: none; + align-items: center; + gap: 0.45rem; + flex: 0 0 auto; + color: var(--bs-secondary-color); + font-size: 0.72rem; + white-space: nowrap; +} +.mobile-speed-stats b { + color: var(--bs-body-color); + font-weight: 700; +} +.mobile-speed-stats span { + display: inline-flex; + align-items: center; + gap: 0.18rem; +} +.topbar .form-control, +.topbar .form-select { + height: 32px; + line-height: 1.15; +} +.topbar .btn { + min-height: 28px; + line-height: 1; +} +#themeToggle, +#mobileToggle { + width: 32px; + min-width: 32px; + display: inline-flex; + align-items: center; + justify-content: center; +} +.spinner-border-xs { + width: 0.75rem; + height: 0.75rem; + border-width: 0.12em; + vertical-align: -1px; +} +.global-loader { + position: fixed; + right: 14px; + bottom: 44px; + z-index: 7000; + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.4rem 0.65rem; + border-radius: 999px; + background: var(--bs-tertiary-bg); + color: var(--bs-body-color); + border: 1px solid var(--bs-border-color); + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35); +} + +.initial-loader { + position: fixed; + inset: 0; + z-index: 9000; + display: grid; + place-items: center; + padding: 1rem; + background: radial-gradient( + circle at 50% 35%, + rgba(var(--bs-secondary-bg-rgb), 0.98), + var(--bs-body-bg) 68% + ); + color: var(--bs-body-color); + transition: + opacity 0.22s ease, + visibility 0.22s ease; +} +.initial-loader.is-hidden { + opacity: 0; + visibility: hidden; + pointer-events: none; +} +.initial-loader-card { + width: min(92vw, 430px); + padding: 2rem; + border: 1px solid var(--bs-border-color); + border-radius: 18px; + background: rgba(var(--bs-secondary-bg-rgb), 0.88); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.48); + text-align: center; +} +.initial-loader-brand { + font-size: 1.35rem; + font-weight: 800; + letter-spacing: 0.2px; +} +.initial-loader-spinner { + margin: 1.4rem 0 1rem; +} +.initial-loader-title { + font-size: 1rem; + font-weight: 700; +} +.initial-loader-text { + margin-top: 0.35rem; + color: var(--bs-secondary-color); +} +.main-grid { + min-height: 0; + display: grid; + grid-template-columns: var(--sidebar) 1fr; +} +.sidebar { + padding: 0.5rem; + overflow: auto; + background: rgba(var(--bs-secondary-bg-rgb), 0.9); +} +.filter { + width: 100%; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.1rem 0.45rem; + align-items: center; + margin-bottom: 0.12rem; + padding: 0.34rem 0.5rem; + border: 0; + border-radius: 0.55rem; + background: transparent; + color: var(--bs-body-color); + text-align: left; +} +.filter:hover, +.filter.active { + background: var(--bs-primary-bg-subtle); + color: var(--bs-primary-text-emphasis); +} +.filter > span:first-child { + min-width: 0; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.filter > span:last-child { + min-width: 0; + max-width: 12rem; + text-align: right; +} +.filter-count { + display: block; + font-weight: 700; + line-height: 1.1; +} +.filter-meta { + display: block; + margin-top: 0.05rem; + color: var(--bs-secondary-color); + font-size: 0.68rem; + font-weight: 400; + line-height: 1.15; + opacity: 0.72; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.filter.active .filter-meta, +.filter:hover .filter-meta { + color: var(--bs-primary-text-emphasis); + opacity: 0.78; +} +.shortcut { + font-size: 0.78rem; + color: var(--bs-secondary-color); + padding: 0.15rem 0.5rem; +} +.content { + min-width: 0; + min-height: 0; + display: grid; + grid-template-rows: minmax(0, 1fr) 7px var(--detail-panel-height, 255px); + position: relative; +} +.table-wrap { + contain: content; + overflow: auto; + position: relative; +} +.torrent-table { + margin: 0; + white-space: nowrap; + table-layout: fixed; +} +.torrent-table thead th { + position: sticky; + top: 0; + z-index: 2; + background: var(--bs-tertiary-bg); + border-bottom: 1px solid var(--bs-border-color); + overflow: visible; + user-select: none; +} +.torrent-table thead th[data-sort] { + cursor: pointer; +} +.torrent-table thead th[data-sort]:hover, +.torrent-table thead th.sorted { + color: var(--bs-primary-text-emphasis); +} +.torrent-table tbody tr { + cursor: default; + height: 32px; +} +.torrent-table > :not(caption) > * > * { + padding-bottom: 0.22rem; + padding-top: 0.22rem; + vertical-align: middle; +} +.torrent-table .message { + overflow: hidden; + text-overflow: ellipsis; +} +.torrent-table tbody tr.selected td { + background: var(--bs-primary-bg-subtle); +} +.torrent-table .sel { + width: 34px; + text-align: center; +} +.torrent-table .name { + overflow: hidden; + text-overflow: ellipsis; +} +.torrent-table .path { + color: var(--bs-secondary-color); + overflow: hidden; + text-overflow: ellipsis; +} + +/* Column resizing keeps table cells aligned while widths are persisted in user preferences. */ +.torrent-table [data-col] { + overflow: hidden; + text-overflow: ellipsis; +} +.torrent-table [data-col="down_rate"], +.torrent-table [data-col="up_rate"], +.torrent-table [data-col="size"], +.torrent-table [data-col="eta"], +.torrent-table [data-col="seeds"], +.torrent-table [data-col="peers"], +.torrent-table [data-col="ratio"], +.torrent-table [data-col="down_total"], +.torrent-table [data-col="to_download"], +.torrent-table [data-col="up_total"] { + text-align: right; +} +.torrent-table .column-title { + display: inline-block; + max-width: calc(100% - 0.5rem); + overflow: hidden; + text-overflow: ellipsis; + vertical-align: bottom; +} +.column-resize-handle { + bottom: 0; + cursor: col-resize; + position: absolute; + right: -3px; + top: 0; + width: 7px; + z-index: 3; +} +.column-resize-handle::after { + background: var(--bs-border-color); + bottom: 0.35rem; + content: ""; + opacity: 0; + position: absolute; + right: 3px; + top: 0.35rem; + transition: opacity 0.15s ease; + width: 1px; +} +.torrent-table thead th:hover .column-resize-handle::after, +body.resizing-columns .column-resize-handle::after { + opacity: 1; +} +body.resizing-columns { + cursor: col-resize; + user-select: none; +} + +.virtual-spacer td { + padding: 0 !important; + border: 0 !important; +} +.empty { + height: 120px; + text-align: center; + vertical-align: middle; + color: var(--bs-secondary-color); +} +.details { + grid-row: 3; + grid-column: 1; + min-height: 0; + overflow: hidden; + background: rgba(var(--bs-secondary-bg-rgb), 0.78); +} +.detail-pane { + height: calc(var(--detail-panel-height, 255px) - 45px); + overflow: auto; + padding: 0.5rem 0.65rem; +} +.detail-resize-handle { + grid-row: 2; + grid-column: 1; + align-items: center; + background: rgba(var(--bs-secondary-bg-rgb), 0.72); + cursor: row-resize; + display: flex; + justify-content: center; + min-height: 7px; + position: relative; + z-index: 3; +} +.detail-resize-handle::before { + background: var(--bs-border-color); + border-radius: 999px; + content: ''; + height: 3px; + width: 46px; +} +.detail-resize-handle:hover::before, +body.resizing-details .detail-resize-handle::before { + background: var(--bs-primary); +} +body.resizing-details { + cursor: row-resize; + user-select: none; +} +.loading-line { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--bs-secondary-color); + padding: 0.75rem; +} +.muted-pane { + color: var(--bs-secondary-color); +} +.detail-table { + white-space: nowrap; +} +.responsive-table-wrap { + max-width: 100%; + overflow-x: auto; + border: 1px solid var(--bs-border-color); + border-radius: 0.6rem; + -webkit-overflow-scrolling: touch; +} +.responsive-table-wrap .detail-table { + margin-bottom: 0; +} +.smart-exclusions-table { + min-width: 680px; +} +.smart-history-table { + min-width: 760px; + table-layout: fixed; +} +.smart-history-table th, +.smart-history-table td { + overflow-wrap: anywhere; + white-space: normal; +} +.general-summary, +.general-grid, +.general-meta { + display: grid; + gap: 0.75rem; +} + +.general-summary { + grid-template-columns: minmax(0, 2fr) minmax(16rem, 1fr); + margin-bottom: 0.75rem; +} + +.general-summary-main, +.general-summary-side, +.general-stat, +.general-meta > div { + background: var(--bs-body-bg); + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + min-width: 0; + padding: 0.75rem; +} + +.general-title-row { + align-items: flex-start; + display: flex; + gap: 0.75rem; + justify-content: space-between; +} + +.general-title-row h6 { + font-size: 1rem; + line-height: 1.35; + margin: 0; + overflow-wrap: anywhere; +} + +.general-path { + display: grid; + gap: 0.15rem; + margin-top: 0.5rem; + overflow-wrap: anywhere; +} + +.general-path b { + color: var(--bs-secondary-color); + font-size: 0.72rem; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.general-path span { + font-size: 0.82rem; +} + +.general-summary-side code { + display: block; + font-size: 0.78rem; + overflow-wrap: anywhere; + white-space: normal; +} + +.general-grid { + grid-template-columns: repeat(5, minmax(0, 1fr)); +} + +.general-meta { + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-top: 0.75rem; +} + +.general-stat b, +.general-meta b, +.general-summary-side b { + color: var(--bs-secondary-color); + display: block; + font-size: 0.72rem; + letter-spacing: 0.03em; + margin-bottom: 0.25rem; + text-transform: uppercase; +} + +.general-stat span, +.general-meta span { + display: block; + overflow-wrap: anywhere; +} +.statusbar { + display: flex; + align-items: center; + gap: 1rem; + padding: 0 0.75rem; + overflow-x: auto; + background: var(--bs-tertiary-bg); + color: var(--bs-secondary-color); + white-space: nowrap; +} +.statusbar b { + color: var(--bs-body-color); +} +.speed-peaks { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} +.status-limit { + border: 1px solid var(--bs-border-color); + background: rgba(var(--bs-secondary-bg-rgb), 0.9); + color: var(--bs-secondary-color); + border-radius: 0.45rem; + padding: 0.12rem 0.5rem; + white-space: nowrap; +} +.status-limit:hover { + color: var(--bs-body-color); + background: var(--bs-secondary-bg); +} +.ctx-menu { + display: none; + position: absolute; + z-index: 5000; + min-width: 200px; + padding: 0.35rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.6rem; + background: var(--bs-body-bg); +} +.ctx-menu button { + display: block; + width: 100%; + text-align: left; + border: 0; + background: transparent; + color: var(--bs-body-color); + padding: 0.42rem 0.55rem; + border-radius: 0.4rem; +} +.ctx-menu button:hover { + background: var(--bs-secondary-bg); +} +.ctx-menu .danger { + color: var(--bs-danger); +} +.ctx-menu hr { + margin: 0.25rem 0; + border-color: var(--bs-border-color); +} +.profile-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.25rem 0.5rem; + align-items: center; + margin-bottom: 0.45rem; + padding: 0.45rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.6rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.58); +} +.profile-row.active { + border-color: var(--bs-primary); + background: var(--bs-primary-bg-subtle); +} +.profile-row span { + grid-column: 1 / 2; + color: var(--bs-secondary-color); + overflow-wrap: anywhere; +} +.profile-actions, +.profile-form-actions { + display: inline-flex; + gap: 0.35rem; + flex-wrap: wrap; +} +.profile-form-grid { + display: grid; + grid-template-columns: minmax(150px, 1.1fr) minmax(260px, 2.1fr) minmax( + 90px, + 0.55fr + ) minmax(120px, 0.75fr) minmax(145px, auto) auto; + gap: 0.65rem; + align-items: start; +} +.profile-form-field { + display: grid; + gap: 0.25rem; + min-width: 0; +} +.profile-form-field > span:first-child { + color: var(--bs-secondary-color); + font-size: 0.72rem; + font-weight: 700; + line-height: 1.1; + text-transform: uppercase; +} +.profile-form-field small { + color: var(--bs-secondary-color); + line-height: 1.25; +} +.profile-check-field .form-check { + min-height: 31px; + display: flex; + align-items: center; + gap: 0.45rem; +} +.modal-content { + background: var(--bs-body-bg); + border: 1px solid var(--bs-border-color); + border-radius: 14px; +} +.modal-header, +.modal-footer { + background: rgba(var(--bs-secondary-bg-rgb), 0.82); + border-color: var(--bs-border-color); +} +.magnet-box { + min-height: 92px; + resize: vertical; +} +.surface-section { + border: 1px solid var(--bs-border-color); + background: rgba(var(--bs-secondary-bg-rgb), 0.5); + border-radius: 0.75rem; + padding: 0.75rem; +} +.section-title { + font-weight: 700; + margin-bottom: 0.55rem; + color: var(--bs-body-color); +} +.preset-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.4rem; +} +.toast-host { + position: fixed; + right: 14px; + top: 70px; + z-index: 8000; + display: grid; + gap: 0.4rem; +} +.toast-item { + display: flex; + align-items: center; + gap: 0.45rem; + max-width: 360px; + padding: 0.45rem 0.65rem; + border-radius: 0.55rem; + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.28); +} + +.toast-message { + min-width: 0; + overflow-wrap: anywhere; +} + +.toast-count { + flex: 0 0 auto; + padding: 0.05rem 0.35rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.22); + font-size: 0.78rem; + font-weight: 700; +} +@media (max-width: 1100px) { + :root { + --topbar: 88px; + } + .topbar { + align-items: flex-start; + flex-wrap: wrap; + } + .toolbar-left { + flex: 1 1 100%; + overflow: visible; + flex-wrap: wrap; + } + .toolbar-right { + flex: 1 1 100%; + justify-content: flex-end; + } + .search { + flex: 1 1 220px; + width: auto; + min-width: 160px; + max-width: none; + } +} +@media (max-width: 900px) { + :root { + --sidebar: 0px; + } + .sidebar { + display: none; + } + .general-summary, + .general-grid, + .general-meta { + grid-template-columns: 1fr; + } +} +@media (max-width: 640px) { + :root { + --topbar: 132px; + } + .preset-grid { + grid-template-columns: 1fr 1fr; + } +} + +.job-settings-grid { + display: grid; + grid-template-columns: repeat(2, minmax(220px, 1fr)); + gap: 0.75rem; +} +.job-settings-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.preferences-grid { + display: grid; + grid-template-columns: repeat(2, minmax(220px, 1fr)); + gap: 0.75rem; +} +.form-field { + display: grid; + gap: 0.3rem; +} +.form-field > span { + color: var(--bs-secondary-color); + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; +} + +@media (max-width: 640px) { + .job-settings-grid, + .preferences-grid { + grid-template-columns: 1fr; + } +} + +.date-compact { + white-space: nowrap; +} +.btn-xs { + --bs-btn-padding-y: 0.18rem; + --bs-btn-padding-x: 0.42rem; + --bs-btn-font-size: 0.78rem; + --bs-btn-border-radius: 0.35rem; +} +.nav-btn { + border-radius: 0.45rem !important; + margin: 0 !important; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.25rem; +} +.nav-btn + .nav-btn, +.torrent-action + .torrent-action { + margin-left: 0.08rem !important; +} +.path-list { + height: 360px; + overflow: auto; + border: 1px solid var(--bs-border-color); + border-radius: 0.6rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.35); +} +.path-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.42rem 0.6rem; + border-bottom: 1px solid var(--bs-border-color); + cursor: pointer; +} +.path-row:hover { + background: var(--bs-primary-bg-subtle); + color: var(--bs-primary-text-emphasis); +} +.chips { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; +} +.chip { + border: 1px solid var(--bs-border-color); + background: rgba(var(--bs-secondary-bg-rgb), 0.6); + color: var(--bs-body-color); + border-radius: 999px; + padding: 0.22rem 0.6rem; + font-size: 0.78rem; +} +.mobile-list { + overflow: auto; + padding: 0.55rem; + background: var(--bs-body-bg); +} +.mobile-card { + border: 1px solid var(--bs-border-color); + background: rgba(var(--bs-secondary-bg-rgb), 0.72); + border-radius: 0.75rem; + padding: 0.65rem; + margin-bottom: 0.55rem; +} +.mobile-card.selected { + outline: 2px solid var(--bs-primary); +} +.mobile-card .name { + font-weight: 700; + word-break: break-word; +} +.mobile-actions { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: 0.45rem; +} +#systemChart { + width: 140px; + height: 24px; + border: 1px solid var(--bs-border-color); + border-radius: 0.35rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.85); +} +.badge-degraded { + background: #f59e0b !important; + color: #111 !important; +} +body.mobile-mode .table-wrap, +body.mobile-mode .detail-resize-handle { + display: none; +} +body.mobile-mode .details { + display: none !important; +} +body.mobile-mode #mobileList { + display: block !important; + grid-row: 3; + min-height: 0; + height: 100%; + overflow: auto; + position: relative; + z-index: 2; + padding: 0.55rem 0.55rem 1rem !important; +} +body.mobile-mode .content { + display: grid !important; + grid-template-rows: auto auto minmax(0, 1fr) !important; + min-height: 0; + overflow: hidden; +} +body.mobile-mode .torrent-table { + display: none; +} +body.mobile-mode .main-grid { + min-height: 0; + overflow: hidden; +} +@media (max-width: 640px) { + .nav-btn span { + display: none; + } +} + +.mobile-sort-row { + display: flex; + margin-top: 0.4rem; + justify-content: flex-end; + gap: 0.5rem; +} +.mobile-sort-row .btn { + width: 100%; + justify-content: center; +} +.view-preferences-note { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.75rem; + padding: 0.65rem 0.75rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.72); + color: var(--bs-secondary-color); +} +.view-preferences-note i { + color: var(--bs-primary); +} +.view-preferences-note span { + flex: 1 1 260px; +} +.view-preferences-note .btn { + flex: 0 0 auto; +} + +.hidden-col { + display: none !important; +} +.status-docs { + margin-left: auto; + color: inherit; + text-decoration: none; + font-weight: 600; + opacity: 0.9; + white-space: nowrap; +} +.status-docs:hover { + opacity: 1; + text-decoration: underline; +} +.label-filters .label-filter, +.tracker-filters .tracker-filter { + font-size: 0.78rem; + margin-bottom: 0.08rem; + padding: 0.26rem 0.44rem; +} +.label-filters .label-filter i, +.tracker-filters .tracker-filter i { + opacity: 0.75; + margin-right: 0.25rem; +} + +.tracker-filters .tracker-filter span:first-child { + align-items: center; + display: inline-flex; + gap: 0.35rem; + min-width: 0; +} + +.tracker-favicon { + border-radius: 0.2rem; + flex: 0 0 auto; + height: 14px; + object-fit: contain; + width: 14px; +} + +.tracker-favicon:not(.d-none) + .tracker-fallback-icon { + display: none; +} + +.tracker-filter-empty { + align-items: center; + color: var(--bs-secondary-color); + display: flex; + font-size: 0.76rem; + gap: 0.3rem; + padding: 0.2rem 0.44rem; +} + +.tracker-filter-empty .spinner-border-xs { + height: 0.65rem; + width: 0.65rem; +} + +.column-manager { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); + gap: 0.55rem; +} + +.column-card { + display: flex; + align-items: center; + gap: 0.55rem; + margin: 0; + padding: 0.55rem 0.65rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.7rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.45); + cursor: pointer; + user-select: none; + transition: + background 0.15s, + border-color 0.15s, + transform 0.15s; +} + +.column-card:hover, +.column-card.active { + background: var(--bs-primary-bg-subtle); +} + +.column-card:hover { + border-color: var(--bs-primary); +} + +.column-card.active { + border-color: rgba(var(--bs-primary-rgb), 0.55); +} + +.column-card .form-check-input { + margin: 0; +} + +.column-card .form-check-label { + display: flex; + align-items: center; + gap: 0.45rem; + font-weight: 600; +} + +.column-card i { + opacity: 0.72; +} +.path-row::before { + content: "\f07b"; + font-family: "Font Awesome 6 Free"; + font-weight: 900; + color: var(--bs-warning); +} +body.mobile-mode .mobile-card { + display: block; +} +.mobile-card .mobile-actions button { + min-width: 34px; +} +#toolSmart .form-label { + font-size: 0.75rem; + color: var(--bs-secondary-color); + margin-bottom: 0.2rem; +} +#toolSmart .btn { + padding: 0.25rem 0.55rem; + border-radius: 0.5rem; + white-space: nowrap; +} +#toolSmart .row .d-flex { + align-items: end; + justify-content: flex-start; +} +@media (max-width: 992px) { + .profile-form-grid { + grid-template-columns: 1fr; + } + .profile-form-grid .btn { + width: 100%; + } +} + +.history-card { + min-width: 0; + padding: 0.85rem; + overflow: hidden; + background: linear-gradient(180deg, rgba(var(--bs-secondary-bg-rgb), 0.58), rgba(var(--bs-secondary-bg-rgb), 0.28)); + border: 1px solid var(--bs-border-color); + border-radius: 1rem; + box-shadow: 0 0.5rem 1.75rem rgba(15, 23, 42, 0.08); +} + +.history-title { + margin-bottom: 0.55rem; + color: var(--bs-body-color); + font-size: 0.9rem; + font-weight: 700; + letter-spacing: 0.01em; +} + +.traffic-chart { + display: block; + width: 100%; + height: 420px; + max-width: 100%; + background: var(--bs-secondary-bg); + border: 0; + border-radius: 0.75rem; +} + +.traffic-chart-tooltip { + position: fixed; + z-index: 9000; + min-width: 150px; + padding: 0.45rem 0.6rem; + color: var(--bs-body-color); + background: var(--bs-body-bg); + border: 1px solid var(--bs-border-color); + border-radius: 0.5rem; + box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.28); + font-size: 0.78rem; + pointer-events: none; +} + +.traffic-tooltip-title { + margin-bottom: 0.25rem; + color: var(--bs-secondary-color); + font-weight: 700; +} + +.empty-mini { + padding: 0.7rem 0.8rem; + border: 1px dashed var(--bs-border-color); + border-radius: 0.7rem; + color: var(--bs-secondary-color); + background: rgba(var(--bs-secondary-bg-rgb), 0.35); +} +.label-manager-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.65rem; + padding: 0.4rem 0.5rem; + margin-bottom: 0.4rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.35); +} +.tool-tab i { + margin-right: 0.25rem; + opacity: 0.82; +} +@media (max-width: 640px) { + .history-card { + padding: 0.5rem; + } + .traffic-chart { + height: 320px; + } + .statusbar { + font-size: 0.75rem; + gap: 0.6rem; + } + .mobile-list { + padding: 0.45rem; + } + .mobile-card { + margin-bottom: 0.45rem; + } +} + +.torrent-progress { + height: 16px; + min-width: 92px; + position: relative; + margin: 0; + overflow: hidden; + background: rgba(var(--bs-secondary-bg-rgb), 0.8) !important; +} +.torrent-progress .progress-bar { + min-width: 0 !important; + position: relative; + transition: + width 0.25s ease, + background-color 0.25s ease; +} +.torrent-progress > span { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 700; + line-height: 1; + color: var(--bs-body-color); + text-shadow: none; + white-space: nowrap; + pointer-events: none; +} +.torrent-progress .progress-bar + span { + color: var(--bs-body-color); +} +.pager-row { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.5rem; +} +.peers-refresh { + display: flex; + align-items: center; + gap: 0.5rem; + justify-content: flex-end; + padding: 0.35rem 0.75rem; + border-bottom: 1px solid var(--bs-border-color); + background: rgba(var(--bs-secondary-bg-rgb), 0.35); +} +.peers-refresh select { + width: auto; + min-width: 96px; +} + +@media (max-width: 900px) { + body:not(.modal-open) .table-wrap { + display: none !important; + } + body:not(.modal-open) #mobileList { + display: block !important; + height: 100% !important; + min-height: 260px; + overflow: auto; + } + body:not(.modal-open) .content { + display: grid !important; + grid-template-rows: auto auto minmax(0, 1fr) !important; + min-height: 0; + overflow: hidden; + } + body:not(.modal-open) .detail-resize-handle, + body:not(.modal-open) .details { + display: none !important; + } +} +.torrent-paused td { + opacity: 0.82; +} +.torrent-paused .name { + font-style: italic; +} + +@media (max-width: 900px) { + .main-grid { + display: grid !important; + grid-template-columns: minmax(0, 1fr) !important; + min-height: 0 !important; + height: 100% !important; + overflow: hidden !important; + } + .sidebar { + display: none !important; + } + .content { + display: grid !important; + grid-template-rows: auto auto minmax(0, 1fr) !important; + min-height: 0 !important; + height: 100% !important; + overflow: hidden !important; + } + .table-wrap { + display: none !important; + } + #bulkBar { + grid-row: 1; + } + #mobileList { + display: block !important; + grid-row: 3; + height: 100% !important; + min-height: 0 !important; + overflow: auto !important; + position: relative !important; + z-index: 10 !important; + background: var(--bs-body-bg) !important; + padding: 0.55rem !important; + } + .details { + display: none !important; + } + .toolbar-right { + width: 100% !important; + min-width: 0 !important; + flex-wrap: nowrap !important; + gap: 0.35rem !important; + } + .search { + min-width: 0 !important; + width: auto !important; + flex: 1 1 0 !important; + max-width: none !important; + } + .mobile-speed-stats { + display: inline-flex; + } +} +@media (max-width: 640px) { + .mobile-speed-stats { + align-items: flex-start; + flex-direction: column; + gap: 0.08rem; + font-size: 0.66rem; + line-height: 1.05; + } +} + +.files-toolbar { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: space-between; + margin-bottom: 0.5rem; +} +.files-action-strip { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} +.files-action-section { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} +.files-action-label { + color: var(--muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.04em; + margin-right: 0.1rem; + text-transform: uppercase; +} +.files-action-separator { + align-self: stretch; + background: var(--border); + display: inline-block; + min-height: 1.8rem; + width: 1px; +} +.file-priority-table > :not(caption) > * > * { + line-height: 1.15; + padding: 0.22rem 0.4rem; + vertical-align: middle; +} +.file-priority-table tbody tr { + height: 30px; +} +.file-priority-table .path { + max-width: 520px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.file-priority-table .file-priority { + min-width: 110px; +} +.file-priority-table .file-progress { + margin-bottom: 0; + min-width: 110px; + width: 120px; +} +.file-priority-table .form-select, +.file-priority-table .btn-xs { + min-height: 24px; + padding-bottom: 0.1rem; + padding-top: 0.1rem; +} +.file-priority-table .file-check, +.file-priority-table #fileSelectAll { + display: block; + margin: 0 auto; +} +@media (max-width: 900px) { + .files-toolbar { + align-items: stretch; + } + .files-action-strip, + .files-action-section { + align-items: stretch; + } + .files-action-separator { + min-height: auto; + } + .file-priority-table { + font-size: 0.82rem; + } + .file-priority-table .path { + max-width: 180px; + } +} + +.bulk-bar { + height: 38px; + display: flex; + align-items: center; + gap: 0.35rem; + flex-wrap: nowrap; + overflow-x: auto; + overflow-y: hidden; + padding: 0.35rem 0.55rem; + border-bottom: 1px solid var(--bs-border-color); + background: rgba(var(--bs-secondary-bg-rgb), 0.95); + z-index: 4; +} +.bulk-bar.d-none { + display: none !important; +} +.bulk-bar span { + color: var(--bs-secondary-color); + margin-right: 0.3rem; + white-space: nowrap; +} +.bulk-bar .btn { + white-space: nowrap; + flex: 0 0 auto; +} +.move-options { + border: 1px solid var(--bs-border-color); + border-radius: 0.6rem; + padding: 0.75rem; + background: var(--bs-tertiary-bg); +} +#bulkBar { + grid-row: 1; + grid-column: 1; + align-self: start; +} +#tableWrap, +#mobileList { + grid-row: 1; + grid-column: 1; + min-height: 0; +} +.bulk-bar:not(.d-none) + .table-wrap { + padding-top: 38px; +} +@media (max-width: 900px) { + .bulk-bar { + gap: 0.3rem; + } +} + +.label-mini { + font-size: 0.72rem; + padding: 0.12rem 0.38rem; + margin-right: 0.15rem; +} +.label-chip.active { + border-color: var(--bs-primary); + background: var(--bs-primary-bg-subtle); + color: var(--bs-primary-text-emphasis); +} +.label-selected { + border-color: var(--bs-primary); + background: var(--bs-primary-bg-subtle); + color: var(--bs-primary-text-emphasis); +} + +.automation-shell { + display: grid; + gap: 0.75rem; +} +.automation-main-card { + padding: 0.75rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + background: var(--bs-body-bg); +} +.automation-card-title { + margin-bottom: 0.5rem; + font-weight: 700; +} +.automation-rule-grid, +.automation-builder-grid { + display: grid; + grid-template-columns: repeat(4, minmax(160px, 1fr)); + gap: 0.5rem; + align-items: center; +} +.automation-enabled, +.automation-negate { + margin: 0; + padding: 0.45rem 0.6rem 0.45rem 2.5rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.5rem; +} +.automation-path-input { + grid-column: span 2; +} +.automation-chip-list { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} +.automation-chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + max-width: 100%; + padding: 0.25rem 0.5rem; + border: 1px solid var(--bs-border-color); + border-radius: 999px; + background: var(--bs-tertiary-bg); + font-size: 0.82rem; +} +.automation-actions, +.automation-row-actions { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + align-items: center; +} +.automation-row { + display: flex; + justify-content: space-between; + gap: 0.75rem; + align-items: center; + padding: 0.55rem 0.65rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.6rem; + margin-bottom: 0.45rem; + background: var(--bs-body-bg); +} +.automation-row-main { + min-width: 0; +} +.automation-rule-summary { + overflow-wrap: anywhere; +} +.automation-action-pill { + display: inline-flex; + max-width: 100%; + margin: 0.1rem; + padding: 0.15rem 0.4rem; + border-radius: 999px; + background: var(--bs-secondary-bg); + font-size: 0.78rem; + overflow-wrap: anywhere; + white-space: normal; + word-break: break-word; +} +.automation-smart-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 0.5rem; + margin: 0.5rem 0 0.75rem; +} +.automation-smart-stat { + min-width: 0; + padding: 0.5rem 0.6rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.6rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.28); +} +.automation-smart-stat span, +.automation-smart-stat small { + display: block; + color: var(--bs-secondary-color); + font-size: 0.72rem; + line-height: 1.2; +} +.automation-smart-stat b { + display: block; + overflow: hidden; + font-size: 1rem; + line-height: 1.3; + text-overflow: ellipsis; + white-space: nowrap; +} +.automation-history-toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: 0.5rem; +} +.automation-history-table { + width: 100%; + min-width: 760px; + table-layout: fixed; + white-space: normal; +} +.automation-history-table th, +.automation-history-table td { + min-width: 0; + vertical-align: top; +} +.automation-history-table th:nth-child(1), +.automation-history-table td:nth-child(1) { + width: 9rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.automation-history-table th:nth-child(2), +.automation-history-table td:nth-child(2) { + width: 11rem; + overflow: hidden; + overflow-wrap: anywhere; + word-break: break-word; +} +.automation-history-table th:nth-child(3), +.automation-history-table td:nth-child(3) { + width: 12rem; + overflow: hidden; + overflow-wrap: anywhere; + word-break: break-word; +} +.automation-history-table th:nth-child(4), +.automation-history-table td:nth-child(4) { + width: auto; + overflow: hidden; + overflow-wrap: anywhere; + word-break: break-word; +} +.automation-history-details { + display: block; + min-width: 0; + max-width: 100%; +} +.automation-history-details summary { + display: block; + max-width: 100%; + cursor: pointer; + list-style-position: inside; + overflow-wrap: anywhere; + white-space: normal; + word-break: break-word; +} +.automation-history-details pre, +.automation-history-raw { + max-width: 100%; + max-height: 220px; + margin: 0.35rem 0 0; + padding: 0.5rem; + overflow: auto; + border: 1px solid var(--bs-border-color); + border-radius: 0.5rem; + background: var(--bs-tertiary-bg); + overflow-wrap: anywhere; + white-space: pre-wrap; + word-break: break-word; +} +@media (max-width: 900px) { + .automation-rule-grid, + .automation-builder-grid { + grid-template-columns: 1fr; + } + .automation-path-input, + .automation-history-details { + grid-column: auto; + max-width: 100%; + } + .automation-history-toolbar { + justify-content: flex-start; + } +} +.disk-status { + display: inline-flex; + align-items: center; + gap: 0.35rem; + min-width: 0; + flex: 0 1 70%; +} + +.disk-status canvas { + width: 100%; + max-width: none; + min-width: 80px; +} + +.disk-status.disk-warn b { + color: var(--bs-warning) !important; +} + +.system-chart { + width: 96px; + height: 24px; + border-radius: 0.35rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.45); +} +.torrent-progress.is-complete > span { + color: #fff; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.35); +} +.peer-progress { + min-width: 86px; + width: 96px; +} +.loading-center { + justify-content: center; + min-height: 80px; +} +.loading-cell { + padding: 0 !important; +} +.mobile-list .loading-center { + min-height: 160px; +} + +.torrent-warning td { + background: rgba(245, 158, 11, 0.075) !important; +} +.torrent-warning:hover td { + background: rgba(245, 158, 11, 0.11) !important; +} +.torrent-warning.selected td { + background: color-mix( + in srgb, + var(--bs-primary-bg-subtle) 82%, + rgba(245, 158, 11, 0.16) + ) !important; +} +.mobile-card.torrent-warning { + background: rgba(245, 158, 11, 0.075); +} +.mobile-card.torrent-warning.selected { + background: color-mix( + in srgb, + var(--bs-primary-bg-subtle) 82%, + rgba(245, 158, 11, 0.16) + ); +} +.torrent-warning-icon { + color: var(--bs-warning); + margin-right: 0.2rem; +} +.mobile-filter-bar { + display: none; + grid-row: 2; + grid-column: 1; + align-self: start; + position: relative; + z-index: 12; + padding: 0.45rem 0.55rem; + border-bottom: 1px solid var(--bs-border-color); + background: rgba(var(--bs-body-bg-rgb), 0.96); +} +.mobile-filter-actions, +.mobile-filter-select-row { + align-items: center; + display: flex; + gap: 0.35rem; +} +.mobile-filter-actions { + flex-wrap: wrap; + margin-bottom: 0.4rem; +} +.mobile-filter-actions span { + color: var(--bs-secondary-color); + font-size: 0.78rem; + white-space: nowrap; +} +.mobile-filter-select-row label { + color: var(--bs-secondary-color); + font-size: 0.78rem; + white-space: nowrap; +} +.mobile-filter-select-row select { + min-width: 0; + flex: 1 1 auto; +} +body.mobile-mode .mobile-filter-bar { + display: block !important; +} +@media (max-width: 900px) { + #mobileFilterBar { + display: block !important; + } + .topbar .badge { + width: 0.72rem; + height: 0.72rem; + min-width: 0.72rem; + padding: 0 !important; + border-radius: 999px; + overflow: hidden; + color: transparent !important; + text-indent: -999px; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.22); + } + .topbar .badge .spinner-border { + display: none; + } +} + +.rt-config-grid { + display: grid; + gap: 0.6rem; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); +} + +.rt-config-group { + grid-column: 1 / -1; + padding: 0.45rem 0.2rem 0.1rem; + border-bottom: 1px solid var(--bs-border-color); + color: var(--bs-primary-text-emphasis); + font-weight: 800; +} + +.rt-config-note { + margin-bottom: 0.75rem; +} + +.rt-config-toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.rt-config-row { + display: grid; + grid-template-columns: 1fr minmax(120px, 190px); + align-items: center; + gap: 0.6rem; + padding: 0.6rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.7rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.35); +} + +.rt-config-switch { + justify-self: end; + margin: 0; +} + +.rt-config-switch .form-check-input { + margin-top: 0; +} + +.rt-config-switch .form-check-label { + min-width: 2rem; + color: var(--bs-secondary-color); + font-size: 0.78rem; + font-weight: 700; +} + +.rt-config-row b { + font-size: 0.88rem; +} + +.rt-config-row small { + display: block; + overflow-wrap: anywhere; + color: var(--bs-secondary-color); + font-size: 0.72rem; +} + +.rt-config-row.disabled { + opacity: 0.58; +} + +.rt-config-row.changed, +.rt-config-row.changed-live { + border-color: var(--bs-danger); + box-shadow: 0 0 0 0.12rem rgba(220, 53, 69, 0.2); +} + +.rt-config-value-note { + margin-top: 0.15rem; +} + +.rt-config-output { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; +} + +.tracker-toolbar, +.tracker-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.45rem; +} + +.tracker-toolbar { + justify-content: space-between; + margin-bottom: 0.55rem; +} + +.tracker-add-input { + min-width: 240px; + max-width: 520px; +} + +.tracker-url-text { + word-break: break-all; +} + +.tool-note { + color: var(--bs-secondary-color); + font-size: 0.82rem; +} + +.cleanup-grid, +.diag-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 0.6rem; +} + +.cleanup-card, +.diag-card { + padding: 0.65rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.7rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.35); +} + +.cleanup-card b, +.diag-card b { + display: block; + margin-bottom: 0.2rem; + color: var(--bs-secondary-color); + font-size: 0.78rem; +} + +.cleanup-card span, +.diag-card span { + font-weight: 700; +} + +.cleanup-card small { + display: block; + margin-top: 0.2rem; + overflow-wrap: anywhere; + color: var(--bs-secondary-color); +} + +.cleanup-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.diag-error { + border-color: rgba(var(--bs-danger-rgb), 0.45); + background: rgba(var(--bs-danger-rgb), 0.08); +} + +.port-status { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.12rem 0.4rem; + border-radius: 0.45rem; +} + +.port-ok { + background: rgba(34, 197, 94, 0.14); + color: var(--bs-success); +} + +.port-bad { + background: rgba(239, 68, 68, 0.14); + color: var(--bs-danger); +} + +.port-secondary { + background: rgba(148, 163, 184, 0.12); + color: var(--bs-secondary-color); +} + +.limit-slider-panel { + padding: 0.65rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.7rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.32); +} + +.limit-slider-row + .limit-slider-row { + margin-top: 0.65rem; +} + +.limit-slider-row .form-label { + display: flex; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.25rem; +} + +@media (max-width: 640px) { + #mobileToggle { + display: none !important; + } + + .tracker-add-input { + min-width: 160px; + max-width: 230px; + } + + .tracker-message { + max-width: 220px; + } +} +.text-compact { + display: inline-block; + max-width: 32rem; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: bottom; + white-space: nowrap; +} + +.torrent-operating td { + background: rgba(13, 202, 240, 0.085) !important; +} + +.torrent-operating:hover td { + background: rgba(13, 202, 240, 0.13) !important; +} + +.torrent-operating.selected td { + background: color-mix( + in srgb, + var(--bs-primary-bg-subtle) 78%, + rgba(13, 202, 240, 0.18) + ) !important; +} + +.mobile-card.torrent-operating { + background: rgba(13, 202, 240, 0.085); + border-color: rgba(13, 202, 240, 0.45); +} + +.mobile-card.torrent-operating.selected { + background: color-mix( + in srgb, + var(--bs-primary-bg-subtle) 78%, + rgba(13, 202, 240, 0.18) + ); +} + +.operation-status-badge { + color: #062c33; +} + +.mobile-progress { + margin-top: 0.45rem; +} + +.mobile-progress .torrent-progress { + width: 100%; + min-width: 0; +} + +.empty-state { + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 0.45rem; + max-width: 34rem; + white-space: normal; +} +.empty-state b { + color: var(--bs-body-color); + font-size: 0.95rem; +} +.empty-state span { + color: var(--bs-secondary-color); +} + +.footer-pref-hidden { + display: none !important; +} + +.footer-preferences { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.5rem; +} + +.footer-pref-card { + display: flex; + align-items: center; + gap: 0.55rem; + min-width: 0; + margin: 0; + padding: 0.6rem 0.7rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.45); + cursor: pointer; + user-select: none; + transition: + background 0.15s, + border-color 0.15s; +} + +.footer-pref-card:hover, +.footer-pref-card.active { + background: var(--bs-primary-bg-subtle); +} + +.footer-pref-card:hover { + border-color: var(--bs-primary); +} + +.footer-pref-card.active { + border-color: rgba(var(--bs-primary-rgb), 0.55); +} + +.footer-pref-card .form-check-input { + flex: 0 0 auto; + margin: 0; +} + +.footer-pref-card .form-check-label { + min-width: 0; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#statusClock, +#statusSockets { + white-space: nowrap; +} + +.torrent-stats-toolbar { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.torrent-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.75rem; +} + +.torrent-stats-card { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; + padding: 0.75rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.85rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.45); +} + +.torrent-stats-card b { + color: var(--bs-secondary-color); + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; +} + +.torrent-stats-card span { + font-size: 1.05rem; + font-weight: 700; +} + +.torrent-stats-card small { + color: var(--bs-secondary-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.peer-ip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + white-space: nowrap; +} + +.peer-ip-link { + color: var(--bs-secondary-color); + font-size: 0.75rem; + text-decoration: none; +} + +.peer-ip-link:hover { + color: var(--bs-primary); +} + +.auth-page { + display: grid; + min-height: 100vh; + place-items: center; + padding: 1rem; + background: radial-gradient( + circle at 50% 35%, + rgba(var(--bs-secondary-bg-rgb), 0.98), + var(--bs-body-bg) 68% + ); + color: var(--bs-body-color); +} + +.auth-card { + width: min(92vw, 430px); +} + +.auth-lock { + display: inline-grid; + width: 3rem; + height: 3rem; + margin: 1.35rem 0 1rem; + place-items: center; + border: 1px solid var(--bs-border-color); + border-radius: 999px; + background: rgba(var(--bs-tertiary-bg-rgb), 0.72); + color: var(--bs-primary); + font-size: 1.15rem; +} + +.auth-alert { + margin: 1rem 0 0; + padding: 0.5rem 0.75rem; + text-align: left; +} + +.auth-form { + margin-top: 1.2rem; + text-align: left; +} + +.auth-form .form-label { + margin-bottom: 0.35rem; + font-size: 0.82rem; + font-weight: 700; + color: var(--bs-secondary-color); +} + +.auth-form .form-control { + margin-bottom: 0.85rem; +} + +.auth-form .btn { + margin-top: 0.35rem; +} + +.user-form-grid { + display: grid; + grid-template-columns: minmax(150px, 1fr) minmax(160px, 1fr) 120px 150px 110px auto auto; + gap: 0.55rem; + align-items: center; +} + +.smart-panel { + container-type: inline-size; +} + +.smart-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--bs-border-color); +} + +.smart-header-actions { + display: flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; + justify-content: flex-end; + flex: 0 0 auto; +} + +.smart-settings-list { + display: grid; + gap: 0.65rem; + margin-top: 0.85rem; +} + +.smart-setting-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + min-height: 52px; + padding: 0.6rem 0.7rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.65rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.28); +} + +/* Keep Bootstrap switches aligned inside compact settings rows. */ +.inline-switch, +.smart-toggle-row .form-check { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 0.45rem; + flex: 0 0 auto; + min-height: 0; + margin: 0; + padding-left: 0; +} + +.inline-switch .form-check-input, +.smart-toggle-row .form-check-input { + flex: 0 0 auto; + margin-top: 0; + margin-left: 0; +} + +.inline-switch .form-check-label { + line-height: 1.2; + white-space: nowrap; +} + +.smart-setting-row > div:first-child { + min-width: 0; +} + +.smart-setting-row b, +.smart-setting-row small { + display: block; +} + +.smart-setting-row .form-check-label, +.smart-input-field span { + font-weight: 700; +} + +.smart-input-grid { + display: grid; + grid-template-columns: repeat(4, minmax(120px, 1fr)); + gap: 0.65rem; +} + +.smart-input-field { + display: grid; + gap: 0.35rem; + min-width: 0; + padding: 0.6rem 0.7rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.65rem; + background: rgba(var(--bs-body-bg-rgb), 0.48); +} + +.smart-input-field small { + color: var(--bs-secondary-color); + line-height: 1.2; +} + +.smart-input-field .form-control { + width: 100%; +} + +.smart-actions { + display: flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; + padding: 0.7rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.65rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.24); +} + +@media (max-width: 992px) { + .user-form-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .smart-input-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 576px) { + .user-form-grid, + .smart-input-grid { + grid-template-columns: 1fr; + } + + .smart-header, + .smart-setting-row { + align-items: stretch; + flex-direction: column; + } + + .smart-header-actions { + justify-content: stretch; + } + + .smart-header-actions .btn { + flex: 1 1 auto; + } + + .smart-toggle-row .form-check { + justify-content: flex-start; + } +} +.about-modal-content { + overflow: hidden; +} +.about-nav-btn { + opacity: 0.82; +} +.about-nav-btn:hover, +.about-nav-btn:focus-visible { + opacity: 1; +} +.about-hero { + display: flex; + align-items: center; + gap: 0.85rem; + margin-bottom: 1rem; + padding: 0.9rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.85rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.38); +} +.about-logo { + display: inline-grid; + width: 2.8rem; + height: 2.8rem; + flex: 0 0 auto; + place-items: center; + border-radius: 0.8rem; + background: var(--bs-primary-bg-subtle); + color: var(--bs-primary-text-emphasis); + font-size: 1.25rem; +} + +.about-hero h6, +.about-hero p { + margin: 0; +} + +.about-hero h6 { + font-weight: 800; +} + +.about-hero p { + color: var(--bs-secondary-color); +} + +.about-list { + display: grid; + gap: 0.55rem; + margin: 0; +} + +.about-list div { + display: grid; + grid-template-columns: 7rem minmax(0, 1fr); + gap: 0.75rem; + padding: 0.55rem 0; + border-bottom: 1px solid var(--bs-border-color); +} + +.about-list div:last-child { + border-bottom: 0; +} + +.about-list dt { + color: var(--bs-secondary-color); + font-weight: 700; +} + +.about-list dd { + margin: 0; +} + +.error-page { + display: grid; + min-height: 100vh; + place-items: center; + padding: 1rem; + background: radial-gradient( + circle at 50% 35%, + rgba(var(--bs-secondary-bg-rgb), 0.98), + var(--bs-body-bg) 68% + ); + color: var(--bs-body-color); +} + +.error-card { + width: min(92vw, 460px); + padding: 2rem; + border: 1px solid var(--bs-border-color); + border-radius: 18px; + background: rgba(var(--bs-secondary-bg-rgb), 0.9); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.48); + text-align: center; +} + +.error-brand { + font-size: 1.2rem; + font-weight: 800; +} + +.error-icon { + display: inline-grid; + width: 4rem; + height: 4rem; + margin: 1.4rem 0 1rem; + place-items: center; + border: 1px solid var(--bs-border-color); + border-radius: 1rem; + background: var(--bs-primary-bg-subtle); + color: var(--bs-primary-text-emphasis); + font-size: 1.55rem; +} + +.error-code { + margin: 0; + color: var(--bs-secondary-color); + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.error-card h1 { + margin: 0.25rem 0 0.55rem; + font-size: 1.45rem; + font-weight: 800; +} + +.error-card p:not(.error-code) { + margin: 0; + color: var(--bs-secondary-color); +} + +.error-actions { + display: flex; + justify-content: center; + gap: 0.55rem; + flex-wrap: wrap; + margin-top: 1.35rem; +} + +@media (max-width: 576px) { + .about-list div { + grid-template-columns: 1fr; + gap: 0.15rem; + } + + .error-actions .btn { + width: 100%; + } +} + +.date-readable { + display: inline-block; + min-width: 9.5rem; + white-space: nowrap; +} + +.cooldown-live { + display: inline-flex; + margin-left: 0.35rem; + padding: 0.05rem 0.35rem; + border: 1px solid var(--bs-border-color); + border-radius: 999px; + color: var(--bs-secondary-color); + font-size: 0.72rem; + font-weight: 700; +} + +.disk-path-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + color: var(--bs-danger); + line-height: 1; +} + +.disk-path-remove:hover, +.disk-path-remove:focus-visible { + border: 0; + background: transparent; + box-shadow: none; + color: var(--bs-danger-text-emphasis); + outline: 0; +} + +.jobs-table { + min-width: 1080px; + white-space: normal; +} + +.jobs-table th:nth-child(8), +.jobs-table td:nth-child(8), +.jobs-table th:nth-child(9), +.jobs-table td:nth-child(9) { + min-width: 10.5rem; +} + +.jobs-table td:nth-child(6), +.jobs-table td:nth-child(10) { + max-width: 18rem; + overflow-wrap: anywhere; + white-space: normal; +} +.smart-cooldown-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + background: rgba(var(--bs-primary-rgb), 0.08); +} +.smart-cooldown-label, +.smart-cooldown-field span, +.disk-monitor-card-title { + display: block; + font-weight: 700; +} +.smart-cooldown-live { + margin: 0.25rem 0 0; + color: var(--bs-primary-text-emphasis); + background: rgba(var(--bs-primary-rgb), 0.12); + border-color: rgba(var(--bs-primary-rgb), 0.28); + font-size: 0.9rem; +} +.smart-cooldown-card small, +.smart-cooldown-field small, +.disk-monitor-switch small, +.disk-path-row small { + display: block; + color: var(--bs-secondary-color); + line-height: 1.25; +} + +.smart-cooldown-field { + display: grid; + gap: 0.3rem; + width: min(180px, 100%); +} + +.smart-refill-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.28); +} + +.smart-refill-title, +.smart-refill-field span { + display: block; + font-weight: 700; +} + +.smart-refill-card small { + display: block; + color: var(--bs-secondary-color); + line-height: 1.25; +} + +.smart-refill-controls { + display: grid; + grid-template-columns: minmax(130px, 1fr) minmax(90px, 0.7fr); + gap: 0.55rem; + width: min(330px, 100%); +} + +.smart-refill-field { + display: grid; + gap: 0.3rem; +} +.disk-monitor-shell { + display: grid; + grid-template-columns: minmax(240px, 0.9fr) minmax(280px, 1.1fr); + gap: 0.75rem; +} + +.disk-monitor-mode-card, +.disk-monitor-path-card { + display: grid; + gap: 0.55rem; + padding: 0.75rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + background: rgba(var(--bs-body-bg-rgb), 0.45); +} + +.disk-monitor-switch { + display: grid; + grid-template-columns: auto 1fr; + column-gap: 0.6rem; + row-gap: 0.1rem; + align-items: start; + min-height: auto; + margin: 0; + padding: 0.55rem 0.6rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.6rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.25); +} + +.disk-monitor-switch .form-check-input { + grid-row: span 2; + margin-left: 0; +} + +.disk-monitor-switch .form-check-label { + font-weight: 700; +} + +.disk-monitor-path-list { + display: grid; + gap: 0.45rem; +} + +.disk-path-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.55rem 0.65rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.6rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.22); +} + +.disk-path-row b { + display: block; + overflow-wrap: anywhere; +} + +.disk-path-actions { + display: flex; + gap: 0.35rem; + flex: 0 0 auto; +} + +@media (max-width: 768px) { + .smart-cooldown-card, + .smart-refill-card, + .disk-path-row { + align-items: stretch; + flex-direction: column; + } + + .disk-monitor-shell { + grid-template-columns: 1fr; + } + + .disk-path-actions { + justify-content: flex-start; + } +} + +.ratio-rule-grid, +.rss-form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 0.5rem; + align-items: center; +} + +.ratio-rule-grid .form-check, +.rss-form-grid .form-check { + margin-bottom: 0; +} + +@media (max-width: 768px) { + .ratio-rule-grid, + .rss-form-grid { + grid-template-columns: 1fr; + } +} + +.dragging-torrent-files .table-wrap, +.dragging-torrent-files .mobile-list { + outline: 2px dashed var(--bs-primary); + outline-offset: -0.4rem; +} + +.dragging-torrent-files .table-wrap::after { + align-items: center; + background: color-mix(in srgb, var(--bs-body-bg) 82%, transparent); + border: 1px dashed var(--bs-primary); + border-radius: 0.75rem; + color: var(--bs-primary); + content: 'Drop .torrent files to add them'; + display: flex; + font-weight: 700; + inset: 0.75rem; + justify-content: center; + pointer-events: none; + position: absolute; + z-index: 5; +} + +.torrent-preview { + display: grid; + gap: .75rem; +} + +.torrent-preview-title { + color: var(--bs-secondary-color); + font-size: .82rem; + font-weight: 700; + text-transform: uppercase; +} + +.torrent-preview-card { + border: 1px solid var(--bs-border-color); + border-radius: .75rem; + padding: .75rem; +} + +.torrent-preview-card.is-duplicate { + border-color: var(--bs-danger); +} + +.torrent-preview-head, +.preview-actions, +.file-tree-actions { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: .5rem; +} + +.preview-file-table { + margin-bottom: 0; +} + +.preview-file-table td:first-child { + width: 2.25rem; +} + +.file-tree-panel { + border: 1px solid var(--bs-border-color); + border-radius: .75rem; + margin: .75rem 0; + max-height: 18rem; + overflow: auto; + padding: .75rem; +} + +.file-tree-root, +.file-tree-root ul { + list-style: none; + margin: 0; + padding-left: 1rem; +} + +.file-tree-root > li { + padding-left: 0; +} + +.file-tree-file, +.file-tree-root summary { + align-items: center; + display: flex; + gap: .4rem; + min-height: 1.75rem; +} + +.file-tree-root small { + color: var(--bs-secondary-color); +} + +.tool-tab[data-tool="planner"], +.tool-tab[data-tool="poller"] { + white-space: nowrap; +} + +.planner-panel .smart-header, +.poller-panel .smart-header { + margin-bottom: 0.85rem; +} + +.planner-layout, +.planner-toggle-stack { + display: grid; + gap: 0.85rem; +} + +.planner-card { + background: rgba(var(--bs-secondary-bg-rgb), 0.36); + border: 1px solid var(--bs-border-color); + border-radius: 0.85rem; + min-width: 0; + padding: 0.85rem; +} + +.planner-card-title { + align-items: center; + display: flex; + font-weight: 700; + gap: 0.45rem; + margin-bottom: 0.7rem; +} + +.planner-card-time, +.planner-card-protection { + display: grid; + gap: 0.75rem; +} + +.planner-card-time .planner-card-title, +.planner-card-protection .planner-card-title { + margin-bottom: 0; +} + +.planner-card-time .planner-time-grid, +.planner-card-protection .planner-protection-grid { + margin-top: 0; +} + +.planner-toggle-stack-compact { + grid-template-columns: repeat(2, minmax(220px, 1fr)); +} + +.planner-protection-toggles { + grid-template-columns: repeat(2, minmax(240px, 1fr)); +} + +.planner-card .smart-setting-row { + align-items: flex-start; + gap: 0.75rem; + min-height: auto; +} + +.planner-card .smart-setting-row > div:first-child { + flex: 1 1 auto; +} + +.planner-card .smart-setting-row .inline-switch, +.planner-card .smart-setting-row .form-check { + align-self: flex-start; + margin-top: 0.1rem; +} + +.planner-time-grid, +.planner-profile-grid, +.poller-input-grid { + grid-template-columns: repeat(4, minmax(130px, 1fr)); +} + +.planner-protection-grid { + grid-template-columns: repeat(5, minmax(130px, 1fr)); +} + +.planner-speed-grid { + grid-template-columns: repeat(2, minmax(260px, 1fr)); +} + +.planner-speed-card { + gap: 0.45rem; +} + +.planner-limit-summary { + color: var(--bs-secondary-color); + font-size: 0.82rem; +} + +.planner-presets, +.planner-hour-tools, +.tool-action-row { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.planner-speed-sliders { + align-items: center; + display: grid; + gap: 0.45rem 0.65rem; + grid-template-columns: minmax(160px, 1fr) 100px; +} + +.planner-speed-sliders label { + color: var(--bs-secondary-color); + margin: 0; +} + +.planner-byte-input { + font-family: var(--bs-font-monospace); +} + +.tool-action-row { + align-items: center; + margin-top: 0.85rem; +} + +.planner-hour-tools { + margin: 0.65rem 0; +} + +.planner-hour-grid { + display: grid; + gap: 0.35rem; + grid-template-columns: repeat(2, minmax(280px, 1fr)); + max-height: 420px; + overflow: auto; + padding-right: 0.25rem; +} + +.planner-hour-row { + align-items: center; + border: 1px solid var(--bs-border-color); + border-radius: 0.6rem; + display: grid; + gap: 0.4rem; + grid-template-columns: 6.2rem 1fr 1fr minmax(8rem, auto); + padding: 0.35rem; +} + +.planner-hour-row > span { + font-weight: 700; +} + +.planner-hour-row small { + color: var(--bs-secondary-color); +} + +.planner-hour-row small { + white-space: nowrap; +} + +.planner-card-result small, +#pollerRuntime { + display: block; +} + +#pollerRuntime { + line-height: 1.45; +} + +.planner-history-item { + background: rgba(var(--bs-secondary-bg-rgb), 0.45); + border: 1px solid var(--bs-border-color); + border-radius: 999px; + display: inline-block; + margin: 0.15rem 0.35rem 0.15rem 0; + padding: 0.15rem 0.4rem; +} + +#pollerRuntime { + margin-top: 0.25rem; +} + +.status-planner { + align-items: center; + background: transparent; + border: 1px solid var(--bs-border-color); + border-radius: 0.35rem; + color: inherit; + display: inline-flex; + gap: 0.35rem; + line-height: 1.2; + padding: 0.1rem 0.45rem; +} + +.status-planner:hover { + background: rgba(var(--bs-secondary-bg-rgb), 0.5); +} + +.tracker-filter-all { + border-style: dashed; +} + +@media (max-width: 1100px) { + .planner-hour-grid, + .planner-protection-toggles { + grid-template-columns: 1fr; + } + + .planner-protection-grid { + grid-template-columns: repeat(3, minmax(130px, 1fr)); + } +} + +@media (max-width: 900px) { + .planner-time-grid, + .planner-profile-grid, + .planner-protection-grid, + .planner-speed-grid, + .poller-input-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 720px) { + .planner-hour-row, + .planner-speed-sliders, + .planner-time-grid, + .planner-profile-grid, + .planner-protection-grid, + .planner-speed-grid, + .planner-toggle-stack-compact, + .poller-input-grid { + grid-template-columns: 1fr; + } + + .planner-card .smart-setting-row { + flex-direction: column; + } + + .planner-hour-row small { + white-space: normal; + } + + .tool-action-row .btn { + flex: 1 1 auto; + } +} + +/* Phase 5 dashboard, smart views and notifications */ +.health-dashboard-grid, +.smart-view-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: .75rem; +} +.health-card, +.smart-view-card, +.notification-item { + border: 1px solid var(--bs-border-color); + border-radius: .75rem; + background: var(--bs-body-bg); + box-shadow: 0 .25rem .8rem rgba(15, 23, 42, .04); +} +.health-card { + padding: .85rem; + min-width: 0; +} +.health-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: .75rem; + margin-bottom: .25rem; +} +.health-card > small, +.smart-view-card small, +.notification-item small { + color: var(--bs-secondary-color); +} +.health-list { + display: grid; + gap: .4rem; + margin-top: .65rem; +} +.health-row { + display: grid; + gap: .15rem; + width: 100%; + padding: .45rem .55rem; + border: 1px solid var(--bs-border-color); + border-radius: .55rem; + background: var(--bs-tertiary-bg); + color: inherit; + text-align: left; +} +.health-row span, +.health-row small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.smart-view-card { + display: grid; + gap: .35rem; + padding: .9rem; + text-align: left; + color: inherit; +} +.smart-view-card.active, +.smart-view-card:hover { + border-color: var(--bs-primary); +} +.smart-view-card span { + font-size: .8rem; + color: var(--bs-primary); +} +.notification-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: .75rem; + margin-bottom: .75rem; +} +.notification-list { + display: grid; + gap: .55rem; +} +.notification-item { + display: grid; + grid-template-columns: auto 1fr; + gap: .65rem; + padding: .7rem .8rem; +} +.notification-item > i { + margin-top: .15rem; +} +.notification-item > div { + display: grid; + gap: .15rem; + min-width: 0; +} +.notification-item span { + overflow-wrap: anywhere; +} +.notification-error > i, +.notification-warning > i { + color: var(--bs-warning); +} +.notification-planner > i, +.notification-queue > i { + color: var(--bs-primary); +} + +/* Diagnostics layout */ +.diagnostics-section { + display: grid; + gap: .75rem; + margin-bottom: 1rem; +} +.diagnostics-section:last-child { + margin-bottom: 0; +} + +/* Columns tab panes keep the original column card layout for both views. */ +.column-manager-pane, +.column-manager-tabs { + grid-column: 1 / -1; +} + +.column-manager-tabs { + margin-bottom: .75rem; +} +.column-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); + gap: 0.55rem; +} +.mobile-sort-row .btn { + pointer-events: auto; +} +.mobile-progress:empty { + display: none; +} + +.profile-status-badge{font-size:.7rem;text-transform:uppercase;letter-spacing:.02em;} +.profile-diagnostics-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:.5rem;} +.profile-diagnostics-card{border:1px solid var(--bs-border-color);border-radius:.5rem;padding:.5rem;background:var(--bs-body-bg);} +.profile-diagnostics-card small{display:block;color:var(--bs-secondary-color);} + +.labels-manager { display: grid; gap: 0.5rem; } +.profile-status-badge.badge { min-height: 1.25rem; line-height: 1; display: inline-flex; align-items: center; padding: .25em .5em; } + +/* UI hygiene: keep long status/footer content inside the app instead of widening the browser viewport. */ +html, +body, +.app-shell, +.topbar, +.main-grid, +.content, +.statusbar { + max-width: 100%; + min-width: 0; +} + +.statusbar { + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; + overscroll-behavior-x: contain; +} + +.statusbar > * { + flex: 0 0 auto; +} + +/* Compact rTorrent profile badges so online/slow/degraded match archive-style pills. */ +.profile-status-badge.badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: auto; + min-width: 0; + min-height: 1.2rem; + max-width: max-content; + padding: 0.18rem 0.45rem; + font-size: 0.68rem; + line-height: 1; + letter-spacing: 0.015em; + text-transform: uppercase; + white-space: nowrap; + vertical-align: middle; +} + +.profile-row { + grid-template-columns: minmax(0, 1fr) max-content; +} + +.profile-actions { + justify-content: flex-end; +} + +.preferences-browser-layout { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 0.75rem; +} + +.preference-block { + height: 100%; +} + +.management-card { + border: 1px solid var(--bs-border-color); + border-radius: 0.8rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.42); + padding: 0.85rem; +} + +.management-card-title { + display: flex; + align-items: center; + gap: 0.45rem; + margin-bottom: 0.7rem; + color: var(--bs-body-color); + font-weight: 700; +} + +.management-form-grid { + align-items: end; +} + +.management-form-grid .form-field, +.management-switch { + min-width: 0; + margin: 0; +} + +.management-form-grid .form-field > span:first-child { + display: block; + margin-bottom: 0.25rem; + color: var(--bs-secondary-color); + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; +} + +.management-form-grid .form-field-wide { + grid-column: span 2; +} + +.management-switch { + display: flex; + align-items: center; + min-height: 2.35rem; + gap: 0.45rem; +} + +.management-actions { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + margin-top: 0.75rem; +} + +.tool-split-section .table, +.management-card .table { + margin-bottom: 0; +} + +#smartPane-logs, +#automationPane-logs { + padding-top: 0.25rem; +} + +@media (max-width: 768px) { + .management-form-grid .form-field-wide { + grid-column: auto; + } + + .management-actions { + align-items: stretch; + flex-direction: column; + } +} + +/* Keep rTorrent diagnostics badges visually aligned with the smaller active/archive pills. */ +.profile-row .profile-status-badge.badge { + min-height: 1rem; + padding: 0.1rem 0.32rem; + font-size: 0.58rem; + line-height: 1; + letter-spacing: 0.01em; + border-radius: 999px; +} + +/* Flat nested sections inside already framed preference panels. */ +.disk-monitor-shell-flat .disk-monitor-mode-card, +.disk-monitor-shell-flat .disk-monitor-path-card { + border: 0; + border-radius: 0; + background: transparent; + padding: 0; +} + +.create-torrent-form { + display: grid; + gap: 0.85rem; +} + +.add-create-modal-body { + padding-top: 1rem; +} + +.add-create-tab-content { + margin-top: 0.25rem; +} + +.add-torrent-layout { + display: grid; + gap: 0.85rem; +} + +.add-torrent-panel { + background: color-mix(in srgb, var(--bs-body-bg) 92%, var(--bs-tertiary-bg)); + border: 1px solid color-mix(in srgb, var(--bs-border-color) 72%, transparent); + border-radius: 0.85rem; + box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 5%, transparent); + padding: 0.9rem; +} + +.add-torrent-panel-heading, +.add-target-grid, +.create-options-panel { + align-items: center; + display: grid; + gap: 0.75rem; +} + +.add-torrent-panel-heading { + grid-template-columns: minmax(0, 1fr) auto; + margin-bottom: 0.75rem; +} + +.add-magnet-input { + min-height: 8.5rem; + resize: vertical; +} + +.add-file-picker { + overflow: hidden; + position: relative; + white-space: nowrap; +} + +.add-file-picker input { + height: 1px; + opacity: 0; + position: absolute; + right: 0; + top: 0; + width: 1px; +} + +.add-file-summary { + align-items: center; + background: var(--bs-tertiary-bg); + border: 1px dashed var(--bs-border-color); + border-radius: 0.7rem; + color: var(--bs-secondary-color); + display: flex; + min-height: 2.6rem; + padding: 0.6rem 0.75rem; +} + +.add-file-preview:not(:empty) { + margin-top: 0.75rem; +} + +.add-target-grid { + grid-template-columns: minmax(14rem, 1fr) minmax(10rem, 16rem) auto; +} + +.add-start-card { + align-items: center; + display: flex; + gap: 0.55rem; + justify-content: flex-start; + margin: 0; + min-height: 2.4rem; + padding: 0; + white-space: nowrap; +} + +.add-start-card .form-check-input { + flex: 0 0 auto; + margin: 0; +} + +.create-properties-grid, +.create-meta-grid { + display: grid; + gap: 0.75rem; + grid-template-columns: minmax(0, 1fr) minmax(13rem, 18rem); +} + +.create-side-fields { + display: grid; + gap: 0.75rem; +} + +.create-options-panel { + grid-template-columns: repeat(auto-fit, minmax(13rem, max-content)); + justify-content: start; +} + +@media (max-width: 992px) { + .add-target-grid, + .create-properties-grid, + .create-meta-grid { + grid-template-columns: 1fr; + } + + .add-start-card { + justify-content: flex-start; + } +} + +@media (max-width: 576px) { + .add-torrent-panel-heading { + grid-template-columns: 1fr; + } +} + +/* API tokens and path picker improvements */ +.api-token-row { + align-items: center; + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + display: flex; + gap: 0.75rem; + justify-content: space-between; + padding: 0.75rem; +} + +.api-token-row + .api-token-row { + margin-top: 0.5rem; +} + +.api-token-row small { + color: var(--bs-secondary-color); + display: block; + margin-top: 0.15rem; +} + +.path-info-strip { + align-items: center; + background: var(--bs-tertiary-bg); + border-bottom: 1px solid var(--bs-border-color); + display: flex; + flex-wrap: wrap; + gap: 0.5rem 0.85rem; + padding: 0.65rem 0.75rem; +} + +.path-info-strip span { + color: var(--bs-secondary-color); + font-size: 0.82rem; +} + +@media (max-width: 576px) { + .api-token-row { + align-items: stretch; + flex-direction: column; + } +} + +#pathModal { + z-index: 1080; +} + +#pathModal + .modal-backdrop, +.modal-backdrop.path-picker-backdrop { + z-index: 1075; +} + +.api-token-inline { + background: var(--bs-tertiary-bg); + border: 1px solid var(--bs-border-color); + border-radius: 0.85rem; + padding: 0.85rem; +} + +.api-token-inline .input-group { + margin-top: 0.45rem; +} + +.api-token-inline small { + color: var(--bs-secondary-color); + display: block; +} + +#toolsModal .modal-body { + min-width: 0; + overflow-x: hidden; +} + +#toolsModal .tools-nav { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(145px, 1fr)); + gap: 0.45rem; + max-width: 100%; + overflow-x: hidden; +} + +#toolsModal .tools-nav .nav-item { + min-width: 0; +} + +#toolsModal .tools-nav .nav-link { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 0.4rem; + width: 100%; + min-height: 2.35rem; + padding: 0.55rem 0.65rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.28); + line-height: 1.15; + text-align: left; + white-space: normal; + justify-content: center; + text-align: center; +} + +#toolsModal .tools-nav .nav-link:hover { + background: rgba(var(--bs-primary-rgb), 0.08); + border-color: rgba(var(--bs-primary-rgb), 0.35); +} + +#toolsModal .tools-nav .nav-link.active { + background: rgba(var(--bs-primary-rgb), 0.14); + border-color: rgba(var(--bs-primary-rgb), 0.55); + color: var(--bs-primary-text-emphasis); + font-weight: 600; + box-shadow: 0 0 0 0.12rem rgba(var(--bs-primary-rgb), 0.12); +} + +#toolsModal .tools-nav .nav-link i { + flex: 0 0 1rem; + margin-right: 0; + text-align: center; +} + +.table-action-group { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem; +} + +.table-action-group .btn { + white-space: nowrap; +} + +.backup-actions { + justify-content: flex-start; +} + +.backup-create-row { + min-width: 0; +} + +.backup-preview { + margin-bottom: 1rem; +} + +.backup-preview-card { + background: rgba(var(--bs-primary-rgb), 0.04); +} + +.backup-settings-grid { + display: grid; + grid-template-columns: minmax(14rem, 1fr) repeat(2, minmax(8rem, 0.5fr)) auto; + align-items: end; + gap: 0.75rem; +} + +.backup-auto-switch { + align-self: center; + margin-bottom: 0; +} + +.backup-table td:last-child, +.backup-table th:last-child { + text-align: left; + vertical-align: middle; +} + +@media (max-width: 768px) { + .profile-form-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + width: 100%; + } + + .profile-form-actions .btn { + min-width: 0; + width: 100%; + } + + #toolsModal .tools-nav { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.4rem; + } + + #toolsModal .tools-nav .nav-link { + min-height: 2.25rem; + padding: 0.5rem 0.55rem; + font-size: 0.82rem; + display: flex; + + align-items: center; + justify-content: center; + text-align: center; + } + + .backup-create-row { + display: grid; + gap: 0.45rem; + } + + .backup-create-row > .form-control, + .backup-create-row > .btn { + width: 100%; + } + + .backup-settings-grid { + grid-template-columns: 1fr; + } + + .table-action-group .btn { + flex: 1 1 7.5rem; + } +} + +@media (max-width: 420px) { + .profile-form-actions { + grid-template-columns: 1fr; + } + + + #toolsModal .tools-nav { + grid-template-columns: 1fr; + } +} + +/* Column manager actions stay separate from the card grid for predictable spacing. */ +.column-manager-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +/* Note: Preference switches use a two-column layout so labels and descriptions never overlap the toggle control. */ +.browser-speed-pref { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.15rem 0.65rem; + align-items: start; + margin: 0; +} + +.browser-speed-pref .form-check-input { + grid-row: 1 / span 2; + margin-top: 0.18rem; +} + +.browser-speed-pref .form-check-label, +.browser-speed-pref small { + grid-column: 2; + min-width: 0; +} + +.browser-speed-pref small { + color: var(--bs-secondary-color); + line-height: 1.35; +} + +.cleanup-section { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + padding: 0.75rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.3); +} + +.cleanup-section b, +.cleanup-section small { + display: block; +} + +.cleanup-section small { + margin-top: 0.15rem; + color: var(--bs-secondary-color); +} + +@media (max-width: 576px) { + .cleanup-section { + flex-direction: column; + } +} + +.appstatus-tabs { + margin-bottom: 0.75rem; +} + +/* Chunks tab: ruTorrent-like piece map with safe rTorrent actions. */ +.chunks-panel { + display: grid; + gap: 0.75rem; +} + +.chunks-toolbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; +} + +.chunks-title { + display: flex; + align-items: center; + gap: 0.45rem; + font-weight: 700; +} + +.chunks-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.4rem; +} + +.chunk-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); + gap: 0.5rem; +} + +.chunk-stat { + padding: 0.55rem 0.65rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.36); +} + +.chunk-stat b, +.chunk-stat span { + display: block; +} + +.chunk-stat span { + color: var(--bs-secondary-color); + font-size: 0.88rem; +} + +.chunk-tools-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; +} + +.chunk-legend { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 0.8rem; + color: var(--bs-secondary-color); + font-size: 0.85rem; +} + +.chunk-controls { + display: flex; + align-items: flex-end; + gap: 0.5rem; + flex-wrap: wrap; +} + +.chunk-controls label { + display: grid; + gap: 0.2rem; + min-width: 8.5rem; + color: var(--bs-secondary-color); + font-size: 0.78rem; +} + +.chunk-controls select { + font-size: 0.82rem; +} + +.chunk-legend-item { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.chunk-dot { + width: 0.7rem; + height: 0.7rem; + border: 1px solid var(--bs-border-color); + border-radius: 999px; +} + +.chunk-selection-info { + padding: 0.5rem 0.65rem; + border: 1px dashed var(--bs-border-color); + border-radius: 0.7rem; + color: var(--bs-secondary-color); + font-size: 0.85rem; +} + +.chunk-grid { + --chunk-cell-width: 0.72rem; + --chunk-cell-height: 1.2rem; + --chunk-cell-gap: 0.2rem; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(var(--chunk-cell-width), var(--chunk-cell-width))); + gap: var(--chunk-cell-gap); + justify-content: start; + padding: 0.55rem; + overflow: visible; + border: 1px solid var(--bs-border-color); + border-radius: 0.85rem; + background: rgba(var(--bs-tertiary-bg-rgb), 0.75); +} + +.chunk-grid[data-density="compact"] { + --chunk-cell-width: 0.56rem; + --chunk-cell-height: 1rem; + --chunk-cell-gap: 0.16rem; +} + +.chunk-grid[data-density="detailed"] { + --chunk-cell-width: 1rem; + --chunk-cell-height: 1.45rem; + --chunk-cell-gap: 0.24rem; +} + +.chunk-cell { + position: relative; + width: var(--chunk-cell-width); + min-width: var(--chunk-cell-width); + height: var(--chunk-cell-height); + padding: 0; + overflow: hidden; + border: 1px solid rgba(var(--bs-body-color-rgb), 0.18); + border-radius: 0.28rem; + background: rgba(var(--bs-body-color-rgb), 0.06); + cursor: pointer; +} + +.chunk-cell span { + position: absolute; + right: 0; + bottom: 0; + left: 0; + pointer-events: none; +} + +.chunk-cell:hover, +.chunk-cell.is-selected { + outline: 2px solid var(--bs-primary); + outline-offset: 1px; +} + +.chunk-cell.is-grouped { + box-shadow: inset 0 0 0 1px rgba(var(--bs-body-color-rgb), 0.08); +} + +.chunk-complete, +.chunk-complete span { + background: var(--bs-success); +} + +.chunk-partial span { + background: var(--bs-primary); +} + +.chunk-missing span { + background: transparent; +} + +.chunk-seen span { + background: var(--bs-warning); +} + +@media (max-width: 768px) { + .chunks-toolbar { + display: grid; + } + + .chunks-actions { + justify-content: flex-start; + } + + .chunk-grid { + --chunk-cell-width: 0.9rem; + } +} + + +/* Smart Queue exception picker */ +.smart-exclusion-choice-list { + display: grid; + gap: 0.45rem; + max-height: 60vh; + overflow: auto; + padding-right: 0.25rem; +} + +.smart-exclusion-choice-row { + display: flex; + align-items: flex-start; + gap: 0.65rem; + padding: 0.65rem 0.75rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.65rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.24); + cursor: pointer; +} + +.smart-exclusion-choice-row:hover { + background: rgba(var(--bs-primary-rgb), 0.06); +} + +.smart-exclusion-choice-row .form-check-input { + flex: 0 0 auto; + margin-top: 0.15rem; +} + +.smart-exclusion-choice-row span, +.smart-exclusion-choice-row b, +.smart-exclusion-choice-row small { + display: block; + min-width: 0; +} + +.smart-exclusion-choice-row small { + color: var(--bs-secondary-color); + overflow-wrap: anywhere; +} + +/* Backup preview data samples */ +.backup-preview-empty { + color: var(--bs-secondary-color); + padding: 0.5rem 0; +} + +.backup-preview-table-details { + border: 1px solid var(--bs-border-color); + border-radius: 0.65rem; + margin-top: 0.55rem; + overflow: hidden; +} + +.backup-preview-table-details summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.65rem 0.75rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.28); + cursor: pointer; +} + +.backup-preview-table-details summary span, +.backup-preview-table-details summary b, +.backup-preview-table-details summary small { + display: block; +} + +.backup-preview-table-details summary small { + color: var(--bs-secondary-color); +} + +.backup-preview-sample-table { + min-width: 760px; +} + +.backup-preview-sample-table td, +.backup-preview-sample-table th { + max-width: 18rem; + overflow-wrap: anywhere; + white-space: normal; +} diff --git a/pytorrent/static/styles.original.css b/pytorrent/static/styles.original.css new file mode 100644 index 0000000..293e4d2 --- /dev/null +++ b/pytorrent/static/styles.original.css @@ -0,0 +1,3933 @@ +:root { + --app-font-family: + Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; + --ui-scale: 1; + --topbar: calc(50px * var(--ui-scale)); + --statusbar: calc(34px * var(--ui-scale)); + --mobile-filterbar-height: 132px; + --sidebar: calc(270px * var(--ui-scale)); + --torrent-progress-complete: #198754; +} +[data-bs-theme="dark"] { + --bs-body-bg: #05070a; + --bs-body-bg-rgb: 5, 7, 10; + --bs-body-color: #d6dde8; + --bs-secondary-bg: #0a0f16; + --bs-secondary-bg-rgb: 10, 15, 22; + --bs-tertiary-bg: #0e141d; + --bs-border-color: #1d2734; + --bs-secondary-color: #8d98aa; + --bs-primary-bg-subtle: #0d2238; + --bs-primary-text-emphasis: #9ecbff; + --torrent-progress-complete: #2f9e75; +} + +html[data-app-font="adwaita-mono"] { + --app-font-family: + "Adwaita Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, + "Liberation Mono", monospace; +} +html[data-app-font="inter"] { + --app-font-family: + Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; +} +html[data-app-font="system-ui"] { + --app-font-family: + system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; +} +html[data-app-font="source-sans-3"] { + --app-font-family: + "Source Sans 3", "Source Sans Pro", system-ui, -apple-system, Segoe UI, + Roboto, Arial, sans-serif; +} +html[data-app-font="jetbrains-mono"] { + --app-font-family: + "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, + "Liberation Mono", monospace; +} +html, +body { + height: 100%; +} +body { + overflow: hidden; + overflow-x: hidden; + font-size: calc(13px * var(--ui-scale)); + min-height: 100vh; + min-height: 100dvh; + padding: calc(8px * var(--ui-scale)); + background: #05070a; + font-family: var(--app-font-family); +} +.app-shell { + height: calc(100vh - (16px * var(--ui-scale))); + height: calc(100dvh - (16px * var(--ui-scale))); + display: grid; + grid-template-rows: var(--topbar) 1fr var(--statusbar); + background: var(--bs-body-bg); + border: 1px solid var(--bs-border-color); + border-radius: 12px; + overflow: hidden; + box-shadow: 0 12px 45px rgba(0, 0, 0, 0.38); +} +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.42rem 0.7rem; + min-height: var(--topbar); + background: var(--bs-secondary-bg); +} +.toolbar-left, +.toolbar-right { + display: flex; + align-items: center; + gap: 0.45rem; + min-width: 0; +} +.toolbar-left { + flex: 0 1 auto; + overflow: hidden; +} +.toolbar-right { + flex: 1 1 0; + justify-content: flex-end; + margin-left: auto; +} +.brand { + font-weight: 800; + font-size: 1.05rem; + letter-spacing: 0.2px; + white-space: nowrap; + line-height: 32px; +} +.profile-picker-btn { + max-width: 180px; +} +.profile-picker-btn span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.profile-select { + width: 100%; +} +.search { + width: min(38vw, 420px); + min-width: clamp(160px, 20vw, 220px); + max-width: 420px; + flex: 0 1 420px; +} +.mobile-speed-stats { + display: none; + align-items: center; + gap: 0.45rem; + flex: 0 0 auto; + color: var(--bs-secondary-color); + font-size: 0.72rem; + white-space: nowrap; +} +.mobile-speed-stats b { + color: var(--bs-body-color); + font-weight: 700; +} +.mobile-speed-stats span { + display: inline-flex; + align-items: center; + gap: 0.18rem; +} +.topbar .form-control, +.topbar .form-select { + height: 32px; + line-height: 1.15; +} +.topbar .btn { + min-height: 28px; + line-height: 1; +} +#themeToggle, +#mobileToggle { + width: 32px; + min-width: 32px; + display: inline-flex; + align-items: center; + justify-content: center; +} +.spinner-border-xs { + width: 0.75rem; + height: 0.75rem; + border-width: 0.12em; + vertical-align: -1px; +} +.global-loader { + position: fixed; + right: 14px; + bottom: 44px; + z-index: 7000; + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.4rem 0.65rem; + border-radius: 999px; + background: var(--bs-tertiary-bg); + color: var(--bs-body-color); + border: 1px solid var(--bs-border-color); + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35); +} + +.initial-loader { + position: fixed; + inset: 0; + z-index: 9000; + display: grid; + place-items: center; + padding: 1rem; + background: radial-gradient( + circle at 50% 35%, + rgba(var(--bs-secondary-bg-rgb), 0.98), + var(--bs-body-bg) 68% + ); + color: var(--bs-body-color); + transition: + opacity 0.22s ease, + visibility 0.22s ease; +} +.initial-loader.is-hidden { + opacity: 0; + visibility: hidden; + pointer-events: none; +} +.initial-loader-card { + width: min(92vw, 430px); + padding: 2rem; + border: 1px solid var(--bs-border-color); + border-radius: 18px; + background: rgba(var(--bs-secondary-bg-rgb), 0.88); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.48); + text-align: center; +} +.initial-loader-brand { + font-size: 1.35rem; + font-weight: 800; + letter-spacing: 0.2px; +} +.initial-loader-spinner { + margin: 1.4rem 0 1rem; +} +.initial-loader-title { + font-size: 1rem; + font-weight: 700; +} +.initial-loader-text { + margin-top: 0.35rem; + color: var(--bs-secondary-color); +} + +.main-grid { + min-height: 0; + display: grid; + grid-template-columns: var(--sidebar) 1fr; +} +/* Note: Sidebar filters are denser so large tracker lists fit better on one screen. */ +.sidebar { + padding: 0.5rem; + overflow: auto; + background: rgba(var(--bs-secondary-bg-rgb), 0.9); +} +.filter { + width: 100%; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.1rem 0.45rem; + align-items: center; + margin-bottom: 0.12rem; + padding: 0.34rem 0.5rem; + border: 0; + border-radius: 0.55rem; + background: transparent; + color: var(--bs-body-color); + text-align: left; +} +.filter:hover, +.filter.active { + background: var(--bs-primary-bg-subtle); + color: var(--bs-primary-text-emphasis); +} +.filter > span:first-child { + min-width: 0; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.filter > span:last-child { + min-width: 0; + max-width: 12rem; + text-align: right; +} +.filter-count { + display: block; + font-weight: 700; + line-height: 1.1; +} +.filter-meta { + display: block; + margin-top: 0.05rem; + color: var(--bs-secondary-color); + font-size: 0.68rem; + font-weight: 400; + line-height: 1.15; + opacity: 0.72; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.filter.active .filter-meta, +.filter:hover .filter-meta { + color: var(--bs-primary-text-emphasis); + opacity: 0.78; +} +.shortcut { + font-size: 0.78rem; + color: var(--bs-secondary-color); + padding: 0.15rem 0.5rem; +} +.content { + min-width: 0; + min-height: 0; + display: grid; + grid-template-rows: minmax(0, 1fr) 7px var(--detail-panel-height, 255px); + position: relative; +} +.table-wrap { + contain: content; + overflow: auto; + position: relative; +} +.torrent-table { + margin: 0; + white-space: nowrap; + table-layout: auto; +} +.torrent-table thead th { + position: sticky; + top: 0; + z-index: 2; + background: var(--bs-tertiary-bg); + border-bottom: 1px solid var(--bs-border-color); + user-select: none; +} +.torrent-table thead th[data-sort] { + cursor: pointer; +} +.torrent-table thead th[data-sort]:hover, +.torrent-table thead th.sorted { + color: var(--bs-primary-text-emphasis); +} +.sort-icon { + opacity: 0.85; +} +.torrent-table tbody tr { + cursor: default; + height: 32px; +} +.torrent-table > :not(caption) > * > * { + padding-bottom: 0.22rem; + padding-top: 0.22rem; + vertical-align: middle; +} +.torrent-table .message { + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; +} +.torrent-table tbody tr.selected td { + background: var(--bs-primary-bg-subtle); +} +.torrent-table .sel { + width: 34px; + text-align: center; +} +.torrent-table .name { + min-width: 280px; + max-width: 520px; + overflow: hidden; + text-overflow: ellipsis; +} +.torrent-table .path { + max-width: 360px; + overflow: hidden; + text-overflow: ellipsis; + color: var(--bs-secondary-color); +} +.virtual-spacer td { + padding: 0 !important; + border: 0 !important; +} +.empty { + height: 120px; + text-align: center; + vertical-align: middle; + color: var(--bs-secondary-color); +} +.progress.thin { + height: 7px; + min-width: 130px; + margin-bottom: 1px; + background: rgba(255, 255, 255, 0.08); +} +.details { + grid-row: 3; + grid-column: 1; + min-height: 0; + overflow: hidden; + background: rgba(var(--bs-secondary-bg-rgb), 0.78); +} +.detail-pane { + height: calc(var(--detail-panel-height, 255px) - 45px); + overflow: auto; + padding: 0.5rem 0.65rem; +} +.detail-resize-handle { + grid-row: 2; + grid-column: 1; + align-items: center; + background: rgba(var(--bs-secondary-bg-rgb), 0.72); + cursor: row-resize; + display: flex; + justify-content: center; + min-height: 7px; + position: relative; + z-index: 3; +} +.detail-resize-handle::before { + background: var(--bs-border-color); + border-radius: 999px; + content: ''; + height: 3px; + width: 46px; +} +.detail-resize-handle:hover::before, +body.resizing-details .detail-resize-handle::before { + background: var(--bs-primary); +} +body.resizing-details { + cursor: row-resize; + user-select: none; +} +.loading-line { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--bs-secondary-color); + padding: 0.75rem; +} +.muted-pane { + color: var(--bs-secondary-color); +} +.detail-table { + white-space: nowrap; +} +.responsive-table-wrap { + max-width: 100%; + overflow-x: auto; + border: 1px solid var(--bs-border-color); + border-radius: 0.6rem; + -webkit-overflow-scrolling: touch; +} +.responsive-table-wrap .detail-table { + margin-bottom: 0; +} +.smart-exclusions-table { + min-width: 680px; +} +.smart-history-table { + min-width: 760px; + table-layout: fixed; +} +.smart-history-table th, +.smart-history-table td { + overflow-wrap: anywhere; + white-space: normal; +} +.general-summary, +.general-grid, +.general-meta { + display: grid; + gap: 0.75rem; +} + +.general-summary { + grid-template-columns: minmax(0, 2fr) minmax(16rem, 1fr); + margin-bottom: 0.75rem; +} + +.general-summary-main, +.general-summary-side, +.general-stat, +.general-meta > div { + background: var(--bs-body-bg); + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + min-width: 0; + padding: 0.75rem; +} + +.general-title-row { + align-items: flex-start; + display: flex; + gap: 0.75rem; + justify-content: space-between; +} + +.general-title-row h6 { + font-size: 1rem; + line-height: 1.35; + margin: 0; + overflow-wrap: anywhere; +} + +.general-path { + display: grid; + gap: 0.15rem; + margin-top: 0.5rem; + overflow-wrap: anywhere; +} + +.general-path b { + color: var(--bs-secondary-color); + font-size: 0.72rem; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.general-path span { + font-size: 0.82rem; +} + + +.general-summary-side code { + display: block; + font-size: 0.78rem; + overflow-wrap: anywhere; + white-space: normal; +} + +.general-grid { + grid-template-columns: repeat(5, minmax(0, 1fr)); +} + +.general-meta { + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-top: 0.75rem; +} + +.general-stat b, +.general-meta b, +.general-summary-side b { + color: var(--bs-secondary-color); + display: block; + font-size: 0.72rem; + letter-spacing: 0.03em; + margin-bottom: 0.25rem; + text-transform: uppercase; +} + +.general-stat span, +.general-meta span { + display: block; + overflow-wrap: anywhere; +} +.statusbar { + display: flex; + align-items: center; + gap: 1rem; + padding: 0 0.75rem; + overflow-x: auto; + background: var(--bs-tertiary-bg); + color: var(--bs-secondary-color); + white-space: nowrap; +} +.statusbar b { + color: var(--bs-body-color); +} +.speed-peaks { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} +.status-limit { + border: 1px solid var(--bs-border-color); + background: rgba(var(--bs-secondary-bg-rgb), 0.9); + color: var(--bs-secondary-color); + border-radius: 0.45rem; + padding: 0.12rem 0.5rem; + white-space: nowrap; +} +.status-limit:hover { + color: var(--bs-body-color); + background: var(--bs-secondary-bg); +} +.ctx-menu { + display: none; + position: absolute; + z-index: 5000; + min-width: 200px; + padding: 0.35rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.6rem; + background: var(--bs-body-bg); +} +.ctx-menu button { + display: block; + width: 100%; + text-align: left; + border: 0; + background: transparent; + color: var(--bs-body-color); + padding: 0.42rem 0.55rem; + border-radius: 0.4rem; +} +.ctx-menu button:hover { + background: var(--bs-secondary-bg); +} +.ctx-menu .danger { + color: var(--bs-danger); +} +.ctx-menu hr { + margin: 0.25rem 0; + border-color: var(--bs-border-color); +} +.profile-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.25rem 0.5rem; + align-items: center; + margin-bottom: 0.45rem; + padding: 0.45rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.6rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.58); +} +.profile-row.active { + border-color: var(--bs-primary); + background: var(--bs-primary-bg-subtle); +} +.profile-row span { + grid-column: 1 / 2; + color: var(--bs-secondary-color); + overflow-wrap: anywhere; +} +.profile-actions, +.profile-form-actions { + display: inline-flex; + gap: 0.35rem; + flex-wrap: wrap; +} +.profile-form-grid { + display: grid; + grid-template-columns: minmax(150px, 1.1fr) minmax(260px, 2.1fr) minmax( + 90px, + 0.55fr + ) minmax(120px, 0.75fr) minmax(145px, auto) auto; + gap: 0.65rem; + align-items: start; +} +.profile-form-field { + display: grid; + gap: 0.25rem; + min-width: 0; +} +.profile-form-field > span:first-child { + color: var(--bs-secondary-color); + font-size: 0.72rem; + font-weight: 700; + line-height: 1.1; + text-transform: uppercase; +} +.profile-form-field small { + color: var(--bs-secondary-color); + line-height: 1.25; +} +.profile-check-field .form-check { + min-height: 31px; + display: flex; + align-items: center; + gap: 0.45rem; +} +.flag-icon { + border-radius: 2px; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.12); +} +.flag-code { + color: var(--bs-secondary-color); + margin-left: 0.25rem; +} +.modal-content { + background: var(--bs-body-bg); + border: 1px solid var(--bs-border-color); + border-radius: 14px; +} +.modal-header, +.modal-footer { + background: rgba(var(--bs-secondary-bg-rgb), 0.82); + border-color: var(--bs-border-color); +} +.add-grid { + display: grid; + gap: 0.85rem; +} +.magnet-box { + min-height: 92px; + resize: vertical; +} +.upload-box, +.surface-section { + border: 1px solid var(--bs-border-color); + background: rgba(var(--bs-secondary-bg-rgb), 0.5); + border-radius: 0.75rem; + padding: 0.75rem; +} +.section-title { + font-weight: 700; + margin-bottom: 0.55rem; + color: var(--bs-body-color); +} +.preset-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.4rem; +} +.toast-host { + position: fixed; + right: 14px; + top: 70px; + z-index: 8000; + display: grid; + gap: 0.4rem; +} +.toast-item { + display: flex; + align-items: center; + gap: 0.45rem; + max-width: 360px; + padding: 0.45rem 0.65rem; + border-radius: 0.55rem; + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.28); +} + +.toast-message { + min-width: 0; + overflow-wrap: anywhere; +} + +.toast-count { + flex: 0 0 auto; + padding: 0.05rem 0.35rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.22); + font-size: 0.78rem; + font-weight: 700; +} +@media (max-width: 1100px) { + :root { + --topbar: 88px; + } + .topbar { + align-items: flex-start; + flex-wrap: wrap; + } + .toolbar-left { + flex: 1 1 100%; + overflow: visible; + flex-wrap: wrap; + } + .toolbar-right { + flex: 1 1 100%; + justify-content: flex-end; + } + .search { + flex: 1 1 220px; + width: auto; + min-width: 160px; + max-width: none; + } +} +@media (max-width: 900px) { + :root { + --sidebar: 0px; + } + .sidebar { + display: none; + } + .general-summary, + .general-grid, + .general-meta { + grid-template-columns: 1fr; + } +} +@media (max-width: 640px) { + :root { + --topbar: 132px; + } + .preset-grid { + grid-template-columns: 1fr 1fr; + } +} + +.job-settings-grid { + display: grid; + grid-template-columns: repeat(2, minmax(220px, 1fr)); + gap: 0.75rem; +} +.job-settings-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.preferences-grid { + display: grid; + grid-template-columns: repeat(2, minmax(220px, 1fr)); + gap: 0.75rem; +} +.form-field { + display: grid; + gap: 0.3rem; +} +.form-field > span { + color: var(--bs-secondary-color); + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; +} + +@media (max-width: 640px) { + .job-settings-grid, + .preferences-grid { + grid-template-columns: 1fr; + } +} + +.date-compact { + white-space: nowrap; +} +.btn-xs { + --bs-btn-padding-y: 0.18rem; + --bs-btn-padding-x: 0.42rem; + --bs-btn-font-size: 0.78rem; + --bs-btn-border-radius: 0.35rem; +} +.nav-btn { + border-radius: 0.45rem !important; + margin: 0 !important; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.25rem; +} +.nav-btn + .nav-btn, +.torrent-action + .torrent-action { + margin-left: 0.08rem !important; +} +.path-list { + height: 360px; + overflow: auto; + border: 1px solid var(--bs-border-color); + border-radius: 0.6rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.35); +} +.path-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.42rem 0.6rem; + border-bottom: 1px solid var(--bs-border-color); + cursor: pointer; +} +.path-row:hover { + background: var(--bs-primary-bg-subtle); + color: var(--bs-primary-text-emphasis); +} +.chips { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; +} +.chip { + border: 1px solid var(--bs-border-color); + background: rgba(var(--bs-secondary-bg-rgb), 0.6); + color: var(--bs-body-color); + border-radius: 999px; + padding: 0.22rem 0.6rem; + font-size: 0.78rem; +} +.mobile-list { + overflow: auto; + padding: 0.55rem; + background: var(--bs-body-bg); +} +.mobile-card { + border: 1px solid var(--bs-border-color); + background: rgba(var(--bs-secondary-bg-rgb), 0.72); + border-radius: 0.75rem; + padding: 0.65rem; + margin-bottom: 0.55rem; +} +.mobile-card.selected { + outline: 2px solid var(--bs-primary); +} +.mobile-card .name { + font-weight: 700; + word-break: break-word; +} +.mobile-actions { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: 0.45rem; +} +#systemChart { + width: 140px; + height: 24px; + border: 1px solid var(--bs-border-color); + border-radius: 0.35rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.85); +} +.badge-degraded { + background: #f59e0b !important; + color: #111 !important; +} +body.mobile-mode .table-wrap, +body.mobile-mode .detail-resize-handle { + display: none; +} +body.mobile-mode .details { + display: none !important; +} +/* Note: Merged mobile list rules remove duplicate CSS selectors. */ +body.mobile-mode #mobileList { + display: block !important; + grid-row: 3; + min-height: 0; + height: 100%; + overflow: auto; + position: relative; + z-index: 2; + padding: 0.55rem 0.55rem 1rem !important; +} +body.mobile-mode .content { + display: grid !important; + grid-template-rows: auto auto minmax(0, 1fr) !important; + min-height: 0; + overflow: hidden; +} +body.mobile-mode .torrent-table { + display: none; +} +body.mobile-mode .main-grid { + min-height: 0; + overflow: hidden; +} +@media (max-width: 640px) { + .nav-btn span { + display: none; + } +} + +.torrent-table td:nth-child(5) { + min-width: 92px; + width: 110px; + white-space: nowrap; +} + +.mobile-sort-row { + display: flex; + margin-top: 0.4rem; + justify-content: flex-end; + gap: 0.5rem; +} +.mobile-sort-row .btn { + width: 100%; + justify-content: center; +} + +.view-preferences-note { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.75rem; + padding: 0.65rem 0.75rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.72); + color: var(--bs-secondary-color); +} +.view-preferences-note i { + color: var(--bs-primary); +} +.view-preferences-note span { + flex: 1 1 260px; +} +.view-preferences-note .btn { + flex: 0 0 auto; +} + +.hidden-col { + display: none !important; +} +.status-docs { + margin-left: auto; + color: inherit; + text-decoration: none; + font-weight: 600; + opacity: 0.9; + white-space: nowrap; +} +.status-docs:hover { + opacity: 1; + text-decoration: underline; +} +.column-check { + padding: 0.35rem 0.5rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.5rem; + background: var(--bs-body-bg); +} +.label-filters .label-filter, +.tracker-filters .tracker-filter { + font-size: 0.78rem; + margin-bottom: 0.08rem; + padding: 0.26rem 0.44rem; +} +.label-filters .label-filter i, +.tracker-filters .tracker-filter i { + opacity: 0.75; + margin-right: 0.25rem; +} + +.tracker-filters .tracker-filter span:first-child { + align-items: center; + display: inline-flex; + gap: 0.35rem; + min-width: 0; +} + +.tracker-favicon { + border-radius: 0.2rem; + flex: 0 0 auto; + height: 14px; + object-fit: contain; + width: 14px; +} + +.tracker-favicon:not(.d-none) + .tracker-fallback-icon { + display: none; +} + +.tracker-filter-empty { + align-items: center; + color: var(--bs-secondary-color); + display: flex; + font-size: 0.76rem; + gap: 0.3rem; + padding: 0.2rem 0.44rem; +} + +/* Note: Empty tracker state uses the same sidebar spacing as regular filter rows. */ +.tracker-filter-empty .spinner-border-xs { + height: 0.65rem; + width: 0.65rem; +} + +.column-manager { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); + gap: 0.55rem; +} + +.column-card { + display: flex; + align-items: center; + gap: 0.55rem; + margin: 0; + padding: 0.55rem 0.65rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.7rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.45); + cursor: pointer; + user-select: none; + transition: + background 0.15s, + border-color 0.15s, + transform 0.15s; +} + +.column-card:hover, +.column-card.active { + background: var(--bs-primary-bg-subtle); +} + +.column-card:hover { + border-color: var(--bs-primary); +} + +.column-card.active { + border-color: rgba(var(--bs-primary-rgb), 0.55); +} + +.column-card .form-check-input { + margin: 0; +} + +.column-card .form-check-label { + display: flex; + align-items: center; + gap: 0.45rem; + font-weight: 600; +} + +.column-card i { + opacity: 0.72; +} +.path-row::before { + content: "\f07b"; + font-family: "Font Awesome 6 Free"; + font-weight: 900; + color: var(--bs-warning); +} +body.mobile-mode .mobile-card { + display: block; +} +.mobile-card .mobile-actions button { + min-width: 34px; +} +#toolSmart .form-label { + font-size: 0.75rem; + color: var(--bs-secondary-color); + margin-bottom: 0.2rem; +} +#toolSmart .btn { + padding: 0.25rem 0.55rem; + border-radius: 0.5rem; + white-space: nowrap; +} +#toolSmart .row .d-flex { + align-items: end; + justify-content: flex-start; +} +@media (max-width: 992px) { + .profile-form-grid { + grid-template-columns: 1fr; + } + .profile-form-grid .btn { + width: 100%; + } +} + +.history-grid { + display: grid; + grid-template-columns: 1fr; + gap: 1rem; +} + +.history-card { + min-width: 0; + padding: 0.85rem; + overflow: hidden; + background: linear-gradient(180deg, rgba(var(--bs-secondary-bg-rgb), 0.58), rgba(var(--bs-secondary-bg-rgb), 0.28)); + border: 1px solid var(--bs-border-color); + border-radius: 1rem; + box-shadow: 0 0.5rem 1.75rem rgba(15, 23, 42, 0.08); +} + +.history-title { + margin-bottom: 0.55rem; + color: var(--bs-body-color); + font-size: 0.9rem; + font-weight: 700; + letter-spacing: 0.01em; +} + +.traffic-chart { + display: block; + width: 100%; + height: 420px; + max-width: 100%; + background: var(--bs-secondary-bg); + border: 0; + border-radius: 0.75rem; +} + + +.add-torrent-form { + display: grid; + gap: 0.85rem; +} + +.add-start-switch { + display: flex; + align-items: center; + min-height: 31px; + margin-bottom: 0; +} + +.traffic-chart-tooltip { + position: fixed; + z-index: 9000; + min-width: 150px; + padding: 0.45rem 0.6rem; + color: var(--bs-body-color); + background: var(--bs-body-bg); + border: 1px solid var(--bs-border-color); + border-radius: 0.5rem; + box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.28); + font-size: 0.78rem; + pointer-events: none; +} + +.traffic-tooltip-title { + margin-bottom: 0.25rem; + color: var(--bs-secondary-color); + font-weight: 700; +} + +.empty-mini { + padding: 0.7rem 0.8rem; + border: 1px dashed var(--bs-border-color); + border-radius: 0.7rem; + color: var(--bs-secondary-color); + background: rgba(var(--bs-secondary-bg-rgb), 0.35); +} +.label-manager-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.65rem; + padding: 0.4rem 0.5rem; + margin-bottom: 0.4rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.35); +} +.tool-tab i { + margin-right: 0.25rem; + opacity: 0.82; +} +@media (max-width: 640px) { + .history-card { + padding: 0.5rem; + } + .traffic-chart { + height: 320px; + } + .statusbar { + font-size: 0.75rem; + gap: 0.6rem; + } + .mobile-list { + padding: 0.45rem; + } + .mobile-card { + margin-bottom: 0.45rem; + } +} + +.torrent-progress { + height: 16px; + min-width: 92px; + position: relative; + margin: 0; + overflow: hidden; + background: rgba(var(--bs-secondary-bg-rgb), 0.8) !important; +} +.torrent-progress .progress-bar { + min-width: 0 !important; + position: relative; + transition: + width 0.25s ease, + background-color 0.25s ease; +} +.torrent-progress > span { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 700; + line-height: 1; + color: var(--bs-body-color); + text-shadow: none; + white-space: nowrap; + pointer-events: none; +} +.torrent-progress .progress-bar + span { + color: var(--bs-body-color); +} +@media (max-width: 700px) { + body:not(.desktop-mode) .table-wrap { + display: none !important; + } + body:not(.desktop-mode) #mobileList { + display: block !important; + min-height: 260px; + height: 100%; + overflow: auto; + } + body:not(.desktop-mode) .content { + display: grid !important; + grid-template-rows: auto auto minmax(0, 1fr) !important; + min-height: 0; + overflow: hidden; + } + body:not(.desktop-mode) .detail-resize-handle, + body:not(.desktop-mode) .details { + display: none !important; + } +} +.pager-row { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.5rem; +} +.peers-refresh { + display: flex; + align-items: center; + gap: 0.5rem; + justify-content: flex-end; + padding: 0.35rem 0.75rem; + border-bottom: 1px solid var(--bs-border-color); + background: rgba(var(--bs-secondary-bg-rgb), 0.35); +} +.peers-refresh select { + width: auto; + min-width: 96px; +} + +@media (max-width: 900px) { + body:not(.modal-open) .table-wrap { + display: none !important; + } + body:not(.modal-open) #mobileList { + display: block !important; + height: 100% !important; + min-height: 260px; + overflow: auto; + } + body:not(.modal-open) .content { + display: grid !important; + grid-template-rows: auto auto minmax(0, 1fr) !important; + min-height: 0; + overflow: hidden; + } + body:not(.modal-open) .detail-resize-handle, + body:not(.modal-open) .details { + display: none !important; + } +} +.torrent-paused td { + opacity: 0.82; +} +.torrent-paused .name { + font-style: italic; +} + +@media (max-width: 900px) { + .main-grid { + display: grid !important; + grid-template-columns: minmax(0, 1fr) !important; + min-height: 0 !important; + height: 100% !important; + overflow: hidden !important; + } + .sidebar { + display: none !important; + } + .content { + display: grid !important; + grid-template-rows: auto auto minmax(0, 1fr) !important; + min-height: 0 !important; + height: 100% !important; + overflow: hidden !important; + } + .table-wrap { + display: none !important; + } + #bulkBar { + grid-row: 1; + } + #mobileList { + display: block !important; + grid-row: 3; + height: 100% !important; + min-height: 0 !important; + overflow: auto !important; + position: relative !important; + z-index: 10 !important; + background: var(--bs-body-bg) !important; + padding: 0.55rem !important; + } + .details { + display: none !important; + } + .toolbar-right { + width: 100% !important; + min-width: 0 !important; + flex-wrap: nowrap !important; + gap: 0.35rem !important; + } + .search { + min-width: 0 !important; + width: auto !important; + flex: 1 1 0 !important; + max-width: none !important; + } + .mobile-speed-stats { + display: inline-flex; + } +} +@media (max-width: 640px) { + .mobile-speed-stats { + align-items: flex-start; + flex-direction: column; + gap: 0.08rem; + font-size: 0.66rem; + line-height: 1.05; + } +} + +.files-toolbar { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: space-between; + margin-bottom: 0.5rem; +} +.files-action-strip { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} +.files-action-section { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} +.files-action-label { + color: var(--muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.04em; + margin-right: 0.1rem; + text-transform: uppercase; +} +.files-action-separator { + align-self: stretch; + background: var(--border); + display: inline-block; + min-height: 1.8rem; + width: 1px; +} +.file-priority-table > :not(caption) > * > * { + line-height: 1.15; + padding: 0.22rem 0.4rem; + vertical-align: middle; +} +.file-priority-table tbody tr { + height: 30px; +} +.file-priority-table .path { + max-width: 520px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.file-priority-table .file-priority { + min-width: 110px; +} +.file-priority-table .file-progress { + margin-bottom: 0; + min-width: 110px; + width: 120px; +} +.file-priority-table .form-select, +.file-priority-table .btn-xs { + min-height: 24px; + padding-bottom: 0.1rem; + padding-top: 0.1rem; +} +.file-priority-table .file-check, +.file-priority-table #fileSelectAll { + display: block; + margin: 0 auto; +} +@media (max-width: 900px) { + .files-toolbar { + align-items: stretch; + } + .files-action-strip, + .files-action-section { + align-items: stretch; + } + .files-action-separator { + min-height: auto; + } + .file-priority-table { + font-size: 0.82rem; + } + .file-priority-table .path { + max-width: 180px; + } +} + +.bulk-bar { + height: 38px; + display: flex; + align-items: center; + gap: 0.35rem; + flex-wrap: nowrap; + overflow-x: auto; + overflow-y: hidden; + padding: 0.35rem 0.55rem; + border-bottom: 1px solid var(--bs-border-color); + background: rgba(var(--bs-secondary-bg-rgb), 0.95); + z-index: 4; +} +.bulk-bar.d-none { + display: none !important; +} +.bulk-bar span { + color: var(--bs-secondary-color); + margin-right: 0.3rem; + white-space: nowrap; +} +.bulk-bar .btn { + white-space: nowrap; + flex: 0 0 auto; +} +.move-options { + border: 1px solid var(--bs-border-color); + border-radius: 0.6rem; + padding: 0.75rem; + background: var(--bs-tertiary-bg); +} +#bulkBar { + grid-row: 1; + grid-column: 1; + align-self: start; +} +#tableWrap, +#mobileList { + grid-row: 1; + grid-column: 1; + min-height: 0; +} +.bulk-bar:not(.d-none) + .table-wrap { + padding-top: 38px; +} +@media (max-width: 900px) { + .bulk-bar { + gap: 0.3rem; + } +} + +.label-mini { + font-size: 0.72rem; + padding: 0.12rem 0.38rem; + margin-right: 0.15rem; +} +.label-chip.active { + border-color: var(--bs-primary); + background: var(--bs-primary-bg-subtle); + color: var(--bs-primary-text-emphasis); +} +.label-selected { + border-color: var(--bs-primary); + background: var(--bs-primary-bg-subtle); + color: var(--bs-primary-text-emphasis); +} + +.automation-shell { + display: grid; + gap: 0.75rem; +} +.automation-main-card { + padding: 0.75rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + background: var(--bs-body-bg); +} +.automation-card-title { + margin-bottom: 0.5rem; + font-weight: 700; +} +.automation-rule-grid, +.automation-builder-grid { + display: grid; + grid-template-columns: repeat(4, minmax(160px, 1fr)); + gap: 0.5rem; + align-items: center; +} +.automation-enabled, +.automation-negate { + margin: 0; + padding: 0.45rem 0.6rem 0.45rem 2.5rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.5rem; +} +.automation-path-input { + grid-column: span 2; +} +.automation-chip-list { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} +.automation-chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + max-width: 100%; + padding: 0.25rem 0.5rem; + border: 1px solid var(--bs-border-color); + border-radius: 999px; + background: var(--bs-tertiary-bg); + font-size: 0.82rem; +} +.automation-actions, +.automation-row-actions { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + align-items: center; +} +.automation-row { + display: flex; + justify-content: space-between; + gap: 0.75rem; + align-items: center; + padding: 0.55rem 0.65rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.6rem; + margin-bottom: 0.45rem; + background: var(--bs-body-bg); +} +.automation-row-main { + min-width: 0; +} +.automation-rule-summary { + overflow-wrap: anywhere; +} +.automation-action-pill { + display: inline-flex; + max-width: 100%; + margin: 0.1rem; + padding: 0.15rem 0.4rem; + border-radius: 999px; + background: var(--bs-secondary-bg); + font-size: 0.78rem; + overflow-wrap: anywhere; + white-space: normal; + word-break: break-word; +} +/* Note: Smart Queue stats are reusable because they are shown in App status. */ +.automation-smart-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 0.5rem; + margin: 0.5rem 0 0.75rem; +} +.automation-smart-stat { + min-width: 0; + padding: 0.5rem 0.6rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.6rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.28); +} +.automation-smart-stat span, +.automation-smart-stat small { + display: block; + color: var(--bs-secondary-color); + font-size: 0.72rem; + line-height: 1.2; +} +.automation-smart-stat b { + display: block; + overflow: hidden; + font-size: 1rem; + line-height: 1.3; + text-overflow: ellipsis; + white-space: nowrap; +} +.automation-history-toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: 0.5rem; +} +/* Note: Automation history has fixed compact metadata columns and a flexible Actions column, so long JSON cannot overlap Time/Rule. */ +.automation-history-table { + width: 100%; + min-width: 760px; + table-layout: fixed; + white-space: normal; +} +.automation-history-table th, +.automation-history-table td { + min-width: 0; + vertical-align: top; +} +.automation-history-table th:nth-child(1), +.automation-history-table td:nth-child(1) { + width: 9rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.automation-history-table th:nth-child(2), +.automation-history-table td:nth-child(2) { + width: 11rem; + overflow: hidden; + overflow-wrap: anywhere; + word-break: break-word; +} +.automation-history-table th:nth-child(3), +.automation-history-table td:nth-child(3) { + width: 12rem; + overflow: hidden; + overflow-wrap: anywhere; + word-break: break-word; +} +.automation-history-table th:nth-child(4), +.automation-history-table td:nth-child(4) { + width: auto; + overflow: hidden; + overflow-wrap: anywhere; + word-break: break-word; +} +.automation-history-details { + display: block; + min-width: 0; + max-width: 100%; +} +.automation-history-details summary { + display: block; + max-width: 100%; + cursor: pointer; + list-style-position: inside; + overflow-wrap: anywhere; + white-space: normal; + word-break: break-word; +} +.automation-history-details pre, +.automation-history-raw { + max-width: 100%; + max-height: 220px; + margin: 0.35rem 0 0; + padding: 0.5rem; + overflow: auto; + border: 1px solid var(--bs-border-color); + border-radius: 0.5rem; + background: var(--bs-tertiary-bg); + overflow-wrap: anywhere; + white-space: pre-wrap; + word-break: break-word; +} +@media (max-width: 900px) { + .automation-rule-grid, + .automation-builder-grid { + grid-template-columns: 1fr; + } + .automation-path-input, + .automation-history-details { + grid-column: auto; + max-width: 100%; + } + .automation-history-toolbar { + justify-content: flex-start; + } +} +.disk-status { + display: inline-flex; + align-items: center; + gap: 0.35rem; + min-width: 0; + flex: 0 1 70%; +} + +.disk-status canvas { + width: 100%; + max-width: none; + min-width: 80px; +} + +.disk-status.disk-warn b { + color: var(--bs-warning) !important; +} + +.system-chart { + width: 96px; + height: 24px; + border-radius: 0.35rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.45); +} +.torrent-progress.is-complete > span { + color: #fff; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.35); +} +.peer-progress { + min-width: 86px; + width: 96px; +} +.loading-center { + justify-content: center; + min-height: 80px; +} +.loading-cell { + padding: 0 !important; +} +.mobile-list .loading-center { + min-height: 160px; +} + +.torrent-warning td { + background: rgba(245, 158, 11, 0.075) !important; +} +.torrent-warning:hover td { + background: rgba(245, 158, 11, 0.11) !important; +} +.torrent-warning.selected td { + background: color-mix( + in srgb, + var(--bs-primary-bg-subtle) 82%, + rgba(245, 158, 11, 0.16) + ) !important; +} +.mobile-card.torrent-warning { + background: rgba(245, 158, 11, 0.075); +} +.mobile-card.torrent-warning.selected { + background: color-mix( + in srgb, + var(--bs-primary-bg-subtle) 82%, + rgba(245, 158, 11, 0.16) + ); +} +.torrent-warning-icon { + color: var(--bs-warning); + margin-right: 0.2rem; +} +.mobile-filter-bar { + display: none; + grid-row: 2; + grid-column: 1; + align-self: start; + position: relative; + z-index: 12; + padding: 0.45rem 0.55rem; + border-bottom: 1px solid var(--bs-border-color); + background: rgba(var(--bs-body-bg-rgb), 0.96); +} +.mobile-filter-actions, +.mobile-filter-select-row { + align-items: center; + display: flex; + gap: 0.35rem; +} +.mobile-filter-actions { + flex-wrap: wrap; + margin-bottom: 0.4rem; +} +.mobile-filter-actions span { + color: var(--bs-secondary-color); + font-size: 0.78rem; + white-space: nowrap; +} +.mobile-filter-select-row label { + color: var(--bs-secondary-color); + font-size: 0.78rem; + white-space: nowrap; +} +.mobile-filter-select-row select { + min-width: 0; + flex: 1 1 auto; +} +body.mobile-mode .mobile-filter-bar { + display: block !important; +} +@media (max-width: 900px) { + #mobileFilterBar { + display: block !important; + } + .topbar .badge { + width: 0.72rem; + height: 0.72rem; + min-width: 0.72rem; + padding: 0 !important; + border-radius: 999px; + overflow: hidden; + color: transparent !important; + text-indent: -999px; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.22); + } + .topbar .badge .spinner-border { + display: none; + } +} + +.rt-config-grid { + display: grid; + gap: 0.6rem; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); +} + +.rt-config-group { + grid-column: 1 / -1; + padding: 0.45rem 0.2rem 0.1rem; + border-bottom: 1px solid var(--bs-border-color); + color: var(--bs-primary-text-emphasis); + font-weight: 800; +} + +.rt-config-note { + margin-bottom: 0.75rem; +} + +.rt-config-toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.rt-config-row { + display: grid; + grid-template-columns: 1fr minmax(120px, 190px); + align-items: center; + gap: 0.6rem; + padding: 0.6rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.7rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.35); +} + +.rt-config-switch { + justify-self: end; + margin: 0; +} + +.rt-config-switch .form-check-input { + margin-top: 0; +} + +.rt-config-switch .form-check-label { + min-width: 2rem; + color: var(--bs-secondary-color); + font-size: 0.78rem; + font-weight: 700; +} + +.rt-config-row b { + font-size: 0.88rem; +} + +.rt-config-row small { + display: block; + overflow-wrap: anywhere; + color: var(--bs-secondary-color); + font-size: 0.72rem; +} + +.rt-config-row.disabled { + opacity: 0.58; +} + +.rt-config-row.changed, +.rt-config-row.changed-live { + border-color: var(--bs-danger); + box-shadow: 0 0 0 0.12rem rgba(220, 53, 69, 0.2); +} + +.rt-config-value-note { + margin-top: 0.15rem; +} + +.rt-config-output { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; +} + +.tracker-toolbar, +.tracker-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.45rem; +} + +.tracker-toolbar { + justify-content: space-between; + margin-bottom: 0.55rem; +} + +.tracker-add-input { + min-width: 240px; + max-width: 520px; +} + +.tracker-message { + max-width: 360px; + white-space: normal; + word-break: break-word; +} + +.tracker-url-text { + word-break: break-all; +} + +.tool-note { + color: var(--bs-secondary-color); + font-size: 0.82rem; +} + +.cleanup-grid, +.diag-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 0.6rem; +} + +.cleanup-card, +.diag-card { + padding: 0.65rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.7rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.35); +} + +.cleanup-card b, +.diag-card b { + display: block; + margin-bottom: 0.2rem; + color: var(--bs-secondary-color); + font-size: 0.78rem; +} + +.cleanup-card span, +.diag-card span { + font-weight: 700; +} + +.cleanup-card small { + display: block; + margin-top: 0.2rem; + overflow-wrap: anywhere; + color: var(--bs-secondary-color); +} + +.cleanup-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.diag-error { + border-color: rgba(var(--bs-danger-rgb), 0.45); + background: rgba(var(--bs-danger-rgb), 0.08); +} + +.port-status { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.12rem 0.4rem; + border-radius: 0.45rem; +} + +.port-ok { + background: rgba(34, 197, 94, 0.14); + color: var(--bs-success); +} + +.port-bad { + background: rgba(239, 68, 68, 0.14); + color: var(--bs-danger); +} + +.port-secondary { + background: rgba(148, 163, 184, 0.12); + color: var(--bs-secondary-color); +} + +.limit-slider-panel { + padding: 0.65rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.7rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.32); +} + +.limit-slider-row + .limit-slider-row { + margin-top: 0.65rem; +} + +.limit-slider-row .form-label { + display: flex; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.25rem; +} + +@media (max-width: 640px) { + #mobileToggle { + display: none !important; + } + + .tracker-add-input { + min-width: 160px; + max-width: 230px; + } + + .tracker-message { + max-width: 220px; + } +} +.text-compact { + display: inline-block; + max-width: 32rem; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: bottom; + white-space: nowrap; +} + +.torrent-operating td { + background: rgba(13, 202, 240, 0.085) !important; +} + +.torrent-operating:hover td { + background: rgba(13, 202, 240, 0.13) !important; +} + +.torrent-operating.selected td { + background: color-mix( + in srgb, + var(--bs-primary-bg-subtle) 78%, + rgba(13, 202, 240, 0.18) + ) !important; +} + +.mobile-card.torrent-operating { + background: rgba(13, 202, 240, 0.085); + border-color: rgba(13, 202, 240, 0.45); +} + +.mobile-card.torrent-operating.selected { + background: color-mix( + in srgb, + var(--bs-primary-bg-subtle) 78%, + rgba(13, 202, 240, 0.18) + ); +} + +.operation-status-badge { + color: #062c33; +} + +.mobile-progress { + margin-top: 0.45rem; +} + +.mobile-progress .torrent-progress { + width: 100%; + min-width: 0; +} + + +.empty-state { + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 0.45rem; + max-width: 34rem; + white-space: normal; +} +.empty-state b { + color: var(--bs-body-color); + font-size: 0.95rem; +} +.empty-state span { + color: var(--bs-secondary-color); +} + +.footer-pref-hidden { + display: none !important; +} + +.footer-preferences { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.5rem; +} + +.footer-pref-card { + display: flex; + align-items: center; + gap: 0.55rem; + min-width: 0; + margin: 0; + padding: 0.6rem 0.7rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.45); + cursor: pointer; + user-select: none; + transition: + background 0.15s, + border-color 0.15s; +} + +.footer-pref-card:hover, +.footer-pref-card.active { + background: var(--bs-primary-bg-subtle); +} + +.footer-pref-card:hover { + border-color: var(--bs-primary); +} + +.footer-pref-card.active { + border-color: rgba(var(--bs-primary-rgb), 0.55); +} + +.footer-pref-card .form-check-input { + flex: 0 0 auto; + margin: 0; +} + +.footer-pref-card .form-check-label { + min-width: 0; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#statusClock, +#statusSockets { + white-space: nowrap; +} + + +.torrent-stats-toolbar { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.torrent-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.75rem; +} + +.torrent-stats-card { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; + padding: 0.75rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.85rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.45); +} + +.torrent-stats-card b { + color: var(--bs-secondary-color); + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; +} + +.torrent-stats-card span { + font-size: 1.05rem; + font-weight: 700; +} + +.torrent-stats-card small { + color: var(--bs-secondary-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.peer-ip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + white-space: nowrap; +} + +.peer-ip-link { + color: var(--bs-secondary-color); + font-size: 0.75rem; + text-decoration: none; +} + +.peer-ip-link:hover { + color: var(--bs-primary); +} + +.auth-page { + display: grid; + min-height: 100vh; + place-items: center; + padding: 1rem; + background: radial-gradient( + circle at 50% 35%, + rgba(var(--bs-secondary-bg-rgb), 0.98), + var(--bs-body-bg) 68% + ); + color: var(--bs-body-color); +} + +.auth-card { + width: min(92vw, 430px); +} + +.auth-lock { + display: inline-grid; + width: 3rem; + height: 3rem; + margin: 1.35rem 0 1rem; + place-items: center; + border: 1px solid var(--bs-border-color); + border-radius: 999px; + background: rgba(var(--bs-tertiary-bg-rgb), 0.72); + color: var(--bs-primary); + font-size: 1.15rem; +} + +.auth-alert { + margin: 1rem 0 0; + padding: 0.5rem 0.75rem; + text-align: left; +} + +.auth-form { + margin-top: 1.2rem; + text-align: left; +} + +.auth-form .form-label { + margin-bottom: 0.35rem; + font-size: 0.82rem; + font-weight: 700; + color: var(--bs-secondary-color); +} + +.auth-form .form-control { + margin-bottom: 0.85rem; +} + +.auth-form .btn { + margin-top: 0.35rem; +} + +.user-form-grid { + display: grid; + grid-template-columns: minmax(150px, 1fr) minmax(160px, 1fr) 120px 150px 110px auto auto; + gap: 0.55rem; + align-items: center; +} + +.smart-panel { + container-type: inline-size; +} + +.smart-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--bs-border-color); +} + +.smart-header-actions { + display: flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; + justify-content: flex-end; + flex: 0 0 auto; +} + +.smart-settings-list { + display: grid; + gap: 0.65rem; + margin-top: 0.85rem; +} + +.smart-setting-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + min-height: 52px; + padding: 0.6rem 0.7rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.65rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.28); +} + +/* Keep Bootstrap switches aligned inside compact settings rows. */ +.inline-switch, +.smart-toggle-row .form-check { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 0.45rem; + flex: 0 0 auto; + min-height: 0; + margin: 0; + padding-left: 0; +} + +.inline-switch .form-check-input, +.smart-toggle-row .form-check-input { + flex: 0 0 auto; + margin-top: 0; + margin-left: 0; +} + +.inline-switch .form-check-label { + line-height: 1.2; + white-space: nowrap; +} + +.smart-setting-row > div:first-child { + min-width: 0; +} + +.smart-setting-row b, +.smart-setting-row small { + display: block; +} + +.smart-setting-row .form-check-label, +.smart-input-field span { + font-weight: 700; +} + +.smart-input-grid { + display: grid; + grid-template-columns: repeat(4, minmax(120px, 1fr)); + gap: 0.65rem; +} + +.smart-input-field { + display: grid; + gap: 0.35rem; + min-width: 0; + padding: 0.6rem 0.7rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.65rem; + background: rgba(var(--bs-body-bg-rgb), 0.48); +} + +.smart-input-field small { + color: var(--bs-secondary-color); + line-height: 1.2; +} + +.smart-input-field .form-control { + width: 100%; +} + +.smart-actions { + display: flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; + padding: 0.7rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.65rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.24); +} + +@media (max-width: 992px) { + .user-form-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .smart-input-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 576px) { + .user-form-grid, + .smart-input-grid { + grid-template-columns: 1fr; + } + + .smart-header, + .smart-setting-row { + align-items: stretch; + flex-direction: column; + } + + .smart-header-actions { + justify-content: stretch; + } + + .smart-header-actions .btn { + flex: 1 1 auto; + } + + .smart-toggle-row .form-check { + justify-content: flex-start; + } +} + + +/* Note: About and error-page styles are grouped without duplicating existing classes. */ +.about-modal-content { + overflow: hidden; +} + +.about-nav-btn { + opacity: 0.82; +} + +.about-nav-btn:hover, +.about-nav-btn:focus-visible { + opacity: 1; +} + +.about-hero { + display: flex; + align-items: center; + gap: 0.85rem; + margin-bottom: 1rem; + padding: 0.9rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.85rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.38); +} + +.about-logo { + display: inline-grid; + width: 2.8rem; + height: 2.8rem; + flex: 0 0 auto; + place-items: center; + border-radius: 0.8rem; + background: var(--bs-primary-bg-subtle); + color: var(--bs-primary-text-emphasis); + font-size: 1.25rem; +} + +.about-hero h6, +.about-hero p { + margin: 0; +} + +.about-hero h6 { + font-weight: 800; +} + +.about-hero p { + color: var(--bs-secondary-color); +} + + +.about-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 0.6rem; +} + +.about-summary-grid div { + padding: 0.7rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.28); +} + +.about-summary-grid b, +.about-summary-grid span { + display: block; +} + +.about-summary-grid b { + margin-bottom: 0.2rem; +} + +.about-summary-grid span { + color: var(--bs-secondary-color); + font-size: 0.82rem; +} + +.about-list { + display: grid; + gap: 0.55rem; + margin: 0; +} + +.about-list div { + display: grid; + grid-template-columns: 7rem minmax(0, 1fr); + gap: 0.75rem; + padding: 0.55rem 0; + border-bottom: 1px solid var(--bs-border-color); +} + +.about-list div:last-child { + border-bottom: 0; +} + +.about-list dt { + color: var(--bs-secondary-color); + font-weight: 700; +} + +.about-list dd { + margin: 0; +} + +.error-page { + display: grid; + min-height: 100vh; + place-items: center; + padding: 1rem; + background: radial-gradient( + circle at 50% 35%, + rgba(var(--bs-secondary-bg-rgb), 0.98), + var(--bs-body-bg) 68% + ); + color: var(--bs-body-color); +} + +.error-card { + width: min(92vw, 460px); + padding: 2rem; + border: 1px solid var(--bs-border-color); + border-radius: 18px; + background: rgba(var(--bs-secondary-bg-rgb), 0.9); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.48); + text-align: center; +} + +.error-brand { + font-size: 1.2rem; + font-weight: 800; +} + +.error-icon { + display: inline-grid; + width: 4rem; + height: 4rem; + margin: 1.4rem 0 1rem; + place-items: center; + border: 1px solid var(--bs-border-color); + border-radius: 1rem; + background: var(--bs-primary-bg-subtle); + color: var(--bs-primary-text-emphasis); + font-size: 1.55rem; +} + +.error-code { + margin: 0; + color: var(--bs-secondary-color); + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.error-card h1 { + margin: 0.25rem 0 0.55rem; + font-size: 1.45rem; + font-weight: 800; +} + +.error-card p:not(.error-code) { + margin: 0; + color: var(--bs-secondary-color); +} + +.error-actions { + display: flex; + justify-content: center; + gap: 0.55rem; + flex-wrap: wrap; + margin-top: 1.35rem; +} + +@media (max-width: 576px) { + .about-list div { + grid-template-columns: 1fr; + gap: 0.15rem; + } + + .error-actions .btn { + width: 100%; + } +} + +.date-readable { + display: inline-block; + min-width: 9.5rem; + white-space: nowrap; +} + + +.cooldown-live { + display: inline-flex; + margin-left: 0.35rem; + padding: 0.05rem 0.35rem; + border: 1px solid var(--bs-border-color); + border-radius: 999px; + color: var(--bs-secondary-color); + font-size: 0.72rem; + font-weight: 700; +} + +.disk-monitor-grid { + display: grid; + grid-template-columns: minmax(220px, 1.3fr) minmax(170px, 0.8fr) minmax(170px, 0.8fr); + gap: 0.6rem; + align-items: start; +} + +.disk-monitor-grid .chips { + grid-column: 1 / -1; +} + +.disk-path-chip { + gap: 0.25rem; + max-width: 100%; + overflow-wrap: anywhere; +} + +.disk-path-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + color: var(--bs-danger); + line-height: 1; +} + +.disk-path-remove:hover, +.disk-path-remove:focus-visible { + border: 0; + background: transparent; + box-shadow: none; + color: var(--bs-danger-text-emphasis); + outline: 0; +} + +.jobs-table { + min-width: 1080px; + white-space: normal; +} + +.jobs-table th:nth-child(8), +.jobs-table td:nth-child(8), +.jobs-table th:nth-child(9), +.jobs-table td:nth-child(9) { + min-width: 10.5rem; +} + +.jobs-table td:nth-child(6), +.jobs-table td:nth-child(10) { + max-width: 18rem; + overflow-wrap: anywhere; + white-space: normal; +} + +@media (max-width: 768px) { + .disk-monitor-grid { + grid-template-columns: 1fr; + } + + .disk-monitor-grid .chips { + grid-column: auto; + } +} + +/* Note: Smart Queue cooldown, refill and Disk monitor controls are grouped here to keep the new UX styles isolated. */ +.smart-cooldown-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + background: rgba(var(--bs-primary-rgb), 0.08); +} + +.smart-cooldown-label, +.smart-cooldown-field span, +.disk-monitor-card-title { + display: block; + font-weight: 700; +} + +.smart-cooldown-live { + margin: 0.25rem 0 0; + color: var(--bs-primary-text-emphasis); + background: rgba(var(--bs-primary-rgb), 0.12); + border-color: rgba(var(--bs-primary-rgb), 0.28); + font-size: 0.9rem; +} + +.smart-cooldown-card small, +.smart-cooldown-field small, +.disk-monitor-switch small, +.disk-path-row small { + display: block; + color: var(--bs-secondary-color); + line-height: 1.25; +} + +.smart-cooldown-field { + display: grid; + gap: 0.3rem; + width: min(180px, 100%); +} + + +.smart-refill-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.28); +} + +.smart-refill-title, +.smart-refill-field span { + display: block; + font-weight: 700; +} + +.smart-refill-card small { + display: block; + color: var(--bs-secondary-color); + line-height: 1.25; +} + +.smart-refill-controls { + display: grid; + grid-template-columns: minmax(130px, 1fr) minmax(90px, 0.7fr); + gap: 0.55rem; + width: min(330px, 100%); +} + +.smart-refill-field { + display: grid; + gap: 0.3rem; +} +.disk-monitor-shell { + display: grid; + grid-template-columns: minmax(240px, 0.9fr) minmax(280px, 1.1fr); + gap: 0.75rem; +} + +.disk-monitor-mode-card, +.disk-monitor-path-card { + display: grid; + gap: 0.55rem; + padding: 0.75rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + background: rgba(var(--bs-body-bg-rgb), 0.45); +} + +.disk-monitor-switch { + display: grid; + grid-template-columns: auto 1fr; + column-gap: 0.6rem; + row-gap: 0.1rem; + align-items: start; + min-height: auto; + margin: 0; + padding: 0.55rem 0.6rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.6rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.25); +} + +.disk-monitor-switch .form-check-input { + grid-row: span 2; + margin-left: 0; +} + +.disk-monitor-switch .form-check-label { + font-weight: 700; +} + +.disk-monitor-path-list { + display: grid; + gap: 0.45rem; +} + +.disk-path-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.55rem 0.65rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.6rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.22); +} + +.disk-path-row b { + display: block; + overflow-wrap: anywhere; +} + +.disk-path-actions { + display: flex; + gap: 0.35rem; + flex: 0 0 auto; +} + +@media (max-width: 768px) { + .smart-cooldown-card, + .smart-refill-card, + .disk-path-row { + align-items: stretch; + flex-direction: column; + } + + .disk-monitor-shell { + grid-template-columns: 1fr; + } + + .disk-path-actions { + justify-content: flex-start; + } +} + +/* Note: RSS and ratio management forms use shared grid rules to avoid one-off duplicated layout classes. */ +.ratio-rule-grid, +.rss-form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 0.5rem; + align-items: center; +} + +.ratio-rule-grid .form-check, +.rss-form-grid .form-check { + margin-bottom: 0; +} + +@media (max-width: 768px) { + .ratio-rule-grid, + .rss-form-grid { + grid-template-columns: 1fr; + } +} + + +.dragging-torrent-files .table-wrap, +.dragging-torrent-files .mobile-list { + outline: 2px dashed var(--bs-primary); + outline-offset: -0.4rem; +} + +.dragging-torrent-files .table-wrap::after { + align-items: center; + background: color-mix(in srgb, var(--bs-body-bg) 82%, transparent); + border: 1px dashed var(--bs-primary); + border-radius: 0.75rem; + color: var(--bs-primary); + content: 'Drop .torrent files to add them'; + display: flex; + font-weight: 700; + inset: 0.75rem; + justify-content: center; + pointer-events: none; + position: absolute; + z-index: 5; +} + +.torrent-preview { + display: grid; + gap: .75rem; +} + +.torrent-preview-title { + color: var(--bs-secondary-color); + font-size: .82rem; + font-weight: 700; + text-transform: uppercase; +} + +.torrent-preview-card { + border: 1px solid var(--bs-border-color); + border-radius: .75rem; + padding: .75rem; +} + +.torrent-preview-card.is-duplicate { + border-color: var(--bs-danger); +} + +.torrent-preview-head, +.preview-actions, +.file-tree-actions { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: .5rem; +} + +.preview-file-table { + margin-bottom: 0; +} + +.preview-file-table td:first-child { + width: 2.25rem; +} + +.file-tree-panel { + border: 1px solid var(--bs-border-color); + border-radius: .75rem; + margin: .75rem 0; + max-height: 18rem; + overflow: auto; + padding: .75rem; +} + +.file-tree-root, +.file-tree-root ul { + list-style: none; + margin: 0; + padding-left: 1rem; +} + +.file-tree-root > li { + padding-left: 0; +} + +.file-tree-file, +.file-tree-root summary { + align-items: center; + display: flex; + gap: .4rem; + min-height: 1.75rem; +} + +.file-tree-root small { + color: var(--bs-secondary-color); +} + +/* Planner / adaptive poller */ +.tool-tab[data-tool="planner"], +.tool-tab[data-tool="poller"] { + white-space: nowrap; +} + +.planner-panel .smart-header, +.poller-panel .smart-header { + margin-bottom: 0.85rem; +} + +.planner-layout, +.planner-toggle-stack { + display: grid; + gap: 0.85rem; +} + +.planner-card { + background: rgba(var(--bs-secondary-bg-rgb), 0.36); + border: 1px solid var(--bs-border-color); + border-radius: 0.85rem; + min-width: 0; + padding: 0.85rem; +} + +.planner-card-title { + align-items: center; + display: flex; + font-weight: 700; + gap: 0.45rem; + margin-bottom: 0.7rem; +} + +.planner-card-time, +.planner-card-protection { + display: grid; + gap: 0.75rem; +} + +.planner-card-time .planner-card-title, +.planner-card-protection .planner-card-title { + margin-bottom: 0; +} + +.planner-card-time .planner-time-grid, +.planner-card-protection .planner-protection-grid { + margin-top: 0; +} + +.planner-toggle-stack-compact { + grid-template-columns: repeat(2, minmax(220px, 1fr)); +} + +.planner-protection-toggles { + grid-template-columns: repeat(2, minmax(240px, 1fr)); +} + +.planner-card .smart-setting-row { + align-items: flex-start; + gap: 0.75rem; + min-height: auto; +} + +.planner-card .smart-setting-row > div:first-child { + flex: 1 1 auto; +} + +.planner-card .smart-setting-row .inline-switch, +.planner-card .smart-setting-row .form-check { + align-self: flex-start; + margin-top: 0.1rem; +} + +.planner-time-grid, +.planner-profile-grid, +.poller-input-grid { + grid-template-columns: repeat(4, minmax(130px, 1fr)); +} + +.planner-protection-grid { + grid-template-columns: repeat(5, minmax(130px, 1fr)); +} + +.planner-speed-grid { + grid-template-columns: repeat(2, minmax(260px, 1fr)); +} + +.planner-speed-card { + gap: 0.45rem; +} + +.planner-limit-summary { + color: var(--bs-secondary-color); + font-size: 0.82rem; +} + +.planner-presets, +.planner-hour-tools, +.tool-action-row { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.planner-speed-sliders { + align-items: center; + display: grid; + gap: 0.45rem 0.65rem; + grid-template-columns: minmax(160px, 1fr) 100px; +} + +.planner-speed-sliders label { + color: var(--bs-secondary-color); + margin: 0; +} + +.planner-byte-input { + font-family: var(--bs-font-monospace); +} + +.tool-action-row { + align-items: center; + margin-top: 0.85rem; +} + +.planner-hour-tools { + margin: 0.65rem 0; +} + +.planner-hour-grid { + display: grid; + gap: 0.35rem; + grid-template-columns: repeat(2, minmax(280px, 1fr)); + max-height: 420px; + overflow: auto; + padding-right: 0.25rem; +} + +.planner-hour-row { + align-items: center; + border: 1px solid var(--bs-border-color); + border-radius: 0.6rem; + display: grid; + gap: 0.4rem; + grid-template-columns: 6.2rem 1fr 1fr minmax(8rem, auto); + padding: 0.35rem; +} + +.planner-hour-row > span { + font-weight: 700; +} + +.planner-hour-row small { + color: var(--bs-secondary-color); +} + +.planner-hour-row small { + white-space: nowrap; +} + +.planner-card-result small, +#pollerRuntime { + display: block; +} + +.planner-preview-row small, +.planner-history-row small, +#pollerRuntime { + line-height: 1.45; +} + +.planner-history-item { + background: rgba(var(--bs-secondary-bg-rgb), 0.45); + border: 1px solid var(--bs-border-color); + border-radius: 999px; + display: inline-block; + margin: 0.15rem 0.35rem 0.15rem 0; + padding: 0.15rem 0.4rem; +} + +#pollerRuntime { + margin-top: 0.25rem; +} + +.status-planner { + align-items: center; + background: transparent; + border: 1px solid var(--bs-border-color); + border-radius: 0.35rem; + color: inherit; + display: inline-flex; + gap: 0.35rem; + line-height: 1.2; + padding: 0.1rem 0.45rem; +} + +.status-planner:hover { + background: rgba(var(--bs-secondary-bg-rgb), 0.5); +} + +.tracker-scope-badge { + border: 1px solid var(--bs-border-color); + border-radius: 999px; + color: var(--bs-primary); + font-size: 0.65rem; + margin-left: 0.25rem; + padding: 0 0.3rem; +} + +.tracker-filter-all { + border-style: dashed; +} + +@media (max-width: 1100px) { + .planner-hour-grid, + .planner-protection-toggles { + grid-template-columns: 1fr; + } + + .planner-protection-grid { + grid-template-columns: repeat(3, minmax(130px, 1fr)); + } +} + +@media (max-width: 900px) { + .planner-time-grid, + .planner-profile-grid, + .planner-protection-grid, + .planner-speed-grid, + .poller-input-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 720px) { + .planner-hour-row, + .planner-speed-sliders, + .planner-time-grid, + .planner-profile-grid, + .planner-protection-grid, + .planner-speed-grid, + .planner-toggle-stack-compact, + .poller-input-grid { + grid-template-columns: 1fr; + } + + .planner-card .smart-setting-row { + flex-direction: column; + } + + .planner-hour-row small { + white-space: normal; + } + + .tool-action-row .btn { + flex: 1 1 auto; + } +} + + +/* Phase 5 dashboard, smart views and notifications */ +.health-dashboard-grid, +.smart-view-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: .75rem; +} +.health-card, +.smart-view-card, +.notification-item { + border: 1px solid var(--bs-border-color); + border-radius: .75rem; + background: var(--bs-body-bg); + box-shadow: 0 .25rem .8rem rgba(15, 23, 42, .04); +} +.health-card { + padding: .85rem; + min-width: 0; +} +.health-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: .75rem; + margin-bottom: .25rem; +} +.health-card > small, +.smart-view-card small, +.notification-item small { + color: var(--bs-secondary-color); +} +.health-list { + display: grid; + gap: .4rem; + margin-top: .65rem; +} +.health-row { + display: grid; + gap: .15rem; + width: 100%; + padding: .45rem .55rem; + border: 1px solid var(--bs-border-color); + border-radius: .55rem; + background: var(--bs-tertiary-bg); + color: inherit; + text-align: left; +} +.health-row span, +.health-row small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.smart-view-card { + display: grid; + gap: .35rem; + padding: .9rem; + text-align: left; + color: inherit; +} +.smart-view-card.active, +.smart-view-card:hover { + border-color: var(--bs-primary); +} +.smart-view-card span { + font-size: .8rem; + color: var(--bs-primary); +} +.notification-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: .75rem; + margin-bottom: .75rem; +} +.notification-list { + display: grid; + gap: .55rem; +} +.notification-item { + display: grid; + grid-template-columns: auto 1fr; + gap: .65rem; + padding: .7rem .8rem; +} +.notification-item > i { + margin-top: .15rem; +} +.notification-item > div { + display: grid; + gap: .15rem; + min-width: 0; +} +.notification-item span { + overflow-wrap: anywhere; +} +.notification-error > i, +.notification-warning > i { + color: var(--bs-warning); +} +.notification-planner > i, +.notification-queue > i { + color: var(--bs-primary); +} + +/* Diagnostics layout */ +.diagnostics-section { + display: grid; + gap: .75rem; + margin-bottom: 1rem; +} +.diagnostics-section:last-child { + margin-bottom: 0; +} + +/* Columns tab panes keep the original column card layout for both views. */ +.column-manager-tabs, +.column-manager-pane { + grid-column: 1 / -1; +} +.column-manager-tabs { + margin-bottom: .75rem; +} +.column-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); + gap: 0.55rem; +} +.mobile-sort-row .btn { + pointer-events: auto; +} +.mobile-progress:empty { + display: none; +} + +.profile-status-badge{font-size:.7rem;text-transform:uppercase;letter-spacing:.02em;} +.profile-diagnostics-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:.5rem;} +.profile-diagnostics-card{border:1px solid var(--bs-border-color);border-radius:.5rem;padding:.5rem;background:var(--bs-body-bg);} +.profile-diagnostics-card small{display:block;color:var(--bs-secondary-color);} + +.labels-manager { display: grid; gap: 0.5rem; } +.profile-status-badge.badge { min-height: 1.25rem; line-height: 1; display: inline-flex; align-items: center; padding: .25em .5em; } + +/* UI hygiene: keep long status/footer content inside the app instead of widening the browser viewport. */ +html, +body, +.app-shell, +.topbar, +.main-grid, +.content, +.statusbar { + max-width: 100%; + min-width: 0; +} + +.statusbar { + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; + overscroll-behavior-x: contain; +} + +.statusbar > * { + flex: 0 0 auto; +} + +/* Compact rTorrent profile badges so online/slow/degraded match archive-style pills. */ +.profile-status-badge.badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: auto; + min-width: 0; + min-height: 1.2rem; + max-width: max-content; + padding: 0.18rem 0.45rem; + font-size: 0.68rem; + line-height: 1; + letter-spacing: 0.015em; + text-transform: uppercase; + white-space: nowrap; + vertical-align: middle; +} + +.profile-row { + grid-template-columns: minmax(0, 1fr) max-content; +} + +.profile-actions { + justify-content: flex-end; +} + +.preferences-browser-layout { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 0.75rem; +} + +.preference-block { + height: 100%; +} + +.management-card { + border: 1px solid var(--bs-border-color); + border-radius: 0.8rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.42); + padding: 0.85rem; +} + +.management-card-title { + display: flex; + align-items: center; + gap: 0.45rem; + margin-bottom: 0.7rem; + color: var(--bs-body-color); + font-weight: 700; +} + +.management-form-grid { + align-items: end; +} + +.management-form-grid .form-field, +.management-switch { + min-width: 0; + margin: 0; +} + +.management-form-grid .form-field > span:first-child { + display: block; + margin-bottom: 0.25rem; + color: var(--bs-secondary-color); + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; +} + +.management-form-grid .form-field-wide { + grid-column: span 2; +} + +.management-switch { + display: flex; + align-items: center; + min-height: 2.35rem; + gap: 0.45rem; +} + +.management-actions { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + margin-top: 0.75rem; +} + +.tool-split-section .table, +.management-card .table { + margin-bottom: 0; +} + +#smartPane-logs, +#automationPane-logs { + padding-top: 0.25rem; +} + +@media (max-width: 768px) { + .management-form-grid .form-field-wide { + grid-column: auto; + } + + .management-actions { + align-items: stretch; + flex-direction: column; + } +} + +/* Keep rTorrent diagnostics badges visually aligned with the smaller active/archive pills. */ +.profile-row .profile-status-badge.badge { + min-height: 1rem; + padding: 0.1rem 0.32rem; + font-size: 0.58rem; + line-height: 1; + letter-spacing: 0.01em; + border-radius: 999px; +} + +/* Flat nested sections inside already framed preference panels. */ +.disk-monitor-shell-flat .disk-monitor-mode-card, +.disk-monitor-shell-flat .disk-monitor-path-card { + border: 0; + border-radius: 0; + background: transparent; + padding: 0; +} + + +.create-torrent-form { + display: grid; + gap: 0.85rem; +} + +.create-source-row, +.create-fieldset { + padding: 0.85rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.35); + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; +} + +.create-fieldset { + min-width: 0; +} + +.create-fieldset legend { + float: none; + width: auto; + margin: 0 0 0.65rem; + padding: 0; + color: var(--bs-secondary-color); + font-size: 0.8rem; + font-weight: 700; +} + +.create-options { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: center; +} + +.function-note { + padding: 0.55rem 0.7rem; + color: var(--bs-secondary-color); + background: rgba(var(--bs-secondary-bg-rgb), 0.42); + border: 1px solid var(--bs-border-color); + border-radius: 0.65rem; + font-size: 0.82rem; +} + +.function-note b { + color: var(--bs-body-color); +} + + +@media (max-width: 768px) { + .create-options, + .add-start-switch { + align-items: flex-start; + } +} + + +/* Add/create torrent modal refinements. These classes extend the existing modal and form styles without replacing shared CSS. */ +.add-create-modal-body { + padding-top: 1rem; +} + +.add-create-tab-content { + margin-top: 0.25rem; +} + +.add-torrent-layout { + display: grid; + gap: 0.85rem; +} + +.add-torrent-panel { + background: color-mix(in srgb, var(--bs-body-bg) 92%, var(--bs-tertiary-bg)); + border: 1px solid color-mix(in srgb, var(--bs-border-color) 72%, transparent); + border-radius: 0.85rem; + box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 5%, transparent); + padding: 0.9rem; +} + +.add-torrent-panel-heading, +.add-target-grid, +.create-options-panel { + align-items: center; + display: grid; + gap: 0.75rem; +} + +.add-torrent-panel-heading { + grid-template-columns: minmax(0, 1fr) auto; + margin-bottom: 0.75rem; +} + +.add-magnet-input { + min-height: 8.5rem; + resize: vertical; +} + +.add-file-picker { + overflow: hidden; + position: relative; + white-space: nowrap; +} + +.add-file-picker input { + height: 1px; + opacity: 0; + position: absolute; + right: 0; + top: 0; + width: 1px; +} + +.add-file-summary { + align-items: center; + background: var(--bs-tertiary-bg); + border: 1px dashed var(--bs-border-color); + border-radius: 0.7rem; + color: var(--bs-secondary-color); + display: flex; + min-height: 2.6rem; + padding: 0.6rem 0.75rem; +} + +.add-file-preview:not(:empty) { + margin-top: 0.75rem; +} + +.add-target-grid { + grid-template-columns: minmax(14rem, 1fr) minmax(10rem, 16rem) auto; +} + +.add-start-card { + align-items: center; + display: flex; + gap: 0.55rem; + justify-content: flex-start; + margin: 0; + min-height: 2.4rem; + padding: 0; + white-space: nowrap; +} + +.add-start-card .form-check-input { + flex: 0 0 auto; + margin: 0; +} + +.create-properties-grid, +.create-meta-grid { + display: grid; + gap: 0.75rem; + grid-template-columns: minmax(0, 1fr) minmax(13rem, 18rem); +} + +.create-side-fields { + display: grid; + gap: 0.75rem; +} + +.create-options-panel { + grid-template-columns: repeat(auto-fit, minmax(13rem, max-content)); + justify-content: start; +} + +@media (max-width: 992px) { + .add-target-grid, + .create-properties-grid, + .create-meta-grid { + grid-template-columns: 1fr; + } + + .add-start-card { + justify-content: flex-start; + } +} + +@media (max-width: 576px) { + .add-torrent-panel-heading { + grid-template-columns: 1fr; + } +} + +/* API tokens and path picker improvements */ +.api-token-row { + align-items: center; + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + display: flex; + gap: 0.75rem; + justify-content: space-between; + padding: 0.75rem; +} + +.api-token-row + .api-token-row { + margin-top: 0.5rem; +} + +.api-token-row small { + color: var(--bs-secondary-color); + display: block; + margin-top: 0.15rem; +} + +.path-info-strip { + align-items: center; + background: var(--bs-tertiary-bg); + border-bottom: 1px solid var(--bs-border-color); + display: flex; + flex-wrap: wrap; + gap: 0.5rem 0.85rem; + padding: 0.65rem 0.75rem; +} + +.path-info-strip span { + color: var(--bs-secondary-color); + font-size: 0.82rem; +} + +@media (max-width: 576px) { + .api-token-row { + align-items: stretch; + flex-direction: column; + } +} + + +/* Stacked modal and auth token refinements. */ +#pathModal { + z-index: 1080; +} + +#pathModal + .modal-backdrop, +.modal-backdrop.path-picker-backdrop { + z-index: 1075; +} + +.api-token-inline { + background: var(--bs-tertiary-bg); + border: 1px solid var(--bs-border-color); + border-radius: 0.85rem; + padding: 0.85rem; +} + +.api-token-inline .input-group { + margin-top: 0.45rem; +} + +.api-token-inline small { + color: var(--bs-secondary-color); + display: block; +} + + +/* Note: Tool forms and generated action columns must stay inside modal width on narrow screens. */ +#toolsModal .modal-body { + min-width: 0; + overflow-x: hidden; +} + +#toolsModal .nav-pills { + flex-wrap: nowrap; + max-width: 100%; + overflow-x: auto; + padding-bottom: 0.2rem; + scrollbar-width: thin; + -webkit-overflow-scrolling: touch; +} + +#toolsModal .nav-pills .nav-link { + white-space: nowrap; +} + +.table-action-group { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + align-items: center; +} + +.table-action-group .btn { + white-space: nowrap; +} + +.backup-actions { + justify-content: flex-end; +} + +.backup-create-row { + min-width: 0; +} + + +@media (max-width: 768px) { + .profile-form-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + width: 100%; + } + + .profile-form-actions .btn { + min-width: 0; + width: 100%; + } + + .backup-create-row { + display: grid; + gap: 0.45rem; + } + + .backup-create-row > .form-control, + .backup-create-row > .btn { + width: 100%; + } + + .backup-actions { + justify-content: flex-start; + } + + .table-action-group .btn { + flex: 1 1 7.5rem; + } +} + +@media (max-width: 420px) { + .profile-form-actions { + grid-template-columns: 1fr; + } +} diff --git a/pytorrent/static/tracker_favicons b/pytorrent/static/tracker_favicons new file mode 120000 index 0000000..d4b544b --- /dev/null +++ b/pytorrent/static/tracker_favicons @@ -0,0 +1 @@ +../../data/tracker_favicons \ No newline at end of file diff --git a/pytorrent/templates/error.html b/pytorrent/templates/error.html new file mode 100644 index 0000000..674a670 --- /dev/null +++ b/pytorrent/templates/error.html @@ -0,0 +1,26 @@ + + + + + + pyTorrent {{ code }} + + + + + + + +
    +
    pyTorrent
    + +

    {{ code }}

    +

    {{ title }}

    +

    {{ message }}

    + +
    + + diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html new file mode 100644 index 0000000..e5b19d3 --- /dev/null +++ b/pytorrent/templates/index.html @@ -0,0 +1,364 @@ + + + + + + pyTorrent + + + + + + + + +
    +
    +
    pyTorrent
    +
    +
    Loading torrents...
    +
    Connecting to rTorrent and preparing data.
    +
    +
    +
    Working...
    +
    +
    +
    +
    pyTorrent
    + + + + + + + + + + +
    +
    + 0 B/s 0 B/s + busy + offline + + + + {% if auth_enabled %} {{ current_user.username if current_user else 'logout' }}{% endif %} +
    +
    + +
    + + +
    +
    0 selected
    + + + + + + + + +
    NameStatusSizeProgressDLULETASeedsPeersRatioPathLabelRatio groupDownloadedTo downloadUploadedAddedPriorityStateActiveCompleteHashingMessageHash
    Waiting for data.
    +
    +
    +
    + +
    + +
    +
    Select a torrent.
    +
    +
    +
    + +
    + CPU -%RAM -% + - + rTorrent -DL 0 B/sUL 0 B/sPeak S 0B/s / 0B/s · All 0B/s / 0B/s + + Total DL/UP 0B/0B Port - unknown --:--:-- Sockets - Downloads - Uploads - HTTP - Files - Port -Shown 0Selected 0 Docs API +
    +
    + +
    +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + diff --git a/pytorrent/templates/login.html b/pytorrent/templates/login.html new file mode 100644 index 0000000..eaeb07d --- /dev/null +++ b/pytorrent/templates/login.html @@ -0,0 +1,29 @@ + + + + + + pyTorrent login + + + + + + + +
    +
    pyTorrent
    + +

    Sign in

    +

    Authentication is enabled for this pyTorrent instance.

    + {% if error %}
    {{ error }}
    {% endif %} +
    + + + + + +
    +
    + + diff --git a/pytorrent/utils.py b/pytorrent/utils.py new file mode 100644 index 0000000..7a2639a --- /dev/null +++ b/pytorrent/utils.py @@ -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] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ad41f31 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/scripts/INSTALL.md b/scripts/INSTALL.md new file mode 100644 index 0000000..d4ba1c0 --- /dev/null +++ b/scripts/INSTALL.md @@ -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. diff --git a/scripts/check_pytorrent_health.sh b/scripts/check_pytorrent_health.sh new file mode 100755 index 0000000..0bc0f85 --- /dev/null +++ b/scripts/check_pytorrent_health.sh @@ -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 diff --git a/scripts/download_frontend_libs.py b/scripts/download_frontend_libs.py new file mode 100755 index 0000000..d1ef6cc --- /dev/null +++ b/scripts/download_frontend_libs.py @@ -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() diff --git a/scripts/download_geoip.sh b/scripts/download_geoip.sh new file mode 100755 index 0000000..5c8cf51 --- /dev/null +++ b/scripts/download_geoip.sh @@ -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" diff --git a/scripts/install_debian_ubuntu.sh b/scripts/install_debian_ubuntu.sh new file mode 100755 index 0000000..55e284d --- /dev/null +++ b/scripts/install_debian_ubuntu.sh @@ -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" < | 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}" diff --git a/scripts/rtorrent_cli.py b/scripts/rtorrent_cli.py new file mode 100644 index 0000000..3b0046c --- /dev/null +++ b/scripts/rtorrent_cli.py @@ -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 \ No newline at end of file diff --git a/scripts/stack_installers/INSTALL.md b/scripts/stack_installers/INSTALL.md new file mode 100644 index 0000000..d4ba1c0 --- /dev/null +++ b/scripts/stack_installers/INSTALL.md @@ -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. diff --git a/scripts/stack_installers/configure_pytorrent_api.py b/scripts/stack_installers/configure_pytorrent_api.py new file mode 100755 index 0000000..f339a0c --- /dev/null +++ b/scripts/stack_installers/configure_pytorrent_api.py @@ -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) diff --git a/scripts/stack_installers/install_pytorrent_rhel.sh b/scripts/stack_installers/install_pytorrent_rhel.sh new file mode 100755 index 0000000..61dda79 --- /dev/null +++ b/scripts/stack_installers/install_pytorrent_rhel.sh @@ -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" <>> {' '.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) diff --git a/scripts/stack_installers/install_rtorrent_rhel.py b/scripts/stack_installers/install_rtorrent_rhel.py new file mode 100755 index 0000000..6103324 --- /dev/null +++ b/scripts/stack_installers/install_rtorrent_rhel.py @@ -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 detect_rhel(): + 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('"') + + distro_id = data.get("ID", "").lower() + distro_like = data.get("ID_LIKE", "").lower() + rhel_markers = {"rhel", "centos", "rocky", "almalinux", "fedora", "ol", "scientific"} + if distro_id not in rhel_markers and not any(marker in distro_like for marker in ("rhel", "fedora", "centos")): + raise InstallError( + f"Unsupported distribution: ID={data.get('ID', 'unknown')}, " + f"ID_LIKE={data.get('ID_LIKE', 'unknown')}. This installer supports RHEL-compatible systems only." + ) + + print(f"Detected RHEL-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 enable_rhel_optional_repos(*, debug=False): + manager = shutil.which("dnf") or shutil.which("yum") + if not manager: + return + # CRB/PowerTools is required by many EPEL/devel packages on RHEL-compatible systems. + crb = shutil.which("crb") + if crb: + run([crb, "enable"], check=False, debug=debug, log_name="enable_crb") + config_manager = shutil.which("dnf") or shutil.which("yum") + run([config_manager, "config-manager", "--set-enabled", "crb"], check=False, debug=debug, log_name="enable_crb") + run([config_manager, "config-manager", "--set-enabled", "powertools"], check=False, debug=debug, log_name="enable_powertools") + + +def ensure_packages(packages, *, debug=False): + manager = shutil.which("dnf") or shutil.which("yum") + if not manager: + raise InstallError("dnf or yum was not found on this RHEL-compatible system.") + print("Installing build and runtime dependencies...") + enable_rhel_optional_repos(debug=debug) + # RHEL-compatible systems do not provide Debian's build-essential package. + # The closest equivalent is the Development Tools group. + run([manager, "groupinstall", "-y", "Development Tools"], check=False, debug=debug, log_name="dnf_groupinstall_development_tools") + run([manager, "install", "-y", *packages], debug=debug, log_name="dnf_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) + with Spinner("Configuring xmlrpc-c", enabled=not debug): + run(["./configure", f"--prefix={install_dir}"], cwd=str(source_root), debug=debug) + with Spinner("Building xmlrpc-c", enabled=not debug): + run(["make", "-j", str(os.cpu_count() or 1)], cwd=str(source_root), 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), 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="RHEL-compatible 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_rhel() + + packages = [ + "gcc", "gcc-c++", "make", "pkgconf-pkg-config", "libtool", "autoconf", "automake", "git", "ca-certificates", + "openssl-devel", "ncurses-devel", "expat-devel", "curl", "libcurl-devel", "tar", "gzip", "zlib-devel", "which", + "patch", "diffutils", "findutils", "file", "redhat-rpm-config", "libstdc++-devel" + ] + if args.use_cares: + packages.extend(["cmake", "libpsl-devel", "brotli-devel", "libzstd-devel"]) + + 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 RHEL 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) diff --git a/scripts/stack_installers/install_stack_debian_ubuntu.sh b/scripts/stack_installers/install_stack_debian_ubuntu.sh new file mode 100755 index 0000000..a6ba2ce --- /dev/null +++ b/scripts/stack_installers/install_stack_debian_ubuntu.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +set -euo pipefail + +# One-command installer for rTorrent + pyTorrent on Debian/Ubuntu. +# Notes: +# - rTorrent is built as a minimal v0.16.11 install by default. +# - pyTorrent is configured through its HTTP API after the service starts. + +if [[ "${EUID}" -ne 0 ]]; then + echo "Run as root: sudo $0" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +RTORRENT_USER="${RTORRENT_USER:-rtorrent}" +RTORRENT_HOME="${RTORRENT_HOME:-/home/${RTORRENT_USER}}" +RTORRENT_BASE_DIR="${RTORRENT_BASE_DIR:-/opt/rtorrent_build}" +RTORRENT_SCGI_PORT="${RTORRENT_SCGI_PORT:-5000}" +RTORRENT_TORRENT_PORT="${RTORRENT_TORRENT_PORT:-51300}" +RTORRENT_REF="${RTORRENT_REF:-v0.16.11}" +LIBTORRENT_REF="${LIBTORRENT_REF:-v0.16.11}" +PYTORRENT_APP_DIR="${PYTORRENT_APP_DIR:-/opt/pytorrent}" +PYTORRENT_PORT="${PYTORRENT_PORT:-8090}" +PYTORRENT_BASE_URL="${PYTORRENT_BASE_URL:-http://127.0.0.1:${PYTORRENT_PORT}}" +PYTORRENT_PROFILE_NAME="${PYTORRENT_PROFILE_NAME:-Local rTorrent}" +PYTORRENT_API_TOKEN="${PYTORRENT_API_TOKEN:-}" +PYTORRENT_SERVICE_NAME="${PYTORRENT_SERVICE_NAME:-pytorrent}" +PYTORRENT_RTORRENT_SCGI_URL="${PYTORRENT_RTORRENT_SCGI_URL:-scgi://127.0.0.1:${RTORRENT_SCGI_PORT}}" + +export PYTORRENT_APP_DIR PYTORRENT_PORT PYTORRENT_SERVICE_NAME PYTORRENT_API_TOKEN + +install_debian_stack_prerequisites() { + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + tar \ + gzip \ + sudo \ + python3 \ + python3-venv \ + python3-pip \ + build-essential \ + pkg-config \ + libtool \ + autoconf \ + automake \ + git \ + make \ + gcc \ + g++ \ + libssl-dev \ + libncurses-dev \ + libncurses5-dev \ + libncursesw5-dev \ + libexpat1-dev \ + libcurl4-openssl-dev \ + libxml2-dev \ + libreadline-dev \ + zlib1g-dev \ + bison \ + flex \ + m4 \ + gettext \ + texinfo \ + patch \ + diffutils \ + file \ + procps \ + xz-utils +} + +install_debian_stack_prerequisites + +RTORRENT_INSTALL_ARGS=( + --yes + --minimal +) +if [[ "${PYTORRENT_DEBUG_INSTALL:-0}" == "1" ]]; then + RTORRENT_INSTALL_ARGS+=(--debug) +fi + +python3 "${SCRIPT_DIR}/install_rtorrent.py" \ + "${RTORRENT_INSTALL_ARGS[@]}" \ + --force-config \ + --base-dir "${RTORRENT_BASE_DIR}" \ + --user "${RTORRENT_USER}" \ + --group "${RTORRENT_USER}" \ + --home "${RTORRENT_HOME}" \ + --scgi-port "${RTORRENT_SCGI_PORT}" \ + --torrent-port "${RTORRENT_TORRENT_PORT}" \ + --rtorrent-ref "${RTORRENT_REF}" \ + --libtorrent-ref "${LIBTORRENT_REF}" + +cd "${PROJECT_DIR}" +bash "${PROJECT_DIR}/scripts/install_debian_ubuntu.sh" + +if [[ -f "${PYTORRENT_APP_DIR}/.env" ]]; then + python3 - "${PYTORRENT_APP_DIR}/.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 "${PYTORRENT_USER:-pytorrent}:${PYTORRENT_USER:-pytorrent}" "${PYTORRENT_APP_DIR}/.env" || true + systemctl restart "${PYTORRENT_SERVICE_NAME}" +fi + +CONFIGURE_ARGS=( + --base-url "${PYTORRENT_BASE_URL}" + --profile-name "${PYTORRENT_PROFILE_NAME}" + --scgi-url "${PYTORRENT_RTORRENT_SCGI_URL}" +) +if [[ -n "${PYTORRENT_API_TOKEN}" ]]; then + CONFIGURE_ARGS+=(--api-token "${PYTORRENT_API_TOKEN}") +fi +"${PYTORRENT_APP_DIR}/venv/bin/python" "${PYTORRENT_APP_DIR}/scripts/stack_installers/configure_pytorrent_api.py" "${CONFIGURE_ARGS[@]}" + +echo "Done. pyTorrent: ${PYTORRENT_BASE_URL} | rTorrent SCGI: ${PYTORRENT_RTORRENT_SCGI_URL}" diff --git a/scripts/stack_installers/install_stack_rhel.sh b/scripts/stack_installers/install_stack_rhel.sh new file mode 100755 index 0000000..b945732 --- /dev/null +++ b/scripts/stack_installers/install_stack_rhel.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +set -euo pipefail + +# One-command installer for rTorrent + pyTorrent on RHEL-compatible systems. +# Notes: +# - rTorrent is built as a minimal v0.16.11 install by default. +# - pyTorrent is configured through its HTTP API after the service starts. + +if [[ "${EUID}" -ne 0 ]]; then + echo "Run as root: sudo $0" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +RTORRENT_USER="${RTORRENT_USER:-rtorrent}" +RTORRENT_HOME="${RTORRENT_HOME:-/home/${RTORRENT_USER}}" +RTORRENT_BASE_DIR="${RTORRENT_BASE_DIR:-/opt/rtorrent_build}" +RTORRENT_SCGI_PORT="${RTORRENT_SCGI_PORT:-5000}" +RTORRENT_TORRENT_PORT="${RTORRENT_TORRENT_PORT:-51300}" +RTORRENT_REF="${RTORRENT_REF:-v0.16.11}" +LIBTORRENT_REF="${LIBTORRENT_REF:-v0.16.11}" +PYTORRENT_APP_DIR="${PYTORRENT_APP_DIR:-/opt/pytorrent}" +PYTORRENT_PORT="${PYTORRENT_PORT:-8090}" +PYTORRENT_BASE_URL="${PYTORRENT_BASE_URL:-http://127.0.0.1:${PYTORRENT_PORT}}" +PYTORRENT_PROFILE_NAME="${PYTORRENT_PROFILE_NAME:-Local rTorrent}" +PYTORRENT_API_TOKEN="${PYTORRENT_API_TOKEN:-}" +PYTORRENT_SERVICE_NAME="${PYTORRENT_SERVICE_NAME:-pytorrent}" +PYTORRENT_RTORRENT_SCGI_URL="${PYTORRENT_RTORRENT_SCGI_URL:-scgi://127.0.0.1:${RTORRENT_SCGI_PORT}}" + +export PYTORRENT_APP_DIR PYTORRENT_PORT PYTORRENT_SERVICE_NAME PYTORRENT_API_TOKEN + +install_rhel_stack_prerequisites() { + local manager="" + if command -v dnf >/dev/null 2>&1; then + manager="dnf" + elif command -v yum >/dev/null 2>&1; then + manager="yum" + else + echo "dnf or yum is required on RHEL-compatible systems." >&2 + exit 1 + fi + + "${manager}" install -y ca-certificates tar curl gzip sudo python3 dnf-plugins-core epel-release || \ + "${manager}" install -y ca-certificates tar curl gzip sudo python3 + + if command -v crb >/dev/null 2>&1; then + crb enable || true + fi + "${manager}" config-manager --set-enabled crb || true + "${manager}" config-manager --set-enabled powertools || true + "${manager}" makecache || true + + "${manager}" groupinstall -y "Development Tools" || true + "${manager}" install -y \ + git \ + gcc \ + gcc-c++ \ + make \ + autoconf \ + automake \ + libtool \ + pkgconf-pkg-config \ + ncurses-devel \ + openssl-devel \ + expat-devel \ + zlib-devel \ + libcurl-devel \ + redhat-rpm-config \ + patch \ + diffutils \ + findutils \ + file \ + which \ + libstdc++-devel +} + +install_rhel_stack_prerequisites + +RTORRENT_INSTALL_ARGS=( + --yes + --minimal + --force-config +) +if [[ "${PYTORRENT_DEBUG_INSTALL:-0}" == "1" ]]; then + RTORRENT_INSTALL_ARGS+=(--debug) +fi + +python3 "${SCRIPT_DIR}/install_rtorrent_rhel.py" \ + "${RTORRENT_INSTALL_ARGS[@]}" \ + --base-dir "${RTORRENT_BASE_DIR}" \ + --user "${RTORRENT_USER}" \ + --group "${RTORRENT_USER}" \ + --home "${RTORRENT_HOME}" \ + --scgi-port "${RTORRENT_SCGI_PORT}" \ + --torrent-port "${RTORRENT_TORRENT_PORT}" \ + --rtorrent-ref "${RTORRENT_REF}" \ + --libtorrent-ref "${LIBTORRENT_REF}" + +cd "${PROJECT_DIR}" +bash "${SCRIPT_DIR}/install_pytorrent_rhel.sh" + +CONFIGURE_ARGS=( + --base-url "${PYTORRENT_BASE_URL}" + --profile-name "${PYTORRENT_PROFILE_NAME}" + --scgi-url "${PYTORRENT_RTORRENT_SCGI_URL}" +) +if [[ -n "${PYTORRENT_API_TOKEN}" ]]; then + CONFIGURE_ARGS+=(--api-token "${PYTORRENT_API_TOKEN}") +fi +"${PYTORRENT_APP_DIR}/venv/bin/python" "${PYTORRENT_APP_DIR}/scripts/stack_installers/configure_pytorrent_api.py" "${CONFIGURE_ARGS[@]}" + +echo "Done. pyTorrent: ${PYTORRENT_BASE_URL} | rTorrent SCGI: ${PYTORRENT_RTORRENT_SCGI_URL}" diff --git a/systemd/pytorrent.service b/systemd/pytorrent.service new file mode 100644 index 0000000..853b185 --- /dev/null +++ b/systemd/pytorrent.service @@ -0,0 +1,25 @@ +[Unit] +Description=pyTorrent Web UI +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +#User=root +#Group=root +User=pytorrent +Group=pytorrent +WorkingDirectory=/opt/pyTorrent +Environment="PYTHONUNBUFFERED=1" +EnvironmentFile=/opt/pyTorrent/.env +# Note: threaded Gunicorn preserves Flask-SocketIO background tasks without running Werkzeug in production. +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 \ No newline at end of file diff --git a/tests/frontend_modules.test.mjs b/tests/frontend_modules.test.mjs new file mode 100644 index 0000000..eeacd1f --- /dev/null +++ b/tests/frontend_modules.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; + +global.window = {PYTORRENT_DISABLE_AUTOSTART: true}; +const app = await import('../pytorrent/static/js/app.js'); +const source = app.buildRuntimeSource(); + +assert.equal(app.moduleSources.length, 12, 'all frontend module chunks are loaded'); +assert.doesNotThrow(() => Function('io', source), 'assembled frontend runtime compiles'); + +for (const marker of [ + 'function renderRow', + 'function renderTable', + 'function scheduleRender', + 'async function post', + 'async function loadRss', + 'async function loadSmartQueue', + 'function ensurePlannerToolsUI', + 'function loadPlannerPreview', + 'function pollerPayload', + 'function pollerDiagnostics', + 'function renderHealthDashboard', + 'function recordNotification', + 'function drawTrafficHistory', + "socket.on('connect'", + '/api/download-planner/preview', + 'plannerProfileName', + 'pollerTorrentList', +]) { + assert.ok(source.includes(marker), `runtime contains ${marker}`); +} + +function extractFunction(src, name){ + const start = src.indexOf(`function ${name}`); + assert.ok(start >= 0, `found function ${name}`); + const open = src.indexOf('{', start); + let depth = 0; + for(let i=open; i&"'), '<tag>&"', 'esc escapes HTML'); +assert.ok(renderHarness.progressBar(42).includes('42%'), 'progressBar renders percentage'); +assert.ok(renderHarness.compactCell('x'.repeat(200)).includes('title='), 'compactCell renders title for long text'); +assert.ok(renderHarness.table(['A'], [['B']]).includes(' 0 + state = poller_control.ProfilePollState(profile_id=1) + runtime = poller_control.mark_tick( + state, + time.monotonic() - 0.5, + active=True, + ok=True, + emitted_payload_size=1234, + rtorrent_call_count=2, + skipped_emissions=1, + settings=settings, + ) + assert runtime["emitted_payload_size"] == 1234 + assert runtime["rtorrent_call_count"] == 2 + assert runtime["adaptive_mode"] in {"normal", "idle", "slowdown", "recovery"} + + fixed_state = poller_control.ProfilePollState(profile_id=2, adaptive_mode="slowdown", slow_count=5) + fixed_runtime = poller_control.mark_tick( + fixed_state, + time.monotonic() - 1.0, + active=True, + ok=True, + settings={**settings, "adaptive_enabled": False}, + ) + assert fixed_runtime["adaptive_enabled"] is False + assert fixed_runtime["adaptive_mode"] == "fixed" + assert fixed_runtime["slow_count"] == 0 + + +def test_poller_background_slow_task_state(): + state = poller_control.ProfilePollState(profile_id=3) + assert state.slow_task_running is False + state.slow_task_running = True + runtime = poller_control.mark_tick( + state, + time.monotonic() - 0.05, + active=True, + ok=True, + settings={"adaptive_enabled": False, "slow_response_threshold_ms": 200}, + skipped_emissions=1, + ) + assert runtime["adaptive_mode"] == "fixed" + assert runtime["skipped_emissions"] >= 1 + assert state.slow_task_running is True + + +def test_poller_requested_fast_defaults(): + settings = poller_control.normalize_settings({}) + assert settings["active_interval_seconds"] == 0.5 + assert settings["torrent_list_interval_seconds"] == 0.5 + assert settings["idle_interval_seconds"] == 3.0 + assert settings["error_interval_seconds"] == 2.0 + assert settings["system_stats_interval_seconds"] == 1.0 + assert settings["tracker_stats_interval_seconds"] == 30.0 + assert settings["disk_stats_interval_seconds"] == 30.0 + assert settings["queue_stats_interval_seconds"] == 5.0 + assert settings["heartbeat_interval_seconds"] == 5.0 + assert settings["slow_response_threshold_ms"] == 10000.0 + assert settings["slowdown_multiplier"] == 1.0 + state = poller_control.ProfilePollState(profile_id=4) + runtime = poller_control.mark_tick(state, time.monotonic() - 0.01, active=True, ok=True, settings=settings) + assert runtime["effective_interval_seconds"] == 0.5 + assert runtime["configured_min_interval_seconds"] == 0.5 + assert "last_tick_gap_ms" in runtime + + +if __name__ == "__main__": + test_planner_evaluate_network_caps() + test_poller_metrics_and_fallback() + test_poller_background_slow_task_state() + test_poller_requested_fast_defaults() + print("planner/poller service smoke tests passed") diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..6e0da8e --- /dev/null +++ b/wsgi.py @@ -0,0 +1,3 @@ +from pytorrent import create_app + +app = create_app()