Merge pull request 'logs' (#1) from logs into master
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -1 +0,0 @@
|
|||||||
scripts/INSTALL.md
|
|
||||||
198
INSTALL.md
Normal file
198
INSTALL.md
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
# pyTorrent stack installer
|
||||||
|
|
||||||
|
This document describes the one-command installer for installing **rTorrent + pyTorrent** from a clean server.
|
||||||
|
|
||||||
|
The installer is split into two layers:
|
||||||
|
|
||||||
|
- `scripts/install_stack.sh` - public bootstrap script intended to be downloaded directly from Git.
|
||||||
|
- `scripts/stack_installers/` - OS-specific installers and helper scripts used by the bootstrap script.
|
||||||
|
|
||||||
|
## Quick install
|
||||||
|
|
||||||
|
Run as root or through `sudo`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh | sudo bash
|
||||||
|
```
|
||||||
|
|
||||||
|
The bootstrap script downloads the current pyTorrent repository, detects the operating system family, and runs the matching installer:
|
||||||
|
|
||||||
|
- Debian / Ubuntu: `scripts/stack_installers/install_stack_debian_ubuntu.sh`
|
||||||
|
- RHEL-compatible systems: `scripts/stack_installers/install_stack_rhel.sh`
|
||||||
|
|
||||||
|
Supported RHEL-compatible systems include RHEL, Rocky Linux, AlmaLinux, CentOS Stream, and Fedora-like systems where `dnf` or `yum` is available.
|
||||||
|
|
||||||
|
## What gets installed
|
||||||
|
|
||||||
|
Default installation includes:
|
||||||
|
|
||||||
|
- rTorrent `v0.16.11`
|
||||||
|
- libtorrent `v0.16.11`
|
||||||
|
- minimal rTorrent build without c-ares/custom curl
|
||||||
|
- rTorrent system user: `rtorrent`
|
||||||
|
- rTorrent SCGI endpoint: `scgi://127.0.0.1:5000`
|
||||||
|
- rTorrent incoming BitTorrent port: `51300`
|
||||||
|
- pyTorrent application directory: `/opt/pytorrent`
|
||||||
|
- pyTorrent HTTP port: `8090`
|
||||||
|
- pyTorrent profile configured through the HTTP API
|
||||||
|
|
||||||
|
The installer creates or updates a pyTorrent rTorrent profile through API after both services are installed.
|
||||||
|
|
||||||
|
## Recommended usage with overrides
|
||||||
|
|
||||||
|
Environment variables must be passed to the `sudo bash` process.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
||||||
|
| sudo PYTORRENT_PORT=8091 RTORRENT_SCGI_PORT=5001 bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Another example with a custom profile name:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
||||||
|
| sudo PYTORRENT_PROFILE_NAME="Local rTorrent" PYTORRENT_PORT=8090 bash
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bootstrap parameters
|
||||||
|
|
||||||
|
These variables are used by `scripts/install_stack.sh`.
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `PYTORRENT_REPO_URL` | `https://git.linuxiarz.pl/gru/pyTorrent` | Git repository base URL. |
|
||||||
|
| `PYTORRENT_REPO_BRANCH` | `master` | Branch used to download the repository archive. |
|
||||||
|
| `PYTORRENT_ARCHIVE_URL` | derived from repo URL and branch | Custom repository archive URL. |
|
||||||
|
| `PYTORRENT_BOOTSTRAP_DIR` | `/tmp/pytorrent-stack-installer` | Temporary directory used by the bootstrap script. |
|
||||||
|
| `PYTORRENT_KEEP_BOOTSTRAP_DIR` | `0` | Set to `1` to keep the temporary directory after installation. |
|
||||||
|
|
||||||
|
Example using a different branch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
||||||
|
| sudo PYTORRENT_REPO_BRANCH=develop bash
|
||||||
|
```
|
||||||
|
|
||||||
|
## rTorrent parameters
|
||||||
|
|
||||||
|
These variables are used by both stack installers.
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `RTORRENT_USER` | `rtorrent` | System user used to run rTorrent. |
|
||||||
|
| `RTORRENT_HOME` | `/home/${RTORRENT_USER}` | Home directory for the rTorrent user. |
|
||||||
|
| `RTORRENT_BASE_DIR` | `/opt/rtorrent_build` | Build and install directory for xmlrpc-c, libtorrent and rTorrent. |
|
||||||
|
| `RTORRENT_SCGI_PORT` | `5000` | Local SCGI port for rTorrent XMLRPC/SCGI. |
|
||||||
|
| `RTORRENT_TORRENT_PORT` | `51300` | Incoming BitTorrent listen port. |
|
||||||
|
| `RTORRENT_REF` | `v0.16.11` | rTorrent Git tag, branch, or commit. |
|
||||||
|
| `LIBTORRENT_REF` | `v0.16.11` | libtorrent Git tag, branch, or commit. |
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
||||||
|
| sudo RTORRENT_USER=rtorrent RTORRENT_SCGI_PORT=5001 RTORRENT_TORRENT_PORT=51400 bash
|
||||||
|
```
|
||||||
|
|
||||||
|
## pyTorrent parameters
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `PYTORRENT_APP_DIR` | `/opt/pytorrent` | pyTorrent installation directory. |
|
||||||
|
| `PYTORRENT_PORT` | `8090` | HTTP port used by the pyTorrent service. |
|
||||||
|
| `PYTORRENT_BASE_URL` | `http://127.0.0.1:${PYTORRENT_PORT}` | Base URL used by the API configurator. |
|
||||||
|
| `PYTORRENT_PROFILE_NAME` | `Local rTorrent` | Name of the rTorrent profile created in pyTorrent. |
|
||||||
|
| `PYTORRENT_API_TOKEN` | empty | Bearer token used when pyTorrent API authentication is enabled. |
|
||||||
|
| `PYTORRENT_SERVICE_NAME` | `pytorrent` | systemd service name for pyTorrent. |
|
||||||
|
| `PYTORRENT_RTORRENT_SCGI_URL` | `scgi://127.0.0.1:${RTORRENT_SCGI_PORT}` | SCGI URL saved in the pyTorrent rTorrent profile. |
|
||||||
|
|
||||||
|
Example with API token:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
||||||
|
| sudo PYTORRENT_API_TOKEN="pt_xxx" bash
|
||||||
|
```
|
||||||
|
|
||||||
|
## API configurator parameters
|
||||||
|
|
||||||
|
The API configurator can be run manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/opt/pytorrent/venv/bin/python /opt/pytorrent/scripts/stack_installers/configure_pytorrent_api.py \
|
||||||
|
--base-url http://127.0.0.1:8090 \
|
||||||
|
--profile-name "Local rTorrent" \
|
||||||
|
--scgi-url scgi://127.0.0.1:5000
|
||||||
|
```
|
||||||
|
|
||||||
|
CLI options:
|
||||||
|
|
||||||
|
| Option | Environment variable | Default | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `--base-url` | `PYTORRENT_BASE_URL` | `http://127.0.0.1:8090` | pyTorrent API base URL. |
|
||||||
|
| `--api-token` | `PYTORRENT_API_TOKEN` | empty | Bearer token for authenticated API calls. |
|
||||||
|
| `--profile-name` | `PYTORRENT_RTORRENT_PROFILE_NAME` | `Local rTorrent` | Profile name to create or update. |
|
||||||
|
| `--scgi-url` | `PYTORRENT_RTORRENT_SCGI_URL` | `scgi://127.0.0.1:5000` | rTorrent SCGI URL. |
|
||||||
|
| `--timeout` | `PYTORRENT_RTORRENT_TIMEOUT` | `10` | rTorrent request timeout in seconds. |
|
||||||
|
| `--wait` | `PYTORRENT_API_WAIT_SECONDS` | `90` | Time to wait for the pyTorrent API to become available. |
|
||||||
|
| `--remote` | `PYTORRENT_RTORRENT_REMOTE` | `0` | Mark profile as remote. Accepts `1`, `true`, `yes`, `on`. |
|
||||||
|
|
||||||
|
## Local installation without bootstrap
|
||||||
|
|
||||||
|
If the repository is already cloned:
|
||||||
|
|
||||||
|
Debian / Ubuntu:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash scripts/stack_installers/install_stack_debian_ubuntu.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
RHEL-compatible systems:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash scripts/stack_installers/install_stack_rhel.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installed service hints
|
||||||
|
|
||||||
|
Check services:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status pytorrent
|
||||||
|
systemctl status rtorrent@rtorrent.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Check logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tail -f /data/logs/app.log /data/logs/error.log
|
||||||
|
journalctl -u pytorrent -f
|
||||||
|
journalctl -u rtorrent@rtorrent.service -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The default rTorrent build is intentionally minimal.
|
||||||
|
- c-ares and custom curl are not enabled by the stack installer defaults.
|
||||||
|
- The rTorrent installer overwrites the generated `.rtorrent.rc` because the stack installer passes `--force-config`.
|
||||||
|
- pyTorrent is configured through the HTTP API after the service starts.
|
||||||
|
- If API authentication is enabled before profile configuration, pass `PYTORRENT_API_TOKEN`.
|
||||||
|
|
||||||
|
|
||||||
|
## Build logs and troubleshooting
|
||||||
|
|
||||||
|
The stack installer writes quiet build output to `/var/log/pytorrent-installer` by default.
|
||||||
|
Override it with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTORRENT_STACK_LOG_DIR=/tmp/pytorrent-build-logs
|
||||||
|
```
|
||||||
|
|
||||||
|
For full command output during rTorrent/libtorrent compilation, run with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTORRENT_DEBUG_INSTALL=1
|
||||||
|
```
|
||||||
|
|
||||||
|
On RHEL-compatible systems the installer also tries to enable CRB/PowerTools and installs `libcurl-devel`, `redhat-rpm-config`, `patch`, `diffutils`, `findutils`, `file`, and `libstdc++-devel`, because minimal Alma/Rocky images often do not include enough build tooling.
|
||||||
@@ -59,6 +59,7 @@ CREATE TABLE IF NOT EXISTS user_preferences (
|
|||||||
footer_items_json TEXT,
|
footer_items_json TEXT,
|
||||||
title_speed_enabled INTEGER DEFAULT 0,
|
title_speed_enabled INTEGER DEFAULT 0,
|
||||||
tracker_favicons_enabled INTEGER DEFAULT 0,
|
tracker_favicons_enabled INTEGER DEFAULT 0,
|
||||||
|
reverse_dns_enabled INTEGER DEFAULT 0,
|
||||||
automation_toasts_enabled INTEGER DEFAULT 1,
|
automation_toasts_enabled INTEGER DEFAULT 1,
|
||||||
smart_queue_toasts_enabled INTEGER DEFAULT 1,
|
smart_queue_toasts_enabled INTEGER DEFAULT 1,
|
||||||
disk_monitor_paths_json TEXT,
|
disk_monitor_paths_json TEXT,
|
||||||
@@ -457,6 +458,35 @@ CREATE TABLE IF NOT EXISTS tracker_summary_cache (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_tracker_summary_cache_profile ON tracker_summary_cache(profile_id, updated_epoch);
|
CREATE INDEX IF NOT EXISTS idx_tracker_summary_cache_profile ON tracker_summary_cache(profile_id, updated_epoch);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS operation_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
profile_id INTEGER,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
severity TEXT DEFAULT 'info',
|
||||||
|
source TEXT DEFAULT 'system',
|
||||||
|
torrent_hash TEXT,
|
||||||
|
torrent_name TEXT,
|
||||||
|
action TEXT,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
details_json TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_operation_logs_profile_created ON operation_logs(profile_id, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_operation_logs_user_profile_created ON operation_logs(user_id, profile_id, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_operation_logs_event_type ON operation_logs(event_type, created_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS operation_log_settings (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
profile_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
retention_mode TEXT DEFAULT 'days',
|
||||||
|
retention_days INTEGER DEFAULT 30,
|
||||||
|
retention_lines INTEGER DEFAULT 5000,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(user_id, profile_id)
|
||||||
|
);
|
||||||
CREATE TABLE IF NOT EXISTS tracker_favicon_cache (
|
CREATE TABLE IF NOT EXISTS tracker_favicon_cache (
|
||||||
domain TEXT PRIMARY KEY,
|
domain TEXT PRIMARY KEY,
|
||||||
source_url TEXT,
|
source_url TEXT,
|
||||||
@@ -481,6 +511,7 @@ MIGRATIONS = [
|
|||||||
"ALTER TABLE user_preferences ADD COLUMN footer_items_json TEXT",
|
"ALTER TABLE user_preferences ADD COLUMN footer_items_json TEXT",
|
||||||
"ALTER TABLE user_preferences ADD COLUMN title_speed_enabled INTEGER DEFAULT 0",
|
"ALTER TABLE user_preferences ADD COLUMN title_speed_enabled INTEGER DEFAULT 0",
|
||||||
"ALTER TABLE user_preferences ADD COLUMN tracker_favicons_enabled INTEGER DEFAULT 0",
|
"ALTER TABLE user_preferences ADD COLUMN tracker_favicons_enabled INTEGER DEFAULT 0",
|
||||||
|
"ALTER TABLE user_preferences ADD COLUMN reverse_dns_enabled INTEGER DEFAULT 0",
|
||||||
"ALTER TABLE user_preferences ADD COLUMN interface_scale INTEGER DEFAULT 100",
|
"ALTER TABLE user_preferences ADD COLUMN interface_scale INTEGER DEFAULT 100",
|
||||||
"ALTER TABLE user_preferences ADD COLUMN detail_panel_height INTEGER DEFAULT 255",
|
"ALTER TABLE user_preferences ADD COLUMN detail_panel_height INTEGER DEFAULT 255",
|
||||||
"ALTER TABLE user_preferences ADD COLUMN torrent_sort_json TEXT",
|
"ALTER TABLE user_preferences ADD COLUMN torrent_sort_json TEXT",
|
||||||
@@ -579,6 +610,11 @@ MIGRATIONS = [
|
|||||||
"CREATE INDEX IF NOT EXISTS idx_automation_history_user_profile_created ON automation_history(user_id, profile_id, created_at)",
|
"CREATE INDEX IF NOT EXISTS idx_automation_history_user_profile_created ON automation_history(user_id, profile_id, created_at)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id)",
|
"CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_rtorrent_profiles_user_default_name ON rtorrent_profiles(user_id, is_default, name COLLATE NOCASE)",
|
"CREATE INDEX IF NOT EXISTS idx_rtorrent_profiles_user_default_name ON rtorrent_profiles(user_id, is_default, name COLLATE NOCASE)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS operation_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, profile_id INTEGER, event_type TEXT NOT NULL, severity TEXT DEFAULT 'info', source TEXT DEFAULT 'system', torrent_hash TEXT, torrent_name TEXT, action TEXT, message TEXT NOT NULL, details_json TEXT, created_at TEXT NOT NULL)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_operation_logs_profile_created ON operation_logs(profile_id, created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_operation_logs_user_profile_created ON operation_logs(user_id, profile_id, created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_operation_logs_event_type ON operation_logs(event_type, created_at)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS operation_log_settings (user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL DEFAULT 0, retention_mode TEXT DEFAULT 'days', retention_days INTEGER DEFAULT 30, retention_lines INTEGER DEFAULT 5000, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id))",
|
||||||
]
|
]
|
||||||
|
|
||||||
POST_MIGRATION_INDEXES = [
|
POST_MIGRATION_INDEXES = [
|
||||||
@@ -589,6 +625,8 @@ POST_MIGRATION_INDEXES = [
|
|||||||
"CREATE INDEX IF NOT EXISTS idx_jobs_status_heartbeat ON jobs(status, heartbeat_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_user_profile_created ON jobs(user_id, profile_id, created_at)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_jobs_profile_status_active ON jobs(profile_id, status)",
|
"CREATE INDEX IF NOT EXISTS idx_jobs_profile_status_active ON jobs(profile_id, status)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_operation_logs_profile_created ON operation_logs(profile_id, created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_operation_logs_user_profile_created ON operation_logs(user_id, profile_id, created_at)",
|
||||||
]
|
]
|
||||||
|
|
||||||
def utcnow() -> str:
|
def utcnow() -> str:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -19,10 +19,10 @@ import threading
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
from flask import Blueprint, jsonify, request, abort, send_file, redirect, Response, stream_with_context
|
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 ..config import DB_PATH, JOBS_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, LOG_RETENTION_DAYS, WORKERS, PYTORRENT_TMP_DIR
|
||||||
from ..db import connect, utcnow
|
from ..db import connect, utcnow
|
||||||
from ..services.auth import current_user_id as default_user_id, current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, require_profile_write
|
from ..services.auth import current_user_id as default_user_id, current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, require_profile_write
|
||||||
from ..services import preferences, rtorrent, torrent_stats, speed_peaks, tracker_cache, rss as rss_service, ratio_rules, backup as backup_service, download_planner
|
from ..services import preferences, rtorrent, torrent_stats, speed_peaks, tracker_cache, rss as rss_service, ratio_rules, backup as backup_service, download_planner, operation_logs
|
||||||
from ..services.torrent_cache import torrent_cache
|
from ..services.torrent_cache import torrent_cache
|
||||||
from ..services.torrent_summary import cached_summary
|
from ..services.torrent_summary import cached_summary
|
||||||
from ..services.workers import enqueue, list_jobs, cancel_job, retry_job, force_job, clear_jobs, emergency_clear_jobs
|
from ..services.workers import enqueue, list_jobs, cancel_job, retry_job, force_job, clear_jobs, emergency_clear_jobs
|
||||||
@@ -281,19 +281,33 @@ def _active_profile_cache_summary(profile_id: int | None = None) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def cleanup_summary() -> dict:
|
def cleanup_summary() -> dict:
|
||||||
|
active_profile = preferences.active_profile()
|
||||||
|
profile_id = int((active_profile or {}).get("id") or 0)
|
||||||
|
operation_logs_total = _table_count(
|
||||||
|
"operation_logs",
|
||||||
|
"WHERE profile_id=? OR profile_id IS NULL",
|
||||||
|
(profile_id,),
|
||||||
|
) if profile_id else _table_count("operation_logs")
|
||||||
|
operation_log_retention = operation_logs.get_settings(profile_id) if profile_id else operation_logs.get_settings(0)
|
||||||
return {
|
return {
|
||||||
"jobs_total": _table_count("jobs"),
|
"jobs_total": _table_count("jobs"),
|
||||||
"jobs_clearable": _table_count("jobs", "WHERE status NOT IN ('pending', 'running')"),
|
"jobs_clearable": _table_count("jobs", "WHERE status NOT IN ('pending', 'running')"),
|
||||||
"smart_queue_history_total": _table_count("smart_queue_history"),
|
"smart_queue_history_total": _table_count("smart_queue_history"),
|
||||||
|
"operation_logs_total": operation_logs_total,
|
||||||
"automation_history_total": _table_count("automation_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,
|
"planner_history_total": download_planner.history_count(profile_id) if profile_id else 0,
|
||||||
"cache": _active_profile_cache_summary(),
|
"cache": _active_profile_cache_summary(profile_id if profile_id else None),
|
||||||
"retention_days": {
|
"retention_days": {
|
||||||
"jobs": JOBS_RETENTION_DAYS,
|
"jobs": JOBS_RETENTION_DAYS,
|
||||||
"smart_queue_history": SMART_QUEUE_HISTORY_RETENTION_DAYS,
|
"smart_queue_history": SMART_QUEUE_HISTORY_RETENTION_DAYS,
|
||||||
|
"operation_logs": operation_log_retention.get("retention_days", LOG_RETENTION_DAYS),
|
||||||
"automation_history": SMART_QUEUE_HISTORY_RETENTION_DAYS,
|
"automation_history": SMART_QUEUE_HISTORY_RETENTION_DAYS,
|
||||||
"planner_history": SMART_QUEUE_HISTORY_RETENTION_DAYS,
|
"planner_history": SMART_QUEUE_HISTORY_RETENTION_DAYS,
|
||||||
},
|
},
|
||||||
|
"operation_log_retention": operation_log_retention,
|
||||||
|
"retention_labels": {
|
||||||
|
"operation_logs": operation_logs.retention_label(operation_log_retention),
|
||||||
|
},
|
||||||
"database": _db_size(),
|
"database": _db_size(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ from . import automations as _automations_routes
|
|||||||
from . import smart_queue as _smart_queue_routes
|
from . import smart_queue as _smart_queue_routes
|
||||||
from . import system as _system_routes
|
from . import system as _system_routes
|
||||||
from . import backup as _backup_routes
|
from . import backup as _backup_routes
|
||||||
|
from . import operation_logs as _operation_logs_routes
|
||||||
|
|
||||||
__all__ = ["bp"]
|
__all__ = ["bp"]
|
||||||
|
|||||||
57
pytorrent/routes/operation_logs.py
Normal file
57
pytorrent/routes/operation_logs.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ._shared import *
|
||||||
|
from ..services import operation_logs
|
||||||
|
|
||||||
|
|
||||||
|
def _active_profile_or_400():
|
||||||
|
profile = preferences.active_profile()
|
||||||
|
if not profile:
|
||||||
|
return None
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/operation-logs")
|
||||||
|
def operation_logs_list():
|
||||||
|
profile = _active_profile_or_400()
|
||||||
|
if not profile:
|
||||||
|
return ok({"logs": [], "total": 0, "stats": {}, "settings": operation_logs.get_settings(0), "error": "No profile"})
|
||||||
|
operation_logs.apply_retention(int(profile["id"]))
|
||||||
|
data = operation_logs.list_logs(
|
||||||
|
int(profile["id"]),
|
||||||
|
limit=int(request.args.get("limit") or 200),
|
||||||
|
offset=int(request.args.get("offset") or 0),
|
||||||
|
event_type=str(request.args.get("type") or "").strip(),
|
||||||
|
q=str(request.args.get("q") or "").strip(),
|
||||||
|
hide_jobs=str(request.args.get("hide_jobs") or "").lower() in {"1", "true", "yes", "on"},
|
||||||
|
)
|
||||||
|
data["stats"] = operation_logs.stats(int(profile["id"]))
|
||||||
|
data["settings"] = data["stats"].get("settings")
|
||||||
|
return ok(data)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/operation-logs/settings")
|
||||||
|
def operation_logs_settings_save():
|
||||||
|
profile = _active_profile_or_400()
|
||||||
|
if not profile:
|
||||||
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
|
settings = operation_logs.save_settings(int(profile["id"]), request.get_json(silent=True) or {})
|
||||||
|
result = operation_logs.apply_retention(int(profile["id"]))
|
||||||
|
return ok({"settings": settings, "retention": result})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/operation-logs/clear")
|
||||||
|
def operation_logs_clear():
|
||||||
|
profile = _active_profile_or_400()
|
||||||
|
if not profile:
|
||||||
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
|
event_type = str((request.get_json(silent=True) or {}).get("event_type") or "").strip()
|
||||||
|
return ok({"deleted": operation_logs.clear(int(profile["id"]), event_type=event_type)})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/operation-logs/apply-retention")
|
||||||
|
def operation_logs_apply_retention():
|
||||||
|
profile = _active_profile_or_400()
|
||||||
|
if not profile:
|
||||||
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
|
return ok(operation_logs.apply_retention(int(profile["id"])))
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ._shared import *
|
from ._shared import *
|
||||||
|
from ..services import operation_logs
|
||||||
|
|
||||||
@bp.get("/system/disk")
|
@bp.get("/system/disk")
|
||||||
def system_disk():
|
def system_disk():
|
||||||
@@ -208,6 +209,17 @@ def cleanup_smart_queue():
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/cleanup/operation-logs")
|
||||||
|
def cleanup_operation_logs():
|
||||||
|
profile = preferences.active_profile()
|
||||||
|
if not profile:
|
||||||
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
|
# Note: Operation log cleanup removes only profile-scoped log entries; torrents, jobs and settings stay intact.
|
||||||
|
deleted = operation_logs.clear(int(profile["id"]))
|
||||||
|
return ok({"deleted": deleted, "cleanup": cleanup_summary()})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/cleanup/planner")
|
@bp.post("/cleanup/planner")
|
||||||
def cleanup_planner():
|
def cleanup_planner():
|
||||||
profile = preferences.active_profile()
|
profile = preferences.active_profile()
|
||||||
@@ -236,7 +248,9 @@ def cleanup_automations():
|
|||||||
def cleanup_all():
|
def cleanup_all():
|
||||||
deleted_jobs = clear_jobs()
|
deleted_jobs = clear_jobs()
|
||||||
active_profile = preferences.active_profile()
|
active_profile = preferences.active_profile()
|
||||||
deleted_planner = download_planner.clear_history(int(active_profile["id"])) if active_profile else 0
|
active_profile_id = int(active_profile["id"]) if active_profile else 0
|
||||||
|
deleted_logs = operation_logs.clear(active_profile_id) if active_profile_id else 0
|
||||||
|
deleted_planner = download_planner.clear_history(active_profile_id) if active_profile_id else 0
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='smart_queue_history'").fetchone()
|
exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='smart_queue_history'").fetchone()
|
||||||
if not exists:
|
if not exists:
|
||||||
@@ -250,7 +264,7 @@ def cleanup_all():
|
|||||||
else:
|
else:
|
||||||
cur = conn.execute("DELETE FROM automation_history")
|
cur = conn.execute("DELETE FROM automation_history")
|
||||||
deleted_auto = int(cur.rowcount or 0)
|
deleted_auto = int(cur.rowcount or 0)
|
||||||
return ok({"deleted": {"jobs": deleted_jobs, "smart_queue_history": deleted_smart, "planner_history": deleted_planner, "automation_history": deleted_auto}, "cleanup": cleanup_summary()})
|
return ok({"deleted": {"jobs": deleted_jobs, "smart_queue_history": deleted_smart, "operation_logs": deleted_logs, "planner_history": deleted_planner, "automation_history": deleted_auto}, "cleanup": cleanup_summary()})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from ._shared import *
|
from ._shared import *
|
||||||
from ..services import torrent_creator
|
from ..services import torrent_creator
|
||||||
|
from ..services.reverse_dns import attach_reverse_dns
|
||||||
|
|
||||||
@bp.get("/torrents")
|
@bp.get("/torrents")
|
||||||
def torrents():
|
def torrents():
|
||||||
@@ -386,6 +387,10 @@ def torrent_peers(torrent_hash: str):
|
|||||||
peers = rtorrent.torrent_peers(profile, torrent_hash)
|
peers = rtorrent.torrent_peers(profile, torrent_hash)
|
||||||
for peer in peers:
|
for peer in peers:
|
||||||
peer.update(lookup_ip(peer.get("ip", "")))
|
peer.update(lookup_ip(peer.get("ip", "")))
|
||||||
|
prefs = preferences.get_preferences(profile_id=profile.get("id"))
|
||||||
|
if int(prefs.get("reverse_dns_enabled") or 0):
|
||||||
|
# Note: PTR hostnames are attached only when the user enables the lightweight cached resolver.
|
||||||
|
attach_reverse_dns(peers)
|
||||||
return ok({"peers": peers})
|
return ok({"peers": peers})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,33 @@ FLAG_ICONS_VERSION = "7.2.3"
|
|||||||
SWAGGER_UI_VERSION = "5"
|
SWAGGER_UI_VERSION = "5"
|
||||||
SOCKET_IO_VERSION = "4.7.5"
|
SOCKET_IO_VERSION = "4.7.5"
|
||||||
|
|
||||||
|
GOOGLE_FONT_FAMILIES = (
|
||||||
|
"DM Sans",
|
||||||
|
"Figtree",
|
||||||
|
"Geist",
|
||||||
|
"IBM Plex Sans",
|
||||||
|
"Inter",
|
||||||
|
"JetBrains Mono",
|
||||||
|
"Lato",
|
||||||
|
"Manrope",
|
||||||
|
"Montserrat",
|
||||||
|
"Nunito Sans",
|
||||||
|
"Open Sans",
|
||||||
|
"Poppins",
|
||||||
|
"Roboto",
|
||||||
|
"Source Sans 3",
|
||||||
|
)
|
||||||
|
GOOGLE_FONT_WEIGHTS = "400;500;600;700;800"
|
||||||
|
|
||||||
|
|
||||||
|
def google_fonts_css_url() -> str:
|
||||||
|
families = "&".join(
|
||||||
|
f"family={name.replace(' ', '+')}:wght@{GOOGLE_FONT_WEIGHTS}"
|
||||||
|
for name in GOOGLE_FONT_FAMILIES
|
||||||
|
)
|
||||||
|
return f"https://fonts.googleapis.com/css2?{families}&display=swap"
|
||||||
|
|
||||||
|
|
||||||
BOOTSTRAP_THEMES = (
|
BOOTSTRAP_THEMES = (
|
||||||
"default",
|
"default",
|
||||||
"flatly",
|
"flatly",
|
||||||
@@ -39,6 +66,10 @@ STATIC_ASSETS = {
|
|||||||
"local": f"{LIBS_STATIC_DIR}/flag-icons/{FLAG_ICONS_VERSION}/css/flag-icons.min.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",
|
"cdn": f"https://cdn.jsdelivr.net/gh/lipis/flag-icons@{FLAG_ICONS_VERSION}/css/flag-icons.min.css",
|
||||||
},
|
},
|
||||||
|
"font_css": {
|
||||||
|
"local": f"{LIBS_STATIC_DIR}/fonts/google-fonts.css",
|
||||||
|
"cdn": google_fonts_css_url(),
|
||||||
|
},
|
||||||
"socket_io_js": {
|
"socket_io_js": {
|
||||||
"local": f"{LIBS_STATIC_DIR}/socket.io/{SOCKET_IO_VERSION}/socket.io.min.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",
|
"cdn": f"https://cdn.socket.io/{SOCKET_IO_VERSION}/socket.io.min.js",
|
||||||
@@ -87,6 +118,7 @@ def missing_offline_paths() -> list[Path]:
|
|||||||
LIBS_DIR / f"fontawesome/{FONTAWESOME_VERSION}/webfonts",
|
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/4x3",
|
||||||
LIBS_DIR / f"flag-icons/{FLAG_ICONS_VERSION}/flags/1x1",
|
LIBS_DIR / f"flag-icons/{FLAG_ICONS_VERSION}/flags/1x1",
|
||||||
|
LIBS_DIR / "fonts/files",
|
||||||
]
|
]
|
||||||
for directory in required_dirs:
|
for directory in required_dirs:
|
||||||
if not directory.is_dir() or not any(directory.iterdir()):
|
if not directory.is_dir() or not any(directory.iterdir()):
|
||||||
|
|||||||
200
pytorrent/services/operation_logs.py
Normal file
200
pytorrent/services/operation_logs.py
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
from ..db import connect, utcnow, default_user_id
|
||||||
|
from . import auth, rtorrent
|
||||||
|
|
||||||
|
DEFAULT_SETTINGS = {"retention_mode": "days", "retention_days": 30, "retention_lines": 5000}
|
||||||
|
VALID_RETENTION_MODES = {"days", "lines", "both", "manual"}
|
||||||
|
|
||||||
|
|
||||||
|
def _user_id(user_id: int | None = None) -> int:
|
||||||
|
return int(user_id or auth.current_user_id() or default_user_id())
|
||||||
|
|
||||||
|
|
||||||
|
def _details(value: dict | None = None) -> str:
|
||||||
|
try:
|
||||||
|
return json.dumps(value or {}, ensure_ascii=False, sort_keys=True)
|
||||||
|
except Exception:
|
||||||
|
return "{}"
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_public(row: dict) -> dict:
|
||||||
|
item = dict(row)
|
||||||
|
try:
|
||||||
|
item["details"] = json.loads(item.get("details_json") or "{}")
|
||||||
|
except Exception:
|
||||||
|
item["details"] = {}
|
||||||
|
item["details_h"] = ", ".join(f"{k}: {v}" for k, v in item["details"].items() if v not in (None, ""))
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings(profile_id: int = 0, user_id: int | None = None) -> dict:
|
||||||
|
user_id = _user_id(user_id)
|
||||||
|
profile_id = int(profile_id or 0)
|
||||||
|
with connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM operation_log_settings WHERE user_id=? AND profile_id=?",
|
||||||
|
(user_id, profile_id),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return {"user_id": user_id, "profile_id": profile_id, **DEFAULT_SETTINGS}
|
||||||
|
data = {**DEFAULT_SETTINGS, **dict(row)}
|
||||||
|
data["retention_mode"] = data.get("retention_mode") if data.get("retention_mode") in VALID_RETENTION_MODES else "days"
|
||||||
|
data["retention_days"] = max(1, int(data.get("retention_days") or DEFAULT_SETTINGS["retention_days"]))
|
||||||
|
data["retention_lines"] = max(100, int(data.get("retention_lines") or DEFAULT_SETTINGS["retention_lines"]))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def save_settings(profile_id: int, data: dict, user_id: int | None = None) -> dict:
|
||||||
|
user_id = _user_id(user_id)
|
||||||
|
profile_id = int(profile_id or 0)
|
||||||
|
mode = str(data.get("retention_mode") or "days").lower()
|
||||||
|
if mode not in VALID_RETENTION_MODES:
|
||||||
|
mode = "days"
|
||||||
|
days = max(1, min(3650, int(data.get("retention_days") or DEFAULT_SETTINGS["retention_days"])))
|
||||||
|
lines = max(100, min(1_000_000, int(data.get("retention_lines") or DEFAULT_SETTINGS["retention_lines"])))
|
||||||
|
now = utcnow()
|
||||||
|
with connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO operation_log_settings(user_id, profile_id, retention_mode, retention_days, retention_lines, created_at, updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(user_id, profile_id) DO UPDATE SET
|
||||||
|
retention_mode=excluded.retention_mode,
|
||||||
|
retention_days=excluded.retention_days,
|
||||||
|
retention_lines=excluded.retention_lines,
|
||||||
|
updated_at=excluded.updated_at
|
||||||
|
""",
|
||||||
|
(user_id, profile_id, mode, days, lines, now, now),
|
||||||
|
)
|
||||||
|
return get_settings(profile_id, user_id)
|
||||||
|
|
||||||
|
|
||||||
|
def record(profile_id: int | None, event_type: str, message: str, *, severity: str = "info", source: str = "system", torrent_hash: str | None = None, torrent_name: str | None = None, action: str | None = None, details: dict | None = None, user_id: int | None = None) -> int:
|
||||||
|
now = utcnow()
|
||||||
|
user_id = _user_id(user_id)
|
||||||
|
with connect() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO operation_logs(user_id, profile_id, event_type, severity, source, torrent_hash, torrent_name, action, message, details_json, created_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
""",
|
||||||
|
(user_id, int(profile_id or 0) or None, str(event_type), str(severity or "info"), str(source or "system"), torrent_hash, torrent_name, action, str(message), _details(details), now),
|
||||||
|
)
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
|
|
||||||
|
def record_job_event(profile_id: int, action: str, status: str, payload: dict | None, result: dict | None = None, error: str = "", job_id: str | None = None, user_id: int | None = None) -> None:
|
||||||
|
payload = payload or {}
|
||||||
|
result = result or {}
|
||||||
|
hashes = payload.get("hashes") or []
|
||||||
|
ctx = payload.get("job_context") or {}
|
||||||
|
items = ctx.get("items") or []
|
||||||
|
by_hash = {str(item.get("hash")): item for item in items if item}
|
||||||
|
event_type = "job_done" if status == "done" else "job_failed" if status == "failed" else "job_started"
|
||||||
|
severity = "danger" if status == "failed" else "info"
|
||||||
|
if action in {"add_magnet", "add_torrent_raw"}:
|
||||||
|
name = str(payload.get("name") or payload.get("filename") or payload.get("uri") or "torrent")[:300]
|
||||||
|
msg = f"{action} {status}: {name}"
|
||||||
|
record(profile_id, "torrent_added" if status == "done" else event_type, msg, severity=severity, source="job", action=action, details={"job_id": job_id, "status": status, "directory": payload.get("directory"), "label": payload.get("label"), "error": error, "result": result}, user_id=user_id)
|
||||||
|
return
|
||||||
|
if not hashes:
|
||||||
|
record(profile_id, event_type, f"{action} {status}", severity=severity, source="job", action=action, details={"job_id": job_id, "status": status, "error": error, "result": result}, user_id=user_id)
|
||||||
|
return
|
||||||
|
for h in hashes:
|
||||||
|
item = by_hash.get(str(h)) or {}
|
||||||
|
name = str(item.get("name") or h)
|
||||||
|
record(profile_id, "torrent_removed" if action == "remove" and status == "done" else event_type, f"{action} {status}: {name}", severity=severity, source="job", torrent_hash=str(h), torrent_name=name, action=action, details={"job_id": job_id, "status": status, "error": error, "result": result, "target_path": ctx.get("target_path"), "remove_data": ctx.get("remove_data")}, user_id=user_id)
|
||||||
|
|
||||||
|
|
||||||
|
def record_cache_diff(profile_id: int, added: list[dict], removed: list[str], updated: list[dict], old_rows: dict[str, dict]) -> None:
|
||||||
|
for row in added or []:
|
||||||
|
record(profile_id, "torrent_added", f"Torrent added: {row.get('name') or row.get('hash')}", source="poller", torrent_hash=row.get("hash"), torrent_name=row.get("name"), details={"size": row.get("size"), "path": row.get("path"), "label": row.get("label")})
|
||||||
|
for h in removed or []:
|
||||||
|
old = old_rows.get(str(h)) or {}
|
||||||
|
record(profile_id, "torrent_removed", f"Torrent removed: {old.get('name') or h}", source="poller", torrent_hash=str(h), torrent_name=old.get("name"), details={"path": old.get("path"), "label": old.get("label")})
|
||||||
|
for patch in updated or []:
|
||||||
|
h = str(patch.get("hash") or "")
|
||||||
|
old = old_rows.get(h) or {}
|
||||||
|
was_complete = bool(old.get("complete")) or float(old.get("progress") or 0) >= 100
|
||||||
|
is_complete = bool(patch.get("complete", old.get("complete"))) or float(patch.get("progress", old.get("progress") or 0) or 0) >= 100
|
||||||
|
if h and not was_complete and is_complete:
|
||||||
|
record(profile_id, "torrent_completed", f"Torrent completed: {old.get('name') or h}", source="poller", torrent_hash=h, torrent_name=old.get("name"), details={"ratio": patch.get("ratio", old.get("ratio")), "size": old.get("size"), "path": old.get("path")})
|
||||||
|
|
||||||
|
|
||||||
|
def list_logs(profile_id: int, *, limit: int = 200, offset: int = 0, event_type: str = "", q: str = "", hide_jobs: bool = False) -> dict:
|
||||||
|
limit = max(1, min(int(limit or 200), 1000))
|
||||||
|
offset = max(0, int(offset or 0))
|
||||||
|
where = ["(profile_id=? OR profile_id IS NULL)"]
|
||||||
|
params: list[Any] = [int(profile_id or 0)]
|
||||||
|
if event_type:
|
||||||
|
where.append("event_type=?")
|
||||||
|
params.append(event_type)
|
||||||
|
if hide_jobs:
|
||||||
|
# Note: Job-originated rows include torrent_added/torrent_removed events, so source is the reliable filter.
|
||||||
|
where.append("COALESCE(source, '') <> 'job'")
|
||||||
|
if q:
|
||||||
|
where.append("(message LIKE ? OR torrent_name LIKE ? OR torrent_hash LIKE ? OR action LIKE ?)")
|
||||||
|
like = f"%{q}%"
|
||||||
|
params.extend([like, like, like, like])
|
||||||
|
sql_where = " WHERE " + " AND ".join(where)
|
||||||
|
with connect() as conn:
|
||||||
|
rows = conn.execute(f"SELECT * FROM operation_logs{sql_where} ORDER BY id DESC LIMIT ? OFFSET ?", (*params, limit, offset)).fetchall()
|
||||||
|
total = conn.execute(f"SELECT COUNT(*) AS n FROM operation_logs{sql_where}", tuple(params)).fetchone()["n"]
|
||||||
|
return {"logs": [_row_to_public(r) for r in rows], "total": int(total or 0), "limit": limit, "offset": offset}
|
||||||
|
|
||||||
|
|
||||||
|
def stats(profile_id: int) -> dict:
|
||||||
|
profile_id = int(profile_id or 0)
|
||||||
|
with connect() as conn:
|
||||||
|
total = conn.execute("SELECT COUNT(*) AS n FROM operation_logs WHERE profile_id=? OR profile_id IS NULL", (profile_id,)).fetchone()["n"]
|
||||||
|
by_type = conn.execute("SELECT event_type, COUNT(*) AS n FROM operation_logs WHERE profile_id=? OR profile_id IS NULL GROUP BY event_type ORDER BY n DESC LIMIT 12", (profile_id,)).fetchall()
|
||||||
|
by_day = conn.execute("SELECT substr(created_at,1,10) AS bucket, COUNT(*) AS n FROM operation_logs WHERE profile_id=? OR profile_id IS NULL GROUP BY bucket ORDER BY bucket DESC LIMIT 14", (profile_id,)).fetchall()
|
||||||
|
by_month = conn.execute("SELECT substr(created_at,1,7) AS bucket, COUNT(*) AS n FROM operation_logs WHERE profile_id=? OR profile_id IS NULL GROUP BY bucket ORDER BY bucket DESC LIMIT 12", (profile_id,)).fetchall()
|
||||||
|
top_actions = conn.execute("SELECT COALESCE(action, event_type) AS action, COUNT(*) AS n FROM operation_logs WHERE profile_id=? OR profile_id IS NULL GROUP BY COALESCE(action, event_type) ORDER BY n DESC LIMIT 12", (profile_id,)).fetchall()
|
||||||
|
return {"total": int(total or 0), "by_type": by_type, "by_day": by_day, "by_month": by_month, "top_actions": top_actions, "settings": get_settings(profile_id)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def retention_label(settings: dict) -> str:
|
||||||
|
mode = settings.get("retention_mode") or "days"
|
||||||
|
if mode == "manual":
|
||||||
|
return "manual cleanup only"
|
||||||
|
if mode == "lines":
|
||||||
|
return f"retention {settings.get('retention_lines') or DEFAULT_SETTINGS['retention_lines']} lines"
|
||||||
|
if mode == "both":
|
||||||
|
return f"retention {settings.get('retention_days') or DEFAULT_SETTINGS['retention_days']} days and {settings.get('retention_lines') or DEFAULT_SETTINGS['retention_lines']} lines"
|
||||||
|
return f"retention {settings.get('retention_days') or DEFAULT_SETTINGS['retention_days']} days"
|
||||||
|
|
||||||
|
def clear(profile_id: int, *, event_type: str = "") -> int:
|
||||||
|
where = ["(profile_id=? OR profile_id IS NULL)"]
|
||||||
|
params: list[Any] = [int(profile_id or 0)]
|
||||||
|
if event_type:
|
||||||
|
where.append("event_type=?")
|
||||||
|
params.append(event_type)
|
||||||
|
with connect() as conn:
|
||||||
|
cur = conn.execute("DELETE FROM operation_logs WHERE " + " AND ".join(where), tuple(params))
|
||||||
|
return int(cur.rowcount or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_retention(profile_id: int, user_id: int | None = None) -> dict:
|
||||||
|
settings = get_settings(profile_id, user_id)
|
||||||
|
mode = settings.get("retention_mode") or "manual"
|
||||||
|
deleted_days = 0
|
||||||
|
deleted_lines = 0
|
||||||
|
with connect() as conn:
|
||||||
|
if mode in {"days", "both"}:
|
||||||
|
cutoff = (datetime.now(timezone.utc) - timedelta(days=int(settings["retention_days"]))).isoformat(timespec="seconds")
|
||||||
|
cur = conn.execute("DELETE FROM operation_logs WHERE (profile_id=? OR profile_id IS NULL) AND created_at<?", (int(profile_id or 0), cutoff))
|
||||||
|
deleted_days = int(cur.rowcount or 0)
|
||||||
|
if mode in {"lines", "both"}:
|
||||||
|
keep = int(settings["retention_lines"])
|
||||||
|
ids = conn.execute("SELECT id FROM operation_logs WHERE profile_id=? OR profile_id IS NULL ORDER BY id DESC LIMIT -1 OFFSET ?", (int(profile_id or 0), keep)).fetchall()
|
||||||
|
if ids:
|
||||||
|
placeholders = ",".join("?" for _ in ids)
|
||||||
|
cur = conn.execute(f"DELETE FROM operation_logs WHERE id IN ({placeholders})", tuple(r["id"] for r in ids))
|
||||||
|
deleted_lines = int(cur.rowcount or 0)
|
||||||
|
return {"deleted_days": deleted_days, "deleted_lines": deleted_lines, "deleted": deleted_days + deleted_lines, "settings": settings}
|
||||||
@@ -20,11 +20,22 @@ BOOTSTRAP_THEMES = {
|
|||||||
|
|
||||||
FONT_FAMILIES = {
|
FONT_FAMILIES = {
|
||||||
"default": "Theme default",
|
"default": "Theme default",
|
||||||
"adwaita-mono": "Adwaita Mono",
|
"system-ui": "System UI / Apple-like",
|
||||||
|
"figtree": "Figtree",
|
||||||
"inter": "Inter",
|
"inter": "Inter",
|
||||||
"system-ui": "System UI",
|
"geist": "Geist",
|
||||||
|
"manrope": "Manrope",
|
||||||
|
"dm-sans": "DM Sans",
|
||||||
"source-sans-3": "Source Sans 3",
|
"source-sans-3": "Source Sans 3",
|
||||||
|
"open-sans": "Open Sans",
|
||||||
|
"roboto": "Roboto",
|
||||||
|
"lato": "Lato",
|
||||||
|
"nunito-sans": "Nunito Sans",
|
||||||
|
"poppins": "Poppins",
|
||||||
|
"montserrat": "Montserrat",
|
||||||
|
"ibm-plex-sans": "IBM Plex Sans",
|
||||||
"jetbrains-mono": "JetBrains Mono",
|
"jetbrains-mono": "JetBrains Mono",
|
||||||
|
"adwaita-mono": "Adwaita Mono",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Note: Backend owns the recommended torrent table layout so frontend builds do not duplicate presets.
|
# Note: Backend owns the recommended torrent table layout so frontend builds do not duplicate presets.
|
||||||
@@ -38,6 +49,9 @@ RECOMMENDED_TABLE_COLUMNS = {
|
|||||||
"created": False, "priority": False, "state": False, "active": False, "complete": False,
|
"created": False, "priority": False, "state": False, "active": False, "complete": False,
|
||||||
"hashing": False, "message": False, "hash": False,
|
"hashing": False, "message": False, "hash": False,
|
||||||
},
|
},
|
||||||
|
"mobileSortFilters": {
|
||||||
|
"seeds:-1": True, "up_rate:-1": True, "down_rate:-1": True, "progress:-1": True,
|
||||||
|
},
|
||||||
"mobileSmartFiltersEnabled": False,
|
"mobileSmartFiltersEnabled": False,
|
||||||
"widths": {
|
"widths": {
|
||||||
"select": 44, "name": 389, "status": 83, "size": 75, "progress": 177,
|
"select": 44, "name": 389, "status": 83, "size": 75, "progress": 177,
|
||||||
@@ -333,6 +347,7 @@ def save_preferences(data: dict, user_id: int | None = None):
|
|||||||
footer_items_json = data.get("footer_items_json")
|
footer_items_json = data.get("footer_items_json")
|
||||||
title_speed_enabled = data.get("title_speed_enabled")
|
title_speed_enabled = data.get("title_speed_enabled")
|
||||||
tracker_favicons_enabled = data.get("tracker_favicons_enabled")
|
tracker_favicons_enabled = data.get("tracker_favicons_enabled")
|
||||||
|
reverse_dns_enabled = data.get("reverse_dns_enabled")
|
||||||
automation_toasts_enabled = data.get("automation_toasts_enabled")
|
automation_toasts_enabled = data.get("automation_toasts_enabled")
|
||||||
smart_queue_toasts_enabled = data.get("smart_queue_toasts_enabled")
|
smart_queue_toasts_enabled = data.get("smart_queue_toasts_enabled")
|
||||||
disk_monitor_paths_json = data.get("disk_monitor_paths_json")
|
disk_monitor_paths_json = data.get("disk_monitor_paths_json")
|
||||||
@@ -373,6 +388,9 @@ def save_preferences(data: dict, user_id: int | None = None):
|
|||||||
conn.execute("UPDATE user_preferences SET title_speed_enabled=?, updated_at=? WHERE user_id=?", (1 if title_speed_enabled else 0, now, user_id))
|
conn.execute("UPDATE user_preferences SET title_speed_enabled=?, updated_at=? WHERE user_id=?", (1 if title_speed_enabled else 0, now, user_id))
|
||||||
if tracker_favicons_enabled is not None:
|
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))
|
conn.execute("UPDATE user_preferences SET tracker_favicons_enabled=?, updated_at=? WHERE user_id=?", (1 if tracker_favicons_enabled else 0, now, user_id))
|
||||||
|
if reverse_dns_enabled is not None:
|
||||||
|
# Note: Reverse DNS is optional because peer PTR lookups can add latency on busy swarms.
|
||||||
|
conn.execute("UPDATE user_preferences SET reverse_dns_enabled=?, updated_at=? WHERE user_id=?", (1 if reverse_dns_enabled else 0, now, user_id))
|
||||||
if automation_toasts_enabled is not None:
|
if automation_toasts_enabled is not None:
|
||||||
# Note: Lets users silence automation-created toast noise without hiding job/history data.
|
# Note: Lets users silence automation-created toast noise without hiding job/history data.
|
||||||
conn.execute("UPDATE user_preferences SET automation_toasts_enabled=?, updated_at=? WHERE user_id=?", (1 if automation_toasts_enabled else 0, now, user_id))
|
conn.execute("UPDATE user_preferences SET automation_toasts_enabled=?, updated_at=? WHERE user_id=?", (1 if automation_toasts_enabled else 0, now, user_id))
|
||||||
|
|||||||
99
pytorrent/services/reverse_dns.py
Normal file
99
pytorrent/services/reverse_dns.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, wait
|
||||||
|
from threading import Lock
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_CACHE_TTL_SECONDS = 24 * 60 * 60
|
||||||
|
_NEGATIVE_TTL_SECONDS = 60 * 60
|
||||||
|
_CACHE_LIMIT = 2048
|
||||||
|
_LOOKUP_LIMIT_PER_REQUEST = 24
|
||||||
|
_LOOKUP_TIMEOUT_SECONDS = 0.8
|
||||||
|
|
||||||
|
_cache: dict[str, tuple[str, float]] = {}
|
||||||
|
_pending: dict[str, Any] = {}
|
||||||
|
_lock = Lock()
|
||||||
|
_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="reverse-dns")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_resolvable_ip(value: str) -> bool:
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(str(value or "").strip())
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_host(ip: str) -> str:
|
||||||
|
try:
|
||||||
|
host = socket.gethostbyaddr(ip)[0]
|
||||||
|
return str(host or "").rstrip(".")
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _trim_cache(now: float) -> None:
|
||||||
|
expired = [ip for ip, (_, expires_at) in _cache.items() if expires_at <= now]
|
||||||
|
for ip in expired:
|
||||||
|
_cache.pop(ip, None)
|
||||||
|
if len(_cache) <= _CACHE_LIMIT:
|
||||||
|
return
|
||||||
|
for ip, _ in sorted(_cache.items(), key=lambda item: item[1][1])[: len(_cache) - _CACHE_LIMIT]:
|
||||||
|
_cache.pop(ip, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _store(ip: str, host: str, now: float | None = None) -> None:
|
||||||
|
now = now or time.monotonic()
|
||||||
|
ttl = _CACHE_TTL_SECONDS if host else _NEGATIVE_TTL_SECONDS
|
||||||
|
_cache[ip] = (host, now + ttl)
|
||||||
|
|
||||||
|
|
||||||
|
def attach_reverse_dns(peers: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""Attach cached or newly resolved PTR hostnames to peer rows with a small request budget."""
|
||||||
|
now = time.monotonic()
|
||||||
|
missing: list[str] = []
|
||||||
|
with _lock:
|
||||||
|
_trim_cache(now)
|
||||||
|
for peer in peers:
|
||||||
|
ip = str(peer.get("ip") or "").strip()
|
||||||
|
if not ip or not _is_resolvable_ip(ip):
|
||||||
|
peer["host"] = ""
|
||||||
|
continue
|
||||||
|
cached = _cache.get(ip)
|
||||||
|
if cached and cached[1] > now:
|
||||||
|
peer["host"] = cached[0]
|
||||||
|
continue
|
||||||
|
peer["host"] = ""
|
||||||
|
if ip not in _pending and ip not in missing and len(missing) < _LOOKUP_LIMIT_PER_REQUEST:
|
||||||
|
missing.append(ip)
|
||||||
|
for ip in missing:
|
||||||
|
_pending[ip] = _executor.submit(_lookup_host, ip)
|
||||||
|
futures = list(_pending.items())
|
||||||
|
|
||||||
|
if futures:
|
||||||
|
wait([future for _, future in futures], timeout=_LOOKUP_TIMEOUT_SECONDS)
|
||||||
|
|
||||||
|
done_hosts: dict[str, str] = {}
|
||||||
|
with _lock:
|
||||||
|
now = time.monotonic()
|
||||||
|
for ip, future in list(_pending.items()):
|
||||||
|
if not future.done():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
host = str(future.result() or "")
|
||||||
|
except Exception:
|
||||||
|
host = ""
|
||||||
|
_store(ip, host, now)
|
||||||
|
done_hosts[ip] = host
|
||||||
|
_pending.pop(ip, None)
|
||||||
|
|
||||||
|
for peer in peers:
|
||||||
|
ip = str(peer.get("ip") or "").strip()
|
||||||
|
if ip in done_hosts:
|
||||||
|
peer["host"] = done_hosts[ip]
|
||||||
|
elif not peer.get("host") and ip in _pending:
|
||||||
|
peer["host_pending"] = True
|
||||||
|
return peers
|
||||||
@@ -311,6 +311,33 @@ def count_history(profile_id: int, user_id: int | None = None) -> int:
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
return int((row or {}).get('count') or 0)
|
return int((row or {}).get('count') or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_history_event(profile_id: int, user_id: int | None = None) -> str:
|
||||||
|
"""Return the newest Smart Queue history event for duplicate suppression."""
|
||||||
|
# Note: Disabled Smart Queue should leave one waiting marker, not a poller-generated log stream.
|
||||||
|
user_id = user_id or default_user_id()
|
||||||
|
with connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
'SELECT event FROM smart_queue_history WHERE user_id=? AND profile_id=? ORDER BY created_at DESC LIMIT 1',
|
||||||
|
(user_id, profile_id),
|
||||||
|
).fetchone()
|
||||||
|
return str((row or {}).get('event') or '')
|
||||||
|
|
||||||
|
|
||||||
|
def _record_disabled_waiting_once(profile_id: int, user_id: int, details: dict[str, Any] | None = None) -> bool:
|
||||||
|
"""Record one disabled-state history row until Smart Queue runs or changes state again."""
|
||||||
|
# Note: This keeps the UI audit trail useful without creating repeated disabled logs on every poll.
|
||||||
|
if _latest_history_event(profile_id, user_id) in {'disabled_waiting_start', 'auto_stopped_idle'}:
|
||||||
|
return False
|
||||||
|
payload = {
|
||||||
|
'decision': 'Smart Queue disabled, waiting for start',
|
||||||
|
'enabled': False,
|
||||||
|
**(details or {}),
|
||||||
|
}
|
||||||
|
add_history(profile_id, 'disabled_waiting_start', [], [], 0, payload, user_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _excluded_hashes(profile_id: int, user_id: int) -> set[str]:
|
def _excluded_hashes(profile_id: int, user_id: int) -> set[str]:
|
||||||
return {r['torrent_hash'] for r in list_exclusions(profile_id, user_id)}
|
return {r['torrent_hash'] for r in list_exclusions(profile_id, user_id)}
|
||||||
|
|
||||||
@@ -1105,8 +1132,9 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
|
|||||||
restored = _cleanup_auto_labels(rtorrent.client_for(profile), profile_id, torrents, set(), True)
|
restored = _cleanup_auto_labels(rtorrent.client_for(profile), profile_id, torrents, set(), True)
|
||||||
except Exception:
|
except Exception:
|
||||||
restored = []
|
restored = []
|
||||||
add_history(profile_id, 'skipped_disabled', [], [], 0, {'enabled': False, 'labels_restored': restored}, user_id)
|
# Note: Disabled checks are frequent poller passes; record only the first waiting-state row.
|
||||||
return {'ok': True, 'enabled': False, 'paused': [], 'resumed': [], 'stopped': [], 'started': [], 'labels_restored': restored, 'message': 'Smart Queue disabled'}
|
disabled_log_recorded = _record_disabled_waiting_once(profile_id, user_id, {'labels_restored': restored})
|
||||||
|
return {'ok': True, 'enabled': False, 'paused': [], 'resumed': [], 'stopped': [], 'started': [], 'labels_restored': restored, 'disabled_log_recorded': disabled_log_recorded, 'message': 'Smart Queue disabled, waiting for start'}
|
||||||
|
|
||||||
torrents = rtorrent.list_torrents(profile)
|
torrents = rtorrent.list_torrents(profile)
|
||||||
# Note: Stalled labels block automatic starting only; a manually started Stalled item still counts as a running slot.
|
# Note: Stalled labels block automatic starting only; a manually started Stalled item still counts as a running slot.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from threading import RLock
|
from threading import RLock
|
||||||
from time import time
|
from time import time
|
||||||
from . import rtorrent
|
from . import rtorrent, operation_logs
|
||||||
|
|
||||||
_VOLATILE = {"down_rate", "down_rate_h", "up_rate", "up_rate_h", "progress", "completed_bytes", "peers", "seeds", "ratio", "state", "status", "message", "down_total", "down_total_h", "to_download", "to_download_h", "up_total", "up_total_h"}
|
_VOLATILE = {"down_rate", "down_rate_h", "up_rate", "up_rate_h", "progress", "completed_bytes", "peers", "seeds", "ratio", "state", "status", "message", "down_total", "down_total_h", "to_download", "to_download_h", "up_total", "up_total_h"}
|
||||||
|
|
||||||
@@ -58,6 +58,8 @@ class TorrentCache:
|
|||||||
self._data[profile_id] = fresh
|
self._data[profile_id] = fresh
|
||||||
self._errors[profile_id] = ""
|
self._errors[profile_id] = ""
|
||||||
self._updated_at[profile_id] = time()
|
self._updated_at[profile_id] = time()
|
||||||
|
if old:
|
||||||
|
operation_logs.record_cache_diff(profile_id, added, removed, updated, old)
|
||||||
return {"ok": True, "profile_id": profile_id, "added": added, "updated": updated, "removed": removed, "post_check_changes": post_check_changes}
|
return {"ok": True, "profile_id": profile_id, "added": added, "updated": updated, "removed": removed, "post_check_changes": post_check_changes}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from . import rtorrent, auth, disk_guard
|
from . import rtorrent, auth, disk_guard, operation_logs
|
||||||
from .preferences import get_profile
|
from .preferences import get_profile
|
||||||
from ..config import WORKERS
|
from ..config import WORKERS
|
||||||
from ..db import connect, utcnow, default_user_id
|
from ..db import connect, utcnow, default_user_id
|
||||||
@@ -300,6 +300,7 @@ def _run(job_id: str):
|
|||||||
if not _mark_running(job_id, attempts):
|
if not _mark_running(job_id, attempts):
|
||||||
return
|
return
|
||||||
event_meta = _job_event_meta(payload)
|
event_meta = _job_event_meta(payload)
|
||||||
|
operation_logs.record_job_event(profile["id"], job["action"], "started", payload, job_id=job_id, user_id=int(job.get("user_id") or 0))
|
||||||
_emit("operation_started", {"job_id": job_id, "action": job["action"], "profile_id": profile["id"], "hashes": payload.get("hashes") or [], "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1, **event_meta})
|
_emit("operation_started", {"job_id": job_id, "action": job["action"], "profile_id": profile["id"], "hashes": payload.get("hashes") or [], "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1, **event_meta})
|
||||||
_emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "running", "attempts": attempts})
|
_emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "running", "attempts": attempts})
|
||||||
result = _execute(profile, job["action"], payload)
|
result = _execute(profile, job["action"], payload)
|
||||||
@@ -308,6 +309,7 @@ def _run(job_id: str):
|
|||||||
if fresh and fresh["status"] != "running":
|
if fresh and fresh["status"] != "running":
|
||||||
return
|
return
|
||||||
_set_job(job_id, "done", result=result, finished=True)
|
_set_job(job_id, "done", result=result, finished=True)
|
||||||
|
operation_logs.record_job_event(profile["id"], job["action"], "done", payload, result=result or {}, job_id=job_id, user_id=int(job.get("user_id") or 0))
|
||||||
_emit("operation_finished", {"job_id": job_id, "action": job["action"], "profile_id": profile["id"], "hashes": payload.get("hashes") or [], "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1, "result": result, **event_meta})
|
_emit("operation_finished", {"job_id": job_id, "action": job["action"], "profile_id": profile["id"], "hashes": payload.get("hashes") or [], "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1, "result": result, **event_meta})
|
||||||
_emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "done", "result": result})
|
_emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "done", "result": result})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -319,6 +321,8 @@ def _run(job_id: str):
|
|||||||
return
|
return
|
||||||
status = "pending" if attempts < max_attempts else "failed"
|
status = "pending" if attempts < max_attempts else "failed"
|
||||||
_set_job(job_id, status, str(exc), finished=(status == "failed"))
|
_set_job(job_id, status, str(exc), finished=(status == "failed"))
|
||||||
|
if status == "failed":
|
||||||
|
operation_logs.record_job_event(int(job.get("profile_id") or 0), job.get("action"), "failed", payload, error=str(exc), job_id=job_id, user_id=int(job.get("user_id") or 0))
|
||||||
_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("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})
|
_emit("job_update", {"id": job_id, "profile_id": job.get("profile_id"), "status": status, "error": str(exc), "attempts": attempts})
|
||||||
if status == "pending":
|
if status == "pending":
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { pollerSource } from './poller.js';
|
|||||||
import { profilesSource } from './profiles.js';
|
import { profilesSource } from './profiles.js';
|
||||||
import { dashboardSource } from './dashboard.js';
|
import { dashboardSource } from './dashboard.js';
|
||||||
import { chartsSource } from './charts.js';
|
import { chartsSource } from './charts.js';
|
||||||
|
import { operationLogsSource } from './operationLogs.js';
|
||||||
import { bootstrapSource } from './bootstrap.js';
|
import { bootstrapSource } from './bootstrap.js';
|
||||||
|
|
||||||
export const moduleSources = [
|
export const moduleSources = [
|
||||||
@@ -30,6 +31,7 @@ export const moduleSources = [
|
|||||||
smartQueueSource,
|
smartQueueSource,
|
||||||
plannerSource,
|
plannerSource,
|
||||||
dashboardSource,
|
dashboardSource,
|
||||||
|
operationLogsSource,
|
||||||
pollerSource,
|
pollerSource,
|
||||||
profilesSource,
|
profilesSource,
|
||||||
chartsSource,
|
chartsSource,
|
||||||
|
|||||||
2
pytorrent/static/js/bootstrap.js
vendored
2
pytorrent/static/js/bootstrap.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
pytorrent/static/js/operationLogs.js
Normal file
1
pytorrent/static/js/operationLogs.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -33,13 +33,66 @@ html[data-app-font="inter"] {
|
|||||||
}
|
}
|
||||||
html[data-app-font="system-ui"] {
|
html[data-app-font="system-ui"] {
|
||||||
--app-font-family:
|
--app-font-family:
|
||||||
system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
|
system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI",
|
||||||
|
Roboto, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
html[data-app-font="figtree"] {
|
||||||
|
--app-font-family:
|
||||||
|
Figtree, Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
html[data-app-font="geist"] {
|
||||||
|
--app-font-family:
|
||||||
|
Geist, Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
html[data-app-font="manrope"] {
|
||||||
|
--app-font-family:
|
||||||
|
Manrope, Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
html[data-app-font="dm-sans"] {
|
||||||
|
--app-font-family:
|
||||||
|
"DM Sans", Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial,
|
||||||
|
sans-serif;
|
||||||
}
|
}
|
||||||
html[data-app-font="source-sans-3"] {
|
html[data-app-font="source-sans-3"] {
|
||||||
--app-font-family:
|
--app-font-family:
|
||||||
"Source Sans 3", "Source Sans Pro", system-ui, -apple-system, Segoe UI,
|
"Source Sans 3", "Source Sans Pro", system-ui, -apple-system, Segoe UI,
|
||||||
Roboto, Arial, sans-serif;
|
Roboto, Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
html[data-app-font="open-sans"] {
|
||||||
|
--app-font-family:
|
||||||
|
"Open Sans", system-ui, -apple-system, "Segoe UI", Roboto, Arial,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
html[data-app-font="roboto"] {
|
||||||
|
--app-font-family:
|
||||||
|
Roboto, system-ui, -apple-system, "Segoe UI", Arial, sans-serif;
|
||||||
|
}
|
||||||
|
html[data-app-font="lato"] {
|
||||||
|
--app-font-family:
|
||||||
|
Lato, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
html[data-app-font="nunito-sans"] {
|
||||||
|
--app-font-family:
|
||||||
|
"Nunito Sans", system-ui, -apple-system, "Segoe UI", Roboto, Arial,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
html[data-app-font="poppins"] {
|
||||||
|
--app-font-family:
|
||||||
|
Poppins, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
html[data-app-font="montserrat"] {
|
||||||
|
--app-font-family:
|
||||||
|
Montserrat, system-ui, -apple-system, "Segoe UI", Roboto, Arial,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
html[data-app-font="ibm-plex-sans"] {
|
||||||
|
--app-font-family:
|
||||||
|
"IBM Plex Sans", system-ui, -apple-system, "Segoe UI", Roboto, Arial,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
html[data-app-font="jetbrains-mono"] {
|
html[data-app-font="jetbrains-mono"] {
|
||||||
--app-font-family:
|
--app-font-family:
|
||||||
"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas,
|
"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas,
|
||||||
@@ -466,6 +519,18 @@ body.resizing-details {
|
|||||||
.muted-pane {
|
.muted-pane {
|
||||||
color: var(--bs-secondary-color);
|
color: var(--bs-secondary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.torrent-log-message {
|
||||||
|
background: var(--bs-tertiary-bg);
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
min-height: 4.25rem;
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
.detail-table {
|
.detail-table {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -994,6 +1059,7 @@ body.mobile-mode .main-grid {
|
|||||||
.mobile-sort-row .btn {
|
.mobile-sort-row .btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
.view-preferences-note {
|
.view-preferences-note {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3337,9 +3403,21 @@ body.mobile-mode .mobile-filter-bar {
|
|||||||
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
}
|
}
|
||||||
.mobile-sort-row .btn {
|
|
||||||
pointer-events: auto;
|
.column-config-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.column-config-section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-config-section h6 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-progress:empty {
|
.mobile-progress:empty {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -4164,3 +4242,150 @@ body,
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Operation logs */
|
||||||
|
.operation-log-toolbar,
|
||||||
|
.operation-log-toolbar-main,
|
||||||
|
.operation-log-settings-grid,
|
||||||
|
.operation-log-view-settings {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .5rem;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-toolbar {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-toolbar-main {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-toolbar-toggle {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-view-settings {
|
||||||
|
align-items: center;
|
||||||
|
border-top: 1px solid var(--bs-border-color);
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-view-settings > div:first-child {
|
||||||
|
flex: 1 1 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-view-settings small {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-type-filter {
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-search {
|
||||||
|
max-width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-hide-jobs {
|
||||||
|
align-items: center;
|
||||||
|
min-height: 31px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-settings-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||||
|
gap: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-stat,
|
||||||
|
.operation-log-panels section {
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: .75rem;
|
||||||
|
padding: .75rem;
|
||||||
|
background: var(--bs-body-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-stat span {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-panels {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: .75rem;
|
||||||
|
margin-top: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: .25rem 0;
|
||||||
|
border-bottom: 1px solid var(--bs-border-color-translucent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-table td {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.operation-log-type-filter,
|
||||||
|
.operation-log-search {
|
||||||
|
max-width: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-toolbar,
|
||||||
|
.operation-log-toolbar-main,
|
||||||
|
.operation-log-view-settings {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-toolbar-toggle {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-toolbar-main > .btn,
|
||||||
|
.operation-log-settings-actions > .btn,
|
||||||
|
.operation-log-view-settings > .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.peers-table {
|
||||||
|
table-layout: auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peers-table .peer-progress-wide {
|
||||||
|
min-width: 108px;
|
||||||
|
width: clamp(108px, 12vw, 126px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-host {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -8,6 +8,7 @@
|
|||||||
<link rel="shortcut icon" href="{{ static_url('favicon.svg') }}" type="image/svg+xml">
|
<link rel="shortcut icon" href="{{ static_url('favicon.svg') }}" type="image/svg+xml">
|
||||||
<link href="{{ bootstrap_theme_url('default') }}" rel="stylesheet">
|
<link href="{{ bootstrap_theme_url('default') }}" rel="stylesheet">
|
||||||
<link href="{{ frontend_asset_url('fontawesome_css') }}" rel="stylesheet">
|
<link href="{{ frontend_asset_url('fontawesome_css') }}" rel="stylesheet">
|
||||||
|
<link href="{{ frontend_asset_url('font_css') }}" rel="stylesheet">
|
||||||
<link href="{{ static_url('styles.css') }}" rel="stylesheet">
|
<link href="{{ static_url('styles.css') }}" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body class="auth-page">
|
<body class="auth-page">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin, urlparse
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
@@ -14,6 +14,33 @@ FONTAWESOME_VERSION = "6.5.2"
|
|||||||
FLAG_ICONS_VERSION = "7.2.3"
|
FLAG_ICONS_VERSION = "7.2.3"
|
||||||
SWAGGER_UI_VERSION = "5"
|
SWAGGER_UI_VERSION = "5"
|
||||||
SOCKET_IO_VERSION = "4.7.5"
|
SOCKET_IO_VERSION = "4.7.5"
|
||||||
|
GOOGLE_FONT_FAMILIES = (
|
||||||
|
"DM Sans",
|
||||||
|
"Figtree",
|
||||||
|
"Geist",
|
||||||
|
"IBM Plex Sans",
|
||||||
|
"Inter",
|
||||||
|
"JetBrains Mono",
|
||||||
|
"Lato",
|
||||||
|
"Manrope",
|
||||||
|
"Montserrat",
|
||||||
|
"Nunito Sans",
|
||||||
|
"Open Sans",
|
||||||
|
"Poppins",
|
||||||
|
"Roboto",
|
||||||
|
"Source Sans 3",
|
||||||
|
)
|
||||||
|
GOOGLE_FONT_WEIGHTS = "400;500;600;700;800"
|
||||||
|
|
||||||
|
|
||||||
|
def google_fonts_css_url() -> str:
|
||||||
|
families = "&".join(
|
||||||
|
f"family={name.replace(' ', '+')}:wght@{GOOGLE_FONT_WEIGHTS}"
|
||||||
|
for name in GOOGLE_FONT_FAMILIES
|
||||||
|
)
|
||||||
|
return f"https://fonts.googleapis.com/css2?{families}&display=swap"
|
||||||
|
|
||||||
|
|
||||||
BOOTSTRAP_THEMES = (
|
BOOTSTRAP_THEMES = (
|
||||||
"default",
|
"default",
|
||||||
"flatly",
|
"flatly",
|
||||||
@@ -43,6 +70,10 @@ STATIC_ASSETS = {
|
|||||||
"local": f"{LIBS_STATIC_DIR}/flag-icons/{FLAG_ICONS_VERSION}/css/flag-icons.min.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",
|
"cdn": f"https://cdn.jsdelivr.net/gh/lipis/flag-icons@{FLAG_ICONS_VERSION}/css/flag-icons.min.css",
|
||||||
},
|
},
|
||||||
|
"font_css": {
|
||||||
|
"local": f"{LIBS_STATIC_DIR}/fonts/google-fonts.css",
|
||||||
|
"cdn": google_fonts_css_url(),
|
||||||
|
},
|
||||||
"swagger_css": {
|
"swagger_css": {
|
||||||
"local": f"{LIBS_STATIC_DIR}/swagger-ui/{SWAGGER_UI_VERSION}/swagger-ui.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",
|
"cdn": f"https://cdn.jsdelivr.net/npm/swagger-ui-dist@{SWAGGER_UI_VERSION}/swagger-ui.css",
|
||||||
@@ -53,6 +84,7 @@ STATIC_ASSETS = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
URL_RE = re.compile(r"url\((['\"]?)(?!data:)(?!https?:)([^)'\"]+)\1\)")
|
URL_RE = re.compile(r"url\((['\"]?)(?!data:)(?!https?:)([^)'\"]+)\1\)")
|
||||||
|
ANY_URL_RE = re.compile(r"url\((['\"]?)(?!data:)([^)'\"]+)\1\)")
|
||||||
|
|
||||||
|
|
||||||
def bootstrap_css_asset(theme: str) -> dict[str, str]:
|
def bootstrap_css_asset(theme: str) -> dict[str, str]:
|
||||||
@@ -97,13 +129,49 @@ def download_css_with_assets(url: str, dest: Path) -> None:
|
|||||||
download(asset_url, asset_dest)
|
download(asset_url, asset_dest)
|
||||||
|
|
||||||
|
|
||||||
|
def download_google_fonts_css(url: str, dest: Path) -> None:
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
req = Request(
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
"User-Agent": "Mozilla/5.0 pyTorrent installer",
|
||||||
|
"Accept": "text/css,*/*;q=0.1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with urlopen(req, timeout=60) as response:
|
||||||
|
css = response.read().decode("utf-8", errors="ignore")
|
||||||
|
if not css.strip():
|
||||||
|
raise RuntimeError(f"Empty response for {url}")
|
||||||
|
|
||||||
|
def replace_url(match: re.Match[str]) -> str:
|
||||||
|
quote = match.group(1) or ""
|
||||||
|
asset_url = match.group(2)
|
||||||
|
parsed = urlparse(asset_url)
|
||||||
|
if parsed.scheme not in {"http", "https"}:
|
||||||
|
return match.group(0)
|
||||||
|
filename = Path(parsed.path).name
|
||||||
|
if not filename:
|
||||||
|
return match.group(0)
|
||||||
|
asset_dest = dest.parent / "files" / filename
|
||||||
|
if not asset_dest.exists():
|
||||||
|
download(asset_url, asset_dest)
|
||||||
|
return f"url({quote}files/{filename}{quote})"
|
||||||
|
|
||||||
|
rewritten = ANY_URL_RE.sub(replace_url, css)
|
||||||
|
tmp = dest.with_suffix(dest.suffix + ".tmp")
|
||||||
|
tmp.write_text(rewritten, encoding="utf-8")
|
||||||
|
tmp.replace(dest)
|
||||||
|
print(f"OK {dest.relative_to(ROOT)}")
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
items = list(STATIC_ASSETS.values())
|
items = list(STATIC_ASSETS.values())
|
||||||
items.extend(bootstrap_css_asset(theme) for theme in BOOTSTRAP_THEMES)
|
items.extend(bootstrap_css_asset(theme) for theme in BOOTSTRAP_THEMES)
|
||||||
for item in items:
|
for item in items:
|
||||||
url = item["cdn"]
|
url = item["cdn"]
|
||||||
dest = ROOT / "pytorrent" / "static" / item["local"]
|
dest = ROOT / "pytorrent" / "static" / item["local"]
|
||||||
if dest.suffix == ".css":
|
if item.get("local") == STATIC_ASSETS["font_css"]["local"]:
|
||||||
|
download_google_fonts_css(url, dest)
|
||||||
|
elif dest.suffix == ".css":
|
||||||
download_css_with_assets(url, dest)
|
download_css_with_assets(url, dest)
|
||||||
else:
|
else:
|
||||||
download(url, dest)
|
download(url, dest)
|
||||||
|
|||||||
Reference in New Issue
Block a user