Labels visual #23
+2
-1
@@ -40,8 +40,9 @@ data/logs/*
|
|||||||
!data/logs/
|
!data/logs/
|
||||||
!data/logs/README.md
|
!data/logs/README.md
|
||||||
|
|
||||||
|
|
||||||
todo.txt
|
todo.txt
|
||||||
!pytorrent/static/libs/pytorrent-themes/
|
!pytorrent/static/libs/pytorrent-themes/
|
||||||
!pytorrent/static/libs/pytorrent-themes/**
|
!pytorrent/static/libs/pytorrent-themes/**
|
||||||
|
*/static/libs/
|
||||||
smart_queue_scoring_todo.md
|
smart_queue_scoring_todo.md
|
||||||
|
data/mock_rtorrent_state.json
|
||||||
@@ -4,6 +4,45 @@ Modern single-page web UI for managing rTorrent through SCGI/XML-RPC. pyTorrent
|
|||||||
|
|
||||||
> pyTorrent is a controller for your own rTorrent instance. It does not include a BitTorrent engine and does not bypass tracker, copyright or network rules.
|
> pyTorrent is a controller for your own rTorrent instance. It does not include a BitTorrent engine and does not bypass tracker, copyright or network rules.
|
||||||
|
|
||||||
|
## Install pyTorrent only - recommended first path
|
||||||
|
|
||||||
|
Use this when rTorrent already exists and only the pyTorrent web UI should be installed. The installer creates the pyTorrent service, virtualenv, `.env`, database and a default rTorrent profile. It does **not** install or reconfigure rTorrent.
|
||||||
|
|
||||||
|
Supported systems for `scripts/install_pytorrent_only.sh`:
|
||||||
|
|
||||||
|
- Debian / Ubuntu
|
||||||
|
- RHEL-compatible distributions: RHEL, Rocky Linux, AlmaLinux, CentOS Stream and Fedora-like systems with `dnf` or `yum`
|
||||||
|
- Arch Linux
|
||||||
|
|
||||||
|
One-line install from the repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/zdzichu6969/pyTorrent/master/scripts/install_pytorrent.sh | sudo bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Local install after cloning:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/zdzichu6969/pyTorrent.git
|
||||||
|
cd pyTorrent
|
||||||
|
sudo bash scripts/install_pytorrent_only.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Non-interactive example for an existing rTorrent SCGI endpoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash scripts/install_pytorrent_only.sh \
|
||||||
|
--yes \
|
||||||
|
--port 8090 \
|
||||||
|
--scgi-url scgi://127.0.0.1:5000 \
|
||||||
|
--auth enable \
|
||||||
|
--auth-provider local \
|
||||||
|
--auth-user pytorrent \
|
||||||
|
--auth-password 'change-this-password'
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional full stack install is described below. Use it only when the server should install and configure rTorrent together with pyTorrent.
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
- Live torrent table with WebSocket updates and patch-based refreshes.
|
- Live torrent table with WebSocket updates and patch-based refreshes.
|
||||||
@@ -36,9 +75,9 @@ Modern single-page web UI for managing rTorrent through SCGI/XML-RPC. pyTorrent
|
|||||||
|
|
||||||
The project uses Flask, Flask-SocketIO, python-dotenv, psutil, geoip2, gunicorn and related runtime dependencies listed in `requirements.txt`.
|
The project uses Flask, Flask-SocketIO, python-dotenv, psutil, geoip2, gunicorn and related runtime dependencies listed in `requirements.txt`.
|
||||||
|
|
||||||
## Quick start
|
## Manual development quick start
|
||||||
|
|
||||||
Clone the repository and run the local installer:
|
Clone the repository and run the local development installer:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/zdzichu6969/pyTorrent.git
|
git clone https://github.com/zdzichu6969/pyTorrent.git
|
||||||
@@ -76,9 +115,9 @@ network.scgi.open_port = 127.0.0.1:5000
|
|||||||
|
|
||||||
For production, keep SCGI bound to localhost or a private trusted network only.
|
For production, keep SCGI bound to localhost or a private trusted network only.
|
||||||
|
|
||||||
## Stack installer
|
## Optional stack installer
|
||||||
|
|
||||||
The repository includes a stack installer for a clean server. It can install and configure rTorrent + pyTorrent together.
|
The repository also includes a stack installer for a clean server. It can install and configure rTorrent + pyTorrent together.
|
||||||
|
|
||||||
Supported systems:
|
Supported systems:
|
||||||
|
|
||||||
@@ -103,10 +142,10 @@ The default stack install creates:
|
|||||||
| pyTorrent HTTP port | `8090` |
|
| pyTorrent HTTP port | `8090` |
|
||||||
| pyTorrent service | `pytorrent` |
|
| pyTorrent service | `pytorrent` |
|
||||||
|
|
||||||
### One-line install with rtorrent
|
### Optional one-line full stack install with rTorrent
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/zdzichu6969/pyTorrent/main/scripts/install_stack.sh \
|
curl -fsSL https://raw.githubusercontent.com/zdzichu6969/pyTorrent/master/scripts/install_stack.sh \
|
||||||
| PYTORRENT_PORT=8091 \
|
| PYTORRENT_PORT=8091 \
|
||||||
RTORRENT_SCGI_PORT=5001 \
|
RTORRENT_SCGI_PORT=5001 \
|
||||||
PYTORRENT_PROFILE_NAME="Local rTorrent" \
|
PYTORRENT_PROFILE_NAME="Local rTorrent" \
|
||||||
|
|||||||
+1
-1
@@ -108,5 +108,5 @@ LOG_ENABLE = _env_bool("PYTORRENT_LOG_ENABLE", True)
|
|||||||
LOG_DIR = Path(os.getenv("PYTORRENT_LOG_DIR", "data/logs"))
|
LOG_DIR = Path(os.getenv("PYTORRENT_LOG_DIR", "data/logs"))
|
||||||
if not LOG_DIR.is_absolute():
|
if not LOG_DIR.is_absolute():
|
||||||
LOG_DIR = BASE_DIR / LOG_DIR
|
LOG_DIR = BASE_DIR / LOG_DIR
|
||||||
SMART_QUEUE_LABEL = os.getenv("PYTORRENT_SMART_QUEUE_L.ABEL", "Smart Queue Stopped")
|
SMART_QUEUE_LABEL = os.getenv("PYTORRENT_SMART_QUEUE_LABEL", os.getenv("PYTORRENT_SMART_QUEUE_L.ABEL", "Smart Queue Stopped"))
|
||||||
SMART_QUEUE_STALLED_LABEL = os.getenv("PYTORRENT_SMART_QUEUE_STALLED_LABEL", "Stalled")
|
SMART_QUEUE_STALLED_LABEL = os.getenv("PYTORRENT_SMART_QUEUE_STALLED_LABEL", "Stalled")
|
||||||
|
|||||||
+6
-55
@@ -4,6 +4,7 @@ import sqlite3
|
|||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from .config import DB_PATH
|
from .config import DB_PATH
|
||||||
|
from .migrations import run_database_migrations
|
||||||
|
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
@@ -84,6 +85,8 @@ CREATE TABLE IF NOT EXISTS profile_preferences (
|
|||||||
port_check_enabled INTEGER DEFAULT 0,
|
port_check_enabled INTEGER DEFAULT 0,
|
||||||
tracker_favicons_enabled INTEGER DEFAULT 0,
|
tracker_favicons_enabled INTEGER DEFAULT 0,
|
||||||
reverse_dns_enabled INTEGER DEFAULT 0,
|
reverse_dns_enabled INTEGER DEFAULT 0,
|
||||||
|
sidebar_labels_expanded INTEGER DEFAULT 0,
|
||||||
|
sidebar_shortcuts_expanded INTEGER DEFAULT 0,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
PRIMARY KEY(user_id, profile_id),
|
PRIMARY KEY(user_id, profile_id),
|
||||||
@@ -527,59 +530,10 @@ CREATE TABLE IF NOT EXISTS tracker_favicon_cache (
|
|||||||
|
|
||||||
|
|
||||||
def create_schema(conn: sqlite3.Connection) -> None:
|
def create_schema(conn: sqlite3.Connection) -> None:
|
||||||
"""Create the current database schema without running legacy migrations."""
|
"""Create the current database schema definition."""
|
||||||
conn.executescript(SCHEMA)
|
conn.executescript(SCHEMA)
|
||||||
|
|
||||||
|
|
||||||
def ensure_profile_scoped_disk_monitor_preferences(conn: sqlite3.Connection) -> None:
|
|
||||||
"""Migrate disk monitor settings from user+profile rows to one shared row per profile."""
|
|
||||||
columns = conn.execute("PRAGMA table_info(disk_monitor_preferences)").fetchall()
|
|
||||||
pk_columns = [str(row["name"]) for row in columns if int(row.get("pk") or 0)]
|
|
||||||
if pk_columns == ["profile_id"]:
|
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_disk_monitor_preferences_owner ON disk_monitor_preferences(user_id)")
|
|
||||||
return
|
|
||||||
|
|
||||||
now = utcnow()
|
|
||||||
conn.execute("DROP INDEX IF EXISTS idx_disk_monitor_preferences_owner")
|
|
||||||
conn.execute("DROP TABLE IF EXISTS disk_monitor_preferences_new")
|
|
||||||
conn.execute("DROP TABLE IF EXISTS disk_monitor_preferences_old_user_profile")
|
|
||||||
conn.execute("""
|
|
||||||
CREATE TABLE disk_monitor_preferences_new (
|
|
||||||
profile_id INTEGER PRIMARY KEY,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
paths_json TEXT,
|
|
||||||
mode TEXT DEFAULT 'default',
|
|
||||||
selected_path TEXT,
|
|
||||||
stop_enabled INTEGER DEFAULT 0,
|
|
||||||
stop_threshold INTEGER DEFAULT 98,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id),
|
|
||||||
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id)
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
conn.execute("""
|
|
||||||
INSERT INTO disk_monitor_preferences_new(
|
|
||||||
profile_id,user_id,paths_json,mode,selected_path,stop_enabled,stop_threshold,created_at,updated_at
|
|
||||||
)
|
|
||||||
SELECT profile_id,user_id,paths_json,mode,selected_path,stop_enabled,stop_threshold,
|
|
||||||
COALESCE(created_at, ?), COALESCE(updated_at, ?)
|
|
||||||
FROM (
|
|
||||||
SELECT d.*,
|
|
||||||
ROW_NUMBER() OVER (
|
|
||||||
PARTITION BY profile_id
|
|
||||||
ORDER BY COALESCE(updated_at, created_at, '') DESC, user_id ASC
|
|
||||||
) AS rn
|
|
||||||
FROM disk_monitor_preferences d
|
|
||||||
WHERE profile_id IS NOT NULL
|
|
||||||
)
|
|
||||||
WHERE rn=1
|
|
||||||
""", (now, now))
|
|
||||||
conn.execute("ALTER TABLE disk_monitor_preferences RENAME TO disk_monitor_preferences_old_user_profile")
|
|
||||||
conn.execute("ALTER TABLE disk_monitor_preferences_new RENAME TO disk_monitor_preferences")
|
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_disk_monitor_preferences_owner ON disk_monitor_preferences(user_id)")
|
|
||||||
|
|
||||||
|
|
||||||
def seed_default_user(conn: sqlite3.Connection) -> None:
|
def seed_default_user(conn: sqlite3.Connection) -> None:
|
||||||
"""Ensure the built-in admin user and default preferences exist."""
|
"""Ensure the built-in admin user and default preferences exist."""
|
||||||
now = utcnow()
|
now = utcnow()
|
||||||
@@ -623,17 +577,14 @@ def connect():
|
|||||||
|
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
"""Initialize SQLite using the current schema only.
|
"""Initialize SQLite, applying the current schema and idempotent migrations."""
|
||||||
|
|
||||||
Note: migration execution is intentionally not part of this flow.
|
|
||||||
"""
|
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
try:
|
try:
|
||||||
conn.execute("PRAGMA journal_mode = WAL")
|
conn.execute("PRAGMA journal_mode = WAL")
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
pass
|
pass
|
||||||
create_schema(conn)
|
create_schema(conn)
|
||||||
ensure_profile_scoped_disk_monitor_preferences(conn)
|
run_database_migrations(conn)
|
||||||
seed_default_user(conn)
|
seed_default_user(conn)
|
||||||
try:
|
try:
|
||||||
from .services.auth import ensure_admin_user
|
from .services.auth import ensure_admin_user
|
||||||
|
|||||||
+101
-6
@@ -1,15 +1,110 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
MIGRATIONS: tuple[str, ...] = ()
|
|
||||||
|
Migration = Callable[[sqlite3.Connection], bool]
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def _row_value(row: sqlite3.Row | dict[str, object] | tuple[object, ...], key: str, index: int) -> object:
|
||||||
|
try:
|
||||||
|
return row[key] # type: ignore[index]
|
||||||
|
except (KeyError, IndexError, TypeError):
|
||||||
|
return row[index] # type: ignore[index]
|
||||||
|
|
||||||
|
|
||||||
|
def _column_names(conn: sqlite3.Connection, table: str) -> set[str]:
|
||||||
|
return {str(_row_value(row, "name", 1)) for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||||
|
|
||||||
|
|
||||||
|
def _primary_key_columns(conn: sqlite3.Connection, table: str) -> list[str]:
|
||||||
|
columns = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
||||||
|
pk_columns = sorted(
|
||||||
|
(
|
||||||
|
(int(_row_value(row, "pk", 5) or 0), str(_row_value(row, "name", 1)))
|
||||||
|
for row in columns
|
||||||
|
if int(_row_value(row, "pk", 5) or 0)
|
||||||
|
),
|
||||||
|
key=lambda item: item[0],
|
||||||
|
)
|
||||||
|
return [name for _, name in pk_columns]
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_disk_monitor_preferences_to_profile_scope(conn: sqlite3.Connection) -> bool:
|
||||||
|
if _primary_key_columns(conn, "disk_monitor_preferences") == ["profile_id"]:
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_disk_monitor_preferences_owner ON disk_monitor_preferences(user_id)")
|
||||||
|
return False
|
||||||
|
|
||||||
|
now = _utcnow()
|
||||||
|
conn.execute("DROP INDEX IF EXISTS idx_disk_monitor_preferences_owner")
|
||||||
|
conn.execute("DROP TABLE IF EXISTS disk_monitor_preferences_new")
|
||||||
|
conn.execute("DROP TABLE IF EXISTS disk_monitor_preferences_old_user_profile")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE disk_monitor_preferences_new (
|
||||||
|
profile_id INTEGER PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
paths_json TEXT,
|
||||||
|
mode TEXT DEFAULT 'default',
|
||||||
|
selected_path TEXT,
|
||||||
|
stop_enabled INTEGER DEFAULT 0,
|
||||||
|
stop_threshold INTEGER DEFAULT 98,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id),
|
||||||
|
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO disk_monitor_preferences_new(
|
||||||
|
profile_id, user_id, paths_json, mode, selected_path, stop_enabled, stop_threshold, created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT profile_id, user_id, paths_json, mode, selected_path, stop_enabled, stop_threshold,
|
||||||
|
COALESCE(created_at, ?), COALESCE(updated_at, ?)
|
||||||
|
FROM (
|
||||||
|
SELECT d.*,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY profile_id
|
||||||
|
ORDER BY COALESCE(updated_at, created_at, '') DESC, user_id ASC
|
||||||
|
) AS rn
|
||||||
|
FROM disk_monitor_preferences d
|
||||||
|
WHERE profile_id IS NOT NULL
|
||||||
|
)
|
||||||
|
WHERE rn = 1
|
||||||
|
""", (now, now))
|
||||||
|
conn.execute("ALTER TABLE disk_monitor_preferences RENAME TO disk_monitor_preferences_old_user_profile")
|
||||||
|
conn.execute("ALTER TABLE disk_monitor_preferences_new RENAME TO disk_monitor_preferences")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_disk_monitor_preferences_owner ON disk_monitor_preferences(user_id)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_profile_preferences_sidebar_columns(conn: sqlite3.Connection) -> bool:
|
||||||
|
columns = _column_names(conn, "profile_preferences")
|
||||||
|
changed = False
|
||||||
|
if "sidebar_labels_expanded" not in columns:
|
||||||
|
conn.execute("ALTER TABLE profile_preferences ADD COLUMN sidebar_labels_expanded INTEGER DEFAULT 0")
|
||||||
|
changed = True
|
||||||
|
if "sidebar_shortcuts_expanded" not in columns:
|
||||||
|
conn.execute("ALTER TABLE profile_preferences ADD COLUMN sidebar_shortcuts_expanded INTEGER DEFAULT 0")
|
||||||
|
changed = True
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
MIGRATIONS: tuple[Migration, ...] = (
|
||||||
|
migrate_disk_monitor_preferences_to_profile_scope,
|
||||||
|
migrate_profile_preferences_sidebar_columns,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def run_database_migrations(conn: sqlite3.Connection) -> int:
|
def run_database_migrations(conn: sqlite3.Connection) -> int:
|
||||||
"""Run pending database migrations."""
|
"""Run idempotent database migrations and return how many changed the schema/data."""
|
||||||
|
|
||||||
applied = 0
|
applied = 0
|
||||||
for sql in MIGRATIONS:
|
for migration in MIGRATIONS:
|
||||||
conn.execute(sql)
|
if migration(conn):
|
||||||
applied += 1
|
applied += 1
|
||||||
return applied
|
return applied
|
||||||
|
|||||||
@@ -1178,6 +1178,14 @@
|
|||||||
},
|
},
|
||||||
"tracker_favicons_enabled": {
|
"tracker_favicons_enabled": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"sidebar_labels_expanded": {
|
||||||
|
"description": "Stores whether the sidebar label group is expanded for the active profile.",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"sidebar_shortcuts_expanded": {
|
||||||
|
"description": "Stores whether the sidebar keyboard shortcut help is expanded for the active profile.",
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import zipfile
|
|||||||
from flask import Blueprint, render_template, Response, request, redirect, url_for, abort, send_file, stream_with_context
|
from flask import Blueprint, render_template, Response, request, redirect, url_for, abort, send_file, stream_with_context
|
||||||
from ..services.preferences import get_preferences, list_profiles, active_profile, get_profile, BOOTSTRAP_THEMES, FONT_FAMILIES
|
from ..services.preferences import get_preferences, list_profiles, active_profile, get_profile, BOOTSTRAP_THEMES, FONT_FAMILIES
|
||||||
from ..services import auth, pdf_preview_links, rtorrent
|
from ..services import auth, pdf_preview_links, rtorrent
|
||||||
from ..config import PYTORRENT_TMP_DIR
|
from ..config import PYTORRENT_TMP_DIR, SMART_QUEUE_LABEL, SMART_QUEUE_STALLED_LABEL
|
||||||
from ..services.frontend_assets import asset_path
|
from ..services.frontend_assets import asset_path
|
||||||
|
|
||||||
# for favicon
|
# for favicon
|
||||||
@@ -218,6 +218,8 @@ def index():
|
|||||||
auth_provider=auth.provider(),
|
auth_provider=auth.provider(),
|
||||||
external_auth=auth.uses_external_provider(),
|
external_auth=auth.uses_external_provider(),
|
||||||
current_user=auth.current_user(),
|
current_user=auth.current_user(),
|
||||||
|
smart_queue_label=SMART_QUEUE_LABEL,
|
||||||
|
smart_queue_stalled_label=SMART_QUEUE_STALLED_LABEL,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -377,7 +377,7 @@ def _seed_profile_preferences(conn, user_id: int, profile_id: int) -> dict:
|
|||||||
return dict(row)
|
return dict(row)
|
||||||
# Note: First profile preference row is seeded from legacy user-level values so upgrades keep the current layout/filter behavior.
|
# Note: First profile preference row is seeded from legacy user-level values so upgrades keep the current layout/filter behavior.
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO profile_preferences(user_id,profile_id,table_columns_json,torrent_sort_json,active_filter,peers_refresh_seconds,port_check_enabled,tracker_favicons_enabled,reverse_dns_enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
"INSERT INTO profile_preferences(user_id,profile_id,table_columns_json,torrent_sort_json,active_filter,peers_refresh_seconds,port_check_enabled,tracker_favicons_enabled,reverse_dns_enabled,sidebar_labels_expanded,sidebar_shortcuts_expanded,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
(
|
(
|
||||||
user_id,
|
user_id,
|
||||||
profile_id,
|
profile_id,
|
||||||
@@ -388,6 +388,8 @@ def _seed_profile_preferences(conn, user_id: int, profile_id: int) -> dict:
|
|||||||
int(legacy.get("port_check_enabled") or 0),
|
int(legacy.get("port_check_enabled") or 0),
|
||||||
int(legacy.get("tracker_favicons_enabled") or 0),
|
int(legacy.get("tracker_favicons_enabled") or 0),
|
||||||
int(legacy.get("reverse_dns_enabled") or 0),
|
int(legacy.get("reverse_dns_enabled") or 0),
|
||||||
|
int(legacy.get("sidebar_labels_expanded") or 0),
|
||||||
|
int(legacy.get("sidebar_shortcuts_expanded") or 0),
|
||||||
now,
|
now,
|
||||||
now,
|
now,
|
||||||
),
|
),
|
||||||
@@ -422,6 +424,12 @@ def save_profile_preferences(user_id: int, profile_id: int | None, data: dict) -
|
|||||||
if data.get("reverse_dns_enabled") is not None:
|
if data.get("reverse_dns_enabled") is not None:
|
||||||
# Note: Reverse DNS is stored per profile because PTR lookups depend on swarm size and profile network latency.
|
# Note: Reverse DNS is stored per profile because PTR lookups depend on swarm size and profile network latency.
|
||||||
updates["reverse_dns_enabled"] = 1 if data.get("reverse_dns_enabled") else 0
|
updates["reverse_dns_enabled"] = 1 if data.get("reverse_dns_enabled") else 0
|
||||||
|
if data.get("sidebar_labels_expanded") is not None:
|
||||||
|
# Note: Label collapse state is per profile because each rTorrent can have a very different label set.
|
||||||
|
updates["sidebar_labels_expanded"] = 1 if data.get("sidebar_labels_expanded") else 0
|
||||||
|
if data.get("sidebar_shortcuts_expanded") is not None:
|
||||||
|
# Note: Shortcut help visibility is stored with profile preferences to survive refreshes.
|
||||||
|
updates["sidebar_shortcuts_expanded"] = 1 if data.get("sidebar_shortcuts_expanded") else 0
|
||||||
if data.get("torrent_sort_json") is not None:
|
if data.get("torrent_sort_json") is not None:
|
||||||
value = data.get("torrent_sort_json") if isinstance(data.get("torrent_sort_json"), str) else json.dumps(data.get("torrent_sort_json"))
|
value = data.get("torrent_sort_json") if isinstance(data.get("torrent_sort_json"), str) else json.dumps(data.get("torrent_sort_json"))
|
||||||
parsed = json.loads(value or "{}")
|
parsed = json.loads(value or "{}")
|
||||||
@@ -440,7 +448,7 @@ def save_profile_preferences(user_id: int, profile_id: int | None, data: dict) -
|
|||||||
value = str(data.get("active_filter") or "all").strip()
|
value = str(data.get("active_filter") or "all").strip()
|
||||||
if not value or len(value) > 180:
|
if not value or len(value) > 180:
|
||||||
value = "all"
|
value = "all"
|
||||||
allowed_static_filters = {"all", "downloading", "seeding", "paused", "checking", "error", "stopped", "moving"}
|
allowed_static_filters = {"all", "downloading", "seeding", "paused", "checking", "error", "post_check", "stopped", "moving"}
|
||||||
if value not in allowed_static_filters and not value.startswith("label:") and not value.startswith("tracker:"):
|
if value not in allowed_static_filters and not value.startswith("label:") and not value.startswith("tracker:"):
|
||||||
value = "all"
|
value = "all"
|
||||||
updates["active_filter"] = value
|
updates["active_filter"] = value
|
||||||
@@ -448,8 +456,8 @@ def save_profile_preferences(user_id: int, profile_id: int | None, data: dict) -
|
|||||||
return
|
return
|
||||||
merged = {**current, **updates}
|
merged = {**current, **updates}
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO profile_preferences(user_id,profile_id,table_columns_json,torrent_sort_json,active_filter,peers_refresh_seconds,port_check_enabled,tracker_favicons_enabled,reverse_dns_enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?) "
|
"INSERT INTO profile_preferences(user_id,profile_id,table_columns_json,torrent_sort_json,active_filter,peers_refresh_seconds,port_check_enabled,tracker_favicons_enabled,reverse_dns_enabled,sidebar_labels_expanded,sidebar_shortcuts_expanded,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?) "
|
||||||
"ON CONFLICT(user_id,profile_id) DO UPDATE SET table_columns_json=excluded.table_columns_json, torrent_sort_json=excluded.torrent_sort_json, active_filter=excluded.active_filter, peers_refresh_seconds=excluded.peers_refresh_seconds, port_check_enabled=excluded.port_check_enabled, tracker_favicons_enabled=excluded.tracker_favicons_enabled, reverse_dns_enabled=excluded.reverse_dns_enabled, updated_at=excluded.updated_at",
|
"ON CONFLICT(user_id,profile_id) DO UPDATE SET table_columns_json=excluded.table_columns_json, torrent_sort_json=excluded.torrent_sort_json, active_filter=excluded.active_filter, peers_refresh_seconds=excluded.peers_refresh_seconds, port_check_enabled=excluded.port_check_enabled, tracker_favicons_enabled=excluded.tracker_favicons_enabled, reverse_dns_enabled=excluded.reverse_dns_enabled, sidebar_labels_expanded=excluded.sidebar_labels_expanded, sidebar_shortcuts_expanded=excluded.sidebar_shortcuts_expanded, updated_at=excluded.updated_at",
|
||||||
(
|
(
|
||||||
user_id,
|
user_id,
|
||||||
profile_id,
|
profile_id,
|
||||||
@@ -460,6 +468,8 @@ def save_profile_preferences(user_id: int, profile_id: int | None, data: dict) -
|
|||||||
int(merged.get("port_check_enabled") or 0),
|
int(merged.get("port_check_enabled") or 0),
|
||||||
int(merged.get("tracker_favicons_enabled") or 0),
|
int(merged.get("tracker_favicons_enabled") or 0),
|
||||||
int(merged.get("reverse_dns_enabled") or 0),
|
int(merged.get("reverse_dns_enabled") or 0),
|
||||||
|
int(merged.get("sidebar_labels_expanded") or 0),
|
||||||
|
int(merged.get("sidebar_shortcuts_expanded") or 0),
|
||||||
merged.get("created_at") or now,
|
merged.get("created_at") or now,
|
||||||
now,
|
now,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -391,9 +391,8 @@ def _smart_queue_label_cleanup_value(live_label: str | None, previous_label: str
|
|||||||
|
|
||||||
|
|
||||||
def _has_stalled_label(value: str | None) -> bool:
|
def _has_stalled_label(value: str | None) -> bool:
|
||||||
# Note: Stalled is treated case-insensitively so manually edited labels still block Smart Queue.
|
# Note: Stalled is an exact technical label; lower-case variants are normal user labels.
|
||||||
target = SMART_QUEUE_STALLED_LABEL.casefold()
|
return SMART_QUEUE_STALLED_LABEL in _label_names(value)
|
||||||
return any(label.casefold() == target for label in _label_names(value))
|
|
||||||
|
|
||||||
|
|
||||||
def _without_queue_technical_labels(value: str | None) -> str:
|
def _without_queue_technical_labels(value: str | None) -> str:
|
||||||
@@ -403,7 +402,7 @@ def _without_queue_technical_labels(value: str | None) -> str:
|
|||||||
def _ensure_stalled_label(client: Any, torrent_hash: str, current_label: str = '') -> bool:
|
def _ensure_stalled_label(client: Any, torrent_hash: str, current_label: str = '') -> bool:
|
||||||
labels = [label for label in _label_names(current_label) if label != SMART_QUEUE_LABEL]
|
labels = [label for label in _label_names(current_label) if label != SMART_QUEUE_LABEL]
|
||||||
changed = False
|
changed = False
|
||||||
if not any(label.casefold() == SMART_QUEUE_STALLED_LABEL.casefold() for label in labels):
|
if SMART_QUEUE_STALLED_LABEL not in labels:
|
||||||
labels.append(SMART_QUEUE_STALLED_LABEL)
|
labels.append(SMART_QUEUE_STALLED_LABEL)
|
||||||
changed = True
|
changed = True
|
||||||
if SMART_QUEUE_LABEL in _label_names(current_label):
|
if SMART_QUEUE_LABEL in _label_names(current_label):
|
||||||
@@ -421,13 +420,13 @@ def _ensure_stalled_label(client: Any, torrent_hash: str, current_label: str = '
|
|||||||
def _without_stalled_label(value: str | None) -> str:
|
def _without_stalled_label(value: str | None) -> str:
|
||||||
"""Return labels without Smart Queue's Stalled marker."""
|
"""Return labels without Smart Queue's Stalled marker."""
|
||||||
# Note: This keeps user labels intact while clearing only the automatic stalled state.
|
# Note: This keeps user labels intact while clearing only the automatic stalled state.
|
||||||
return _label_value([label for label in _label_names(value) if label.casefold() != SMART_QUEUE_STALLED_LABEL.casefold()])
|
return _label_value([label for label in _label_names(value) if label != SMART_QUEUE_STALLED_LABEL])
|
||||||
|
|
||||||
|
|
||||||
def _clear_stalled_label(client: Any, torrent_hash: str, current_label: str = '') -> bool:
|
def _clear_stalled_label(client: Any, torrent_hash: str, current_label: str = '') -> bool:
|
||||||
"""Remove the Stalled marker from a torrent that is active again."""
|
"""Remove the Stalled marker from a torrent that is active again."""
|
||||||
labels = _label_names(current_label)
|
labels = _label_names(current_label)
|
||||||
if not any(label.casefold() == SMART_QUEUE_STALLED_LABEL.casefold() for label in labels):
|
if SMART_QUEUE_STALLED_LABEL not in labels:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
# Note: Active downloads must not keep the Stalled marker after they resume transferring.
|
# Note: Active downloads must not keep the Stalled marker after they resume transferring.
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
export const bootstrapRuntimeSource = " let lastStaticAssetVersionCheck=0;\n async function checkStaticAssetVersion(force=false){ const now=Date.now(); if(!force && now-lastStaticAssetVersionCheck<60000) return; lastStaticAssetVersionCheck=now; try{ const r=await fetch('/api/static_hash',{cache:'no-store'}); const j=await r.json(); const current=String(window.PYTORRENT?.staticHash||''); const next=String(j.static_hash||j.version||''); if(current && next && current!==next){ window.PYTORRENT.staticHash=next; toast('A new frontend version is available. Reloading...','info'); setTimeout(()=>window.location.reload(), 600); } }catch(e){} }\n setInterval(()=>checkStaticAssetVersion(true), 900000);\n window.addEventListener('focus',()=>checkStaticAssetVersion(false));\n updateSortHeaders(); setupColumnResizers(); applyColumnVisibility(); renderColumnManager(); restoreFooterStatusCache(); refreshFooterStatusNow(); renderFooterPreferences(); applyFooterPreferences(); updateFooterClock(); updateBrowserSpeedTitle(); setupTorrentDropZone(); setInterval(updateFooterClock,1000); scheduleRender(true); if(!hasActiveProfile) renderNoProfileState(); loadLabels().catch(()=>{}); loadRatios().catch(()=>{}); loadSmartQueue().catch(()=>{}); loadAutomations().catch(()=>{}); ensureDashboardToolsUI(); if(portCheckEnabled) loadPortCheck(false); else renderPortCheck({status:'disabled',enabled:false}); if(hasActiveProfile) applyDefaultDownloadPath(false).catch(()=>{}); if(hasActiveProfile) refreshUserDiskUsage(true).catch(()=>{}); scheduleTrackerSummary(true);\n";
|
export const bootstrapRuntimeSource = " let lastStaticAssetVersionCheck=0;\n async function checkStaticAssetVersion(force=false){ const now=Date.now(); if(!force && now-lastStaticAssetVersionCheck<60000) return; lastStaticAssetVersionCheck=now; try{ const r=await fetch('/api/static_hash',{cache:'no-store'}); const j=await r.json(); const current=String(window.PYTORRENT?.staticHash||''); const next=String(j.static_hash||j.version||''); if(current && next && current!==next){ window.PYTORRENT.staticHash=next; toast('A new frontend version is available. Reloading...','info'); setTimeout(()=>window.location.reload(), 600); } }catch(e){} }\n setInterval(()=>checkStaticAssetVersion(true), 900000);\n window.addEventListener('focus',()=>checkStaticAssetVersion(false));\n initSidebarShortcuts(); updateSortHeaders(); setupColumnResizers(); applyColumnVisibility(); renderColumnManager(); restoreFooterStatusCache(); refreshFooterStatusNow(); renderFooterPreferences(); applyFooterPreferences(); updateFooterClock(); updateBrowserSpeedTitle(); setupTorrentDropZone(); setInterval(updateFooterClock,1000); scheduleRender(true); if(!hasActiveProfile) renderNoProfileState(); loadLabels().catch(()=>{}); loadRatios().catch(()=>{}); loadSmartQueue().catch(()=>{}); loadAutomations().catch(()=>{}); ensureDashboardToolsUI(); if(portCheckEnabled) loadPortCheck(false); else renderPortCheck({status:'disabled',enabled:false}); if(hasActiveProfile) applyDefaultDownloadPath(false).catch(()=>{}); if(hasActiveProfile) refreshUserDiskUsage(true).catch(()=>{}); scheduleTrackerSummary(true);\n";
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -353,8 +353,9 @@ body {
|
|||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
gap: 0.1rem 0.45rem;
|
gap: 0.1rem 0.45rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 0.12rem;
|
margin-bottom: 0.08rem;
|
||||||
padding: 0.34rem 0.5rem;
|
/* Note: Main sidebar filters are intentionally compact so labels and tools fit without extra scrolling. */
|
||||||
|
padding: 0.25rem 0.45rem;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 0.55rem;
|
border-radius: 0.55rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -387,9 +388,9 @@ body {
|
|||||||
display: block;
|
display: block;
|
||||||
margin-top: 0.05rem;
|
margin-top: 0.05rem;
|
||||||
color: var(--bs-secondary-color);
|
color: var(--bs-secondary-color);
|
||||||
font-size: 0.68rem;
|
font-size: 0.64rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 1.15;
|
line-height: 1.05;
|
||||||
opacity: 0.72;
|
opacity: 0.72;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -405,6 +406,39 @@ body {
|
|||||||
color: var(--bs-secondary-color);
|
color: var(--bs-secondary-color);
|
||||||
padding: 0.15rem 0.5rem;
|
padding: 0.15rem 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-collapse-toggle {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0.08rem 0;
|
||||||
|
padding: 0.24rem 0.45rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.sidebar-collapse-toggle:hover {
|
||||||
|
background: var(--bs-secondary-bg);
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
}
|
||||||
|
.sidebar-collapse-toggle > span:first-child {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sidebar-collapse-toggle > span:last-child {
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.sidebar-collapsible.is-collapsed {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.content {
|
.content {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
@@ -78,17 +78,22 @@
|
|||||||
<div id="labelFilters" class="label-filters mt-2"></div>
|
<div id="labelFilters" class="label-filters mt-2"></div>
|
||||||
<div id="trackerFilters" class="tracker-filters mt-2"></div>
|
<div id="trackerFilters" class="tracker-filters mt-2"></div>
|
||||||
<hr>
|
<hr>
|
||||||
<div class="small text-muted px-2">Shortcuts</div>
|
<div class="small text-muted px-2 mb-1">Shortcuts</div>
|
||||||
<div class="shortcut">Ctrl+A — select visible</div>
|
<button id="shortcutToggle" class="sidebar-collapse-toggle" type="button" aria-expanded="false"><span><i class="fa-solid fa-chevron-down"></i> Show shortcuts</span><span>11</span></button>
|
||||||
<div class="shortcut">Ctrl+I — invert visible</div>
|
<div id="shortcutList" class="sidebar-collapsible is-collapsed">
|
||||||
<div class="shortcut">Space — start</div>
|
<!-- Note: Keyboard shortcut help is collapsed by default and persisted with the profile sidebar preferences. -->
|
||||||
<div class="shortcut">P — pause</div>
|
<div class="shortcut">Ctrl+A — select visible</div>
|
||||||
<div class="shortcut">S — stop</div>
|
<div class="shortcut">Ctrl+I — invert visible</div>
|
||||||
<div class="shortcut">R — resume</div>
|
<div class="shortcut">Space — start</div>
|
||||||
<div class="shortcut">M — move</div>
|
<div class="shortcut">P — pause</div>
|
||||||
<div class="shortcut">Esc — clear selection</div>
|
<div class="shortcut">S — stop</div>
|
||||||
<div class="shortcut">Delete — remove</div>
|
<div class="shortcut">R — resume</div>
|
||||||
<div class="shortcut">Ctrl+O — add</div><div class="shortcut">Ctrl+S — download .torrent</div>
|
<div class="shortcut">M — move</div>
|
||||||
|
<div class="shortcut">Esc — clear selection</div>
|
||||||
|
<div class="shortcut">Delete — remove</div>
|
||||||
|
<div class="shortcut">Ctrl+O — add</div>
|
||||||
|
<div class="shortcut">Ctrl+S — download .torrent</div>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="content">
|
<section class="content">
|
||||||
@@ -407,7 +412,7 @@
|
|||||||
<div id="toastHost" class="toast-host"></div>
|
<div id="toastHost" class="toast-host"></div>
|
||||||
<script src="{{ frontend_asset_url('socket_io_js') }}"></script>
|
<script src="{{ frontend_asset_url('socket_io_js') }}"></script>
|
||||||
<script src="{{ frontend_asset_url('bootstrap_js') }}"></script>
|
<script src="{{ frontend_asset_url('bootstrap_js') }}"></script>
|
||||||
<script>window.PYTORRENT = {authEnabled: {{ 1 if auth_enabled else 0 }}, authProvider: {{ auth_provider | tojson }}, externalAuth: {{ 1 if external_auth else 0 }}, currentUser: {% if current_user %}{{ current_user | tojson }}{% else %}null{% endif %}, activeProfile: {{ active_profile.id if active_profile else 'null' }}, tableColumns: {{ (prefs.table_columns_json or '{}') | safe }}, torrentSort: {{ (prefs.torrent_sort_json or '{}') | safe }}, activeFilter: {{ (prefs.active_filter if prefs and prefs.active_filter else 'all') | tojson }}, detailPanelHeight: {{ prefs.detail_panel_height if prefs and prefs.detail_panel_height else 255 }}, peersRefreshSeconds: {{ prefs.peers_refresh_seconds if prefs else 0 }}, portCheckEnabled: {{ 1 if prefs and prefs.port_check_enabled else 0 }}, interfaceScale: {{ prefs.interface_scale if prefs and prefs.interface_scale else 100 }}, torrentListFontSize: {{ prefs.torrent_list_font_size if prefs and prefs.torrent_list_font_size else 13 }}, compactTorrentListEnabled: {{ 1 if prefs and prefs.compact_torrent_list_enabled else 0 }}, titleSpeedEnabled: {{ 1 if prefs and prefs.title_speed_enabled else 0 }}, trackerFaviconsEnabled: {{ 1 if prefs and prefs.tracker_favicons_enabled else 0 }}, reverseDnsEnabled: {{ 1 if prefs and prefs.reverse_dns_enabled else 0 }}, automationToastsEnabled: {{ 1 if not prefs or prefs.automation_toasts_enabled else 0 }}, smartQueueToastsEnabled: {{ 1 if not prefs or prefs.smart_queue_toasts_enabled else 0 }}, diskMonitorPaths: {{ (prefs.disk_monitor_paths_json or "[]") | safe }}, diskMonitorMode: {{ (prefs.disk_monitor_mode if prefs and prefs.disk_monitor_mode else "default") | tojson }}, diskMonitorSelectedPath: {{ (prefs.disk_monitor_selected_path if prefs and prefs.disk_monitor_selected_path else "") | tojson }}, diskMonitorOwnerLabel: {{ (prefs.disk_monitor_owner_label if prefs and prefs.disk_monitor_owner_label else "") | tojson }}, bootstrapTheme: {{ (prefs.bootstrap_theme if prefs and prefs.bootstrap_theme else 'default') | tojson }}, fontFamily: {{ (prefs.font_family if prefs and prefs.font_family else 'default') | tojson }}, footerItems: {{ (prefs.footer_items_json or '{}') | safe }}, easterEggEnabled: {{ 1 if prefs and prefs.easter_egg_enabled else 0 }}, easterEggLoadingImageUrl: {{ (prefs.easter_egg_loading_image_url if prefs and prefs.easter_egg_loading_image_url else '') | tojson }}, easterEggClickImageUrl: {{ (prefs.easter_egg_click_image_url if prefs and prefs.easter_egg_click_image_url else '') | tojson }}, bootstrapThemes: {{ bootstrap_themes | tojson }}, bootstrapThemeUrls: { {% for key in bootstrap_themes.keys() %}{{ key | tojson }}: {{ bootstrap_theme_url(key) | tojson }}{% if not loop.last %}, {% endif %}{% endfor %} }, fontFamilies: {{ font_families | tojson }}, staticHash: {{ static_hash() | tojson }}};</script>
|
<script>window.PYTORRENT = {authEnabled: {{ 1 if auth_enabled else 0 }}, authProvider: {{ auth_provider | tojson }}, externalAuth: {{ 1 if external_auth else 0 }}, currentUser: {% if current_user %}{{ current_user | tojson }}{% else %}null{% endif %}, activeProfile: {{ active_profile.id if active_profile else 'null' }}, tableColumns: {{ (prefs.table_columns_json or '{}') | safe }}, torrentSort: {{ (prefs.torrent_sort_json or '{}') | safe }}, activeFilter: {{ (prefs.active_filter if prefs and prefs.active_filter else 'all') | tojson }}, detailPanelHeight: {{ prefs.detail_panel_height if prefs and prefs.detail_panel_height else 255 }}, peersRefreshSeconds: {{ prefs.peers_refresh_seconds if prefs else 0 }}, portCheckEnabled: {{ 1 if prefs and prefs.port_check_enabled else 0 }}, interfaceScale: {{ prefs.interface_scale if prefs and prefs.interface_scale else 100 }}, torrentListFontSize: {{ prefs.torrent_list_font_size if prefs and prefs.torrent_list_font_size else 13 }}, compactTorrentListEnabled: {{ 1 if prefs and prefs.compact_torrent_list_enabled else 0 }}, titleSpeedEnabled: {{ 1 if prefs and prefs.title_speed_enabled else 0 }}, trackerFaviconsEnabled: {{ 1 if prefs and prefs.tracker_favicons_enabled else 0 }}, reverseDnsEnabled: {{ 1 if prefs and prefs.reverse_dns_enabled else 0 }}, automationToastsEnabled: {{ 1 if not prefs or prefs.automation_toasts_enabled else 0 }}, smartQueueToastsEnabled: {{ 1 if not prefs or prefs.smart_queue_toasts_enabled else 0 }}, diskMonitorPaths: {{ (prefs.disk_monitor_paths_json or "[]") | safe }}, diskMonitorMode: {{ (prefs.disk_monitor_mode if prefs and prefs.disk_monitor_mode else "default") | tojson }}, diskMonitorSelectedPath: {{ (prefs.disk_monitor_selected_path if prefs and prefs.disk_monitor_selected_path else "") | tojson }}, diskMonitorOwnerLabel: {{ (prefs.disk_monitor_owner_label if prefs and prefs.disk_monitor_owner_label else "") | tojson }}, bootstrapTheme: {{ (prefs.bootstrap_theme if prefs and prefs.bootstrap_theme else 'default') | tojson }}, fontFamily: {{ (prefs.font_family if prefs and prefs.font_family else 'default') | tojson }}, footerItems: {{ (prefs.footer_items_json or '{}') | safe }}, easterEggEnabled: {{ 1 if prefs and prefs.easter_egg_enabled else 0 }}, easterEggLoadingImageUrl: {{ (prefs.easter_egg_loading_image_url if prefs and prefs.easter_egg_loading_image_url else '') | tojson }}, easterEggClickImageUrl: {{ (prefs.easter_egg_click_image_url if prefs and prefs.easter_egg_click_image_url else '') | tojson }}, bootstrapThemes: {{ bootstrap_themes | tojson }}, bootstrapThemeUrls: { {% for key in bootstrap_themes.keys() %}{{ key | tojson }}: {{ bootstrap_theme_url(key) | tojson }}{% if not loop.last %}, {% endif %}{% endfor %} }, fontFamilies: {{ font_families | tojson }}, staticHash: {{ static_hash() | tojson }}, smartQueueTechnicalLabel: {{ smart_queue_label | tojson }}, smartQueueStalledLabel: {{ smart_queue_stalled_label | tojson }}, sidebarLabelsExpanded: {{ 1 if prefs and prefs.sidebar_labels_expanded else 0 }}, sidebarShortcutsExpanded: {{ 1 if prefs and prefs.sidebar_shortcuts_expanded else 0 }}};</script>
|
||||||
<!-- Rollback: uncomment the legacy include below and comment the module include. -->
|
<!-- Rollback: uncomment the legacy include below and comment the module include. -->
|
||||||
<!-- <script src="{{ static_url('app.js') }}"></script> -->
|
<!-- <script src="{{ static_url('app.js') }}"></script> -->
|
||||||
<script type="module" src="{{ static_url('js/app.js') }}"></script>
|
<script type="module" src="{{ static_url('js/app.js') }}"></script>
|
||||||
|
|||||||
+9
-9
@@ -12,7 +12,7 @@ The installer is split into two layers:
|
|||||||
Run as root or through `sudo`:
|
Run as root or through `sudo`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh | sudo bash
|
curl -fsSL https://raw.githubusercontent.com/zdzichu6969/pyTorrent/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:
|
The bootstrap script downloads the current pyTorrent repository, detects the operating system family, and runs the matching installer:
|
||||||
@@ -46,14 +46,14 @@ Environment variables must be passed to the `sudo bash` process.
|
|||||||
Example:
|
Example:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
curl -fsSL https://raw.githubusercontent.com/zdzichu6969/pyTorrent/master/scripts/install_stack.sh \
|
||||||
| sudo PYTORRENT_PORT=8091 RTORRENT_SCGI_PORT=5001 bash
|
| sudo PYTORRENT_PORT=8091 RTORRENT_SCGI_PORT=5001 bash
|
||||||
```
|
```
|
||||||
|
|
||||||
Another example with a custom profile name:
|
Another example with a custom profile name:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
curl -fsSL https://raw.githubusercontent.com/zdzichu6969/pyTorrent/master/scripts/install_stack.sh \
|
||||||
| sudo PYTORRENT_PROFILE_NAME="Local rTorrent" PYTORRENT_PORT=8090 bash
|
| sudo PYTORRENT_PROFILE_NAME="Local rTorrent" PYTORRENT_PORT=8090 bash
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ These variables are used by `scripts/install_stack.sh`.
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `PYTORRENT_REPO_URL` | `https://git.linuxiarz.pl/gru/pyTorrent` | Git repository base URL. |
|
| `PYTORRENT_REPO_URL` | `https://github.com/zdzichu6969/pyTorrent` | GitHub repository base URL. |
|
||||||
| `PYTORRENT_REPO_BRANCH` | `master` | Branch used to download the repository archive. |
|
| `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_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_BOOTSTRAP_DIR` | `/tmp/pytorrent-stack-installer` | Temporary directory used by the bootstrap script. |
|
||||||
@@ -72,7 +72,7 @@ These variables are used by `scripts/install_stack.sh`.
|
|||||||
Example using a different branch:
|
Example using a different branch:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
curl -fsSL https://raw.githubusercontent.com/zdzichu6969/pyTorrent/master/scripts/install_stack.sh \
|
||||||
| sudo PYTORRENT_REPO_BRANCH=develop bash
|
| sudo PYTORRENT_REPO_BRANCH=develop bash
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -96,14 +96,14 @@ These variables are used by both stack installers.
|
|||||||
Example:
|
Example:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
curl -fsSL https://raw.githubusercontent.com/zdzichu6969/pyTorrent/master/scripts/install_stack.sh \
|
||||||
| sudo RTORRENT_USER=rtorrent RTORRENT_SCGI_PORT=5001 RTORRENT_TORRENT_PORT=51400 bash
|
| sudo RTORRENT_USER=rtorrent RTORRENT_SCGI_PORT=5001 RTORRENT_TORRENT_PORT=51400 bash
|
||||||
```
|
```
|
||||||
|
|
||||||
Classic xmlrpc-c backend instead of default tinyxml2. On Arch this forces source build:
|
Classic xmlrpc-c backend instead of default tinyxml2. On Arch this forces source build:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
curl -fsSL https://raw.githubusercontent.com/zdzichu6969/pyTorrent/master/scripts/install_stack.sh \
|
||||||
| sudo RTORRENT_WITH_XMLRPC_C=1 bash
|
| sudo RTORRENT_WITH_XMLRPC_C=1 bash
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/inst
|
|||||||
Example with API token:
|
Example with API token:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
curl -fsSL https://raw.githubusercontent.com/zdzichu6969/pyTorrent/master/scripts/install_stack.sh \
|
||||||
| sudo PYTORRENT_API_TOKEN="pt_xxx" bash
|
| sudo PYTORRENT_API_TOKEN="pt_xxx" bash
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -234,7 +234,7 @@ sudo bash scripts/install_pytorrent_only.sh
|
|||||||
Bootstrap run from repository:
|
Bootstrap run from repository:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_pytorrent.sh | sudo bash
|
curl -fsSL https://raw.githubusercontent.com/zdzichu6969/pyTorrent/master/scripts/install_pytorrent.sh | sudo bash
|
||||||
```
|
```
|
||||||
|
|
||||||
Non-interactive example for an existing TCP SCGI backend:
|
Non-interactive example for an existing TCP SCGI backend:
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ set -euo pipefail
|
|||||||
|
|
||||||
# Bootstrap installer for pyTorrent only.
|
# Bootstrap installer for pyTorrent only.
|
||||||
# Intended usage:
|
# Intended usage:
|
||||||
# curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_pytorrent.sh | sudo bash
|
# curl -fsSL https://raw.githubusercontent.com/zdzichu6969/pyTorrent/master/scripts/install_pytorrent.sh | sudo bash
|
||||||
|
|
||||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||||
cat <<'USAGE'
|
cat <<'USAGE'
|
||||||
Usage: curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_pytorrent.sh | sudo bash -s -- [options]
|
Usage: curl -fsSL https://raw.githubusercontent.com/zdzichu6969/pyTorrent/master/scripts/install_pytorrent.sh | sudo bash -s -- [options]
|
||||||
|
|
||||||
This bootstrap downloads pyTorrent and forwards all options to scripts/install_pytorrent_only.sh.
|
This bootstrap downloads pyTorrent and forwards all options to scripts/install_pytorrent_only.sh.
|
||||||
Run scripts/install_pytorrent_only.sh --help inside the repository for the full option list.
|
Run scripts/install_pytorrent_only.sh --help inside the repository for the full option list.
|
||||||
@@ -20,11 +20,23 @@ if [[ "${EUID}" -ne 0 ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
REPO_URL="${PYTORRENT_REPO_URL:-https://git.linuxiarz.pl/gru/pyTorrent}"
|
REPO_URL="${PYTORRENT_REPO_URL:-https://github.com/zdzichu6969/pyTorrent}"
|
||||||
REPO_BRANCH="${PYTORRENT_REPO_BRANCH:-master}"
|
REPO_BRANCH="${PYTORRENT_REPO_BRANCH:-master}"
|
||||||
WORK_DIR="${PYTORRENT_BOOTSTRAP_DIR:-/tmp/pytorrent-only-installer}"
|
WORK_DIR="${PYTORRENT_BOOTSTRAP_DIR:-/tmp/pytorrent-only-installer}"
|
||||||
KEEP_WORK_DIR="${PYTORRENT_KEEP_BOOTSTRAP_DIR:-0}"
|
KEEP_WORK_DIR="${PYTORRENT_KEEP_BOOTSTRAP_DIR:-0}"
|
||||||
ARCHIVE_URL="${PYTORRENT_ARCHIVE_URL:-${REPO_URL%/}/archive/${REPO_BRANCH}.tar.gz}"
|
|
||||||
|
default_archive_url() {
|
||||||
|
case "${REPO_URL%/}" in
|
||||||
|
https://github.com/*)
|
||||||
|
printf '%s/archive/refs/heads/%s.tar.gz\n' "${REPO_URL%/}" "${REPO_BRANCH}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf '%s/archive/%s.tar.gz\n' "${REPO_URL%/}" "${REPO_BRANCH}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
ARCHIVE_URL="${PYTORRENT_ARCHIVE_URL:-$(default_archive_url)}"
|
||||||
PROJECT_DIR="${WORK_DIR}/src"
|
PROJECT_DIR="${WORK_DIR}/src"
|
||||||
ARCHIVE_PATH="${WORK_DIR}/pytorrent.tar.gz"
|
ARCHIVE_PATH="${WORK_DIR}/pytorrent.tar.gz"
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
# Bootstrap installer for pyTorrent + rTorrent.
|
# Bootstrap installer for pyTorrent + rTorrent.
|
||||||
# Intended usage from a clean server:
|
# Intended usage from a clean server:
|
||||||
# curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh | sudo bash
|
# curl -fsSL https://raw.githubusercontent.com/zdzichu6969/pyTorrent/master/scripts/install_stack.sh | sudo bash
|
||||||
#
|
#
|
||||||
# The script downloads the current pyTorrent repository, detects the OS family,
|
# The script downloads the current pyTorrent repository, detects the OS family,
|
||||||
# and runs the matching installer from scripts/stack_installers/.
|
# and runs the matching installer from scripts/stack_installers/.
|
||||||
@@ -13,13 +13,37 @@ if [[ "${EUID}" -ne 0 ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
REPO_URL="${PYTORRENT_REPO_URL:-https://git.linuxiarz.pl/gru/pyTorrent}"
|
REPO_URL="${PYTORRENT_REPO_URL:-https://github.com/zdzichu6969/pyTorrent}"
|
||||||
REPO_BRANCH="${PYTORRENT_REPO_BRANCH:-master}"
|
REPO_BRANCH="${PYTORRENT_REPO_BRANCH:-master}"
|
||||||
WORK_DIR="${PYTORRENT_BOOTSTRAP_DIR:-/tmp/pytorrent-stack-installer}"
|
WORK_DIR="${PYTORRENT_BOOTSTRAP_DIR:-/tmp/pytorrent-stack-installer}"
|
||||||
KEEP_WORK_DIR="${PYTORRENT_KEEP_BOOTSTRAP_DIR:-0}"
|
KEEP_WORK_DIR="${PYTORRENT_KEEP_BOOTSTRAP_DIR:-0}"
|
||||||
|
|
||||||
RAW_BASE="${REPO_URL%/}/raw/branch/${REPO_BRANCH}"
|
default_archive_url() {
|
||||||
ARCHIVE_URL="${PYTORRENT_ARCHIVE_URL:-${REPO_URL%/}/archive/${REPO_BRANCH}.tar.gz}"
|
case "${REPO_URL%/}" in
|
||||||
|
https://github.com/*)
|
||||||
|
printf '%s/archive/refs/heads/%s.tar.gz\n' "${REPO_URL%/}" "${REPO_BRANCH}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf '%s/archive/%s.tar.gz\n' "${REPO_URL%/}" "${REPO_BRANCH}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
default_raw_base() {
|
||||||
|
case "${REPO_URL%/}" in
|
||||||
|
https://github.com/*)
|
||||||
|
local path
|
||||||
|
path="${REPO_URL#https://github.com/}"
|
||||||
|
printf 'https://raw.githubusercontent.com/%s/%s\n' "${path%/}" "${REPO_BRANCH}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf '%s/raw/branch/%s\n' "${REPO_URL%/}" "${REPO_BRANCH}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
RAW_BASE="${PYTORRENT_RAW_BASE:-$(default_raw_base)}"
|
||||||
|
ARCHIVE_URL="${PYTORRENT_ARCHIVE_URL:-$(default_archive_url)}"
|
||||||
PROJECT_DIR="${WORK_DIR}/src"
|
PROJECT_DIR="${WORK_DIR}/src"
|
||||||
ARCHIVE_PATH="${WORK_DIR}/pytorrent.tar.gz"
|
ARCHIVE_PATH="${WORK_DIR}/pytorrent.tar.gz"
|
||||||
|
|
||||||
@@ -142,7 +166,7 @@ if ! download_file "${ARCHIVE_URL}" "${ARCHIVE_PATH}"; then
|
|||||||
install_rtorrent.py \
|
install_rtorrent.py \
|
||||||
install_rtorrent_rhel.py \
|
install_rtorrent_rhel.py \
|
||||||
configure_pytorrent_api.py \
|
configure_pytorrent_api.py \
|
||||||
INSTALL_STACK.md
|
INSTALL.md
|
||||||
do
|
do
|
||||||
download_file "${RAW_BASE}/scripts/stack_installers/${file}" "${PROJECT_DIR}/scripts/stack_installers/${file}"
|
download_file "${RAW_BASE}/scripts/stack_installers/${file}" "${PROJECT_DIR}/scripts/stack_installers/${file}"
|
||||||
done
|
done
|
||||||
|
|||||||
Executable
+526
@@ -0,0 +1,526 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Development SCGI/XML-RPC rTorrent mock for pyTorrent."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import socketserver
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from xmlrpc.client import Binary, Fault, dumps, loads
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
DEFAULT_STATE_PATH = BASE_DIR / "data" / "mock_rtorrent_state.json"
|
||||||
|
LABELS = ["Smart Queue Stopped", "Stalled", "movies", "series", "music", "books", "linux", "archive", "games", "work", "private", "backup"]
|
||||||
|
TRACKERS = [
|
||||||
|
"udp://tracker.opentrackr.org:1337/announce",
|
||||||
|
"udp://open.stealth.si:80/announce",
|
||||||
|
"udp://tracker.torrent.eu.org:451/announce",
|
||||||
|
"https://tracker.example.dev/announce",
|
||||||
|
]
|
||||||
|
CLIENTS = ["qBittorrent/4.6", "Transmission/4.0", "libtorrent/2.0", "Deluge/2.1", "rtorrent/0.9"]
|
||||||
|
|
||||||
|
|
||||||
|
def xmlrpc_safe(value: Any) -> Any:
|
||||||
|
"""Convert large integers to strings because XML-RPC int is 32-bit in Python clients."""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, int) and not (-2_147_483_648 <= value <= 2_147_483_647):
|
||||||
|
return str(value)
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [xmlrpc_safe(item) for item in value]
|
||||||
|
if isinstance(value, tuple):
|
||||||
|
return tuple(xmlrpc_safe(item) for item in value)
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {key: xmlrpc_safe(item) for key, item in value.items()}
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def human_now() -> str:
|
||||||
|
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||||
|
|
||||||
|
|
||||||
|
class MockRtorrentState:
|
||||||
|
"""Mutable in-memory rTorrent-like state with optional JSON persistence."""
|
||||||
|
|
||||||
|
def __init__(self, count: int, seed: int, state_file: Path | None = None, persist: bool = False, disk_total_gb: int = 4096, disk_used_percent: float = 68.0):
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.started_at = time.time()
|
||||||
|
self.state_file = state_file
|
||||||
|
self.persist = persist
|
||||||
|
self.disk_total_bytes = max(1, int(disk_total_gb)) * 1024 * 1024 * 1024
|
||||||
|
self.disk_used_percent = max(0.0, min(99.9, float(disk_used_percent)))
|
||||||
|
self.config: dict[str, Any] = {
|
||||||
|
"network.port_range": "49164-49164",
|
||||||
|
"network.xmlrpc.size_limit": "16M",
|
||||||
|
"throttle.global_down.max_rate": 0,
|
||||||
|
"throttle.global_up.max_rate": 0,
|
||||||
|
"system.client_version": "mock-rtorrent/0.1",
|
||||||
|
"system.library_version": "mock-libtorrent/0.13",
|
||||||
|
"directory.default": "/mock/downloads",
|
||||||
|
"session.path": "/mock/session",
|
||||||
|
"system.filesystem.total": self.disk_total_bytes,
|
||||||
|
"system.filesystem.used_percent": self.disk_used_percent,
|
||||||
|
}
|
||||||
|
self.torrents: list[dict[str, Any]] = []
|
||||||
|
self.by_hash: dict[str, dict[str, Any]] = {}
|
||||||
|
if persist and state_file and state_file.is_file():
|
||||||
|
self.load()
|
||||||
|
else:
|
||||||
|
self.generate(count=count, seed=seed)
|
||||||
|
|
||||||
|
def generate(self, count: int, seed: int) -> None:
|
||||||
|
"""Create a large deterministic torrent list for UI and API load testing."""
|
||||||
|
rng = random.Random(seed)
|
||||||
|
now = int(time.time())
|
||||||
|
self.torrents = []
|
||||||
|
for index in range(max(1, count)):
|
||||||
|
size = rng.randint(64, 96_000) * 1024 * 1024
|
||||||
|
complete = index % 5 in (0, 1, 2)
|
||||||
|
progress = 1.0 if complete else rng.uniform(0.01, 0.98)
|
||||||
|
completed = size if complete else int(size * progress)
|
||||||
|
active = index % 7 not in (0, 3)
|
||||||
|
state = 1 if active or index % 11 == 0 else 0
|
||||||
|
label = LABELS[index % len(LABELS)]
|
||||||
|
if index % 19 == 0:
|
||||||
|
label = f"{label}, project-{index % 37}"
|
||||||
|
torrent_hash = hashlib.sha1(f"pyTorrent-mock-{seed}-{index}".encode()).hexdigest().upper()
|
||||||
|
down_rate = 0 if complete or not active else rng.randint(50_000, 8_000_000)
|
||||||
|
up_rate = 0 if not active else rng.randint(5_000, 2_000_000)
|
||||||
|
torrent = {
|
||||||
|
"hash": torrent_hash,
|
||||||
|
"name": f"Mock Torrent {index + 1:05d} - {label}",
|
||||||
|
"state": state,
|
||||||
|
"complete": 1 if complete else 0,
|
||||||
|
"size": size,
|
||||||
|
"completed": completed,
|
||||||
|
"ratio": rng.randint(0, 4500),
|
||||||
|
"up_rate": up_rate,
|
||||||
|
"down_rate": down_rate,
|
||||||
|
"up_total": int(size * rng.uniform(0.0, 3.0)),
|
||||||
|
"down_total": completed,
|
||||||
|
"peers": rng.randint(0, 150),
|
||||||
|
"seeds": rng.randint(0, 500),
|
||||||
|
"priority": rng.choice([0, 1, 2, 3]),
|
||||||
|
"directory": f"/mock/downloads/{label.split(',')[0]}",
|
||||||
|
"base_path": f"/mock/downloads/{label.split(',')[0]}/Mock Torrent {index + 1:05d}",
|
||||||
|
"created": now - rng.randint(60, 365 * 86400),
|
||||||
|
"label": label,
|
||||||
|
"ratio_group": rng.choice(["", "default", "long-seed", "archive"]),
|
||||||
|
"message": "Tracker timeout" if index % 97 == 0 else "",
|
||||||
|
"hashing": 1 if index % 211 == 0 else 0,
|
||||||
|
"is_active": 1 if active else 0,
|
||||||
|
"is_multi_file": 1,
|
||||||
|
"last_activity": now - rng.randint(0, 7 * 86400),
|
||||||
|
"completed_at": now - rng.randint(0, 180 * 86400) if complete else 0,
|
||||||
|
"trackers": rng.sample(TRACKERS, k=rng.randint(1, len(TRACKERS))),
|
||||||
|
"files": self.make_files(index, size, completed, rng),
|
||||||
|
"peers_list": self.make_peers(rng),
|
||||||
|
}
|
||||||
|
self.torrents.append(torrent)
|
||||||
|
self.reindex()
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def make_files(self, index: int, size: int, completed: int, rng: random.Random) -> list[dict[str, Any]]:
|
||||||
|
"""Split one torrent into plausible files with priorities and completion."""
|
||||||
|
file_count = rng.randint(1, 18)
|
||||||
|
remaining_size = size
|
||||||
|
remaining_done = completed
|
||||||
|
files = []
|
||||||
|
for file_index in range(file_count):
|
||||||
|
if file_index == file_count - 1:
|
||||||
|
file_size = remaining_size
|
||||||
|
else:
|
||||||
|
file_size = rng.randint(1, max(1, remaining_size // max(1, file_count - file_index)))
|
||||||
|
file_done = min(file_size, remaining_done)
|
||||||
|
remaining_size -= file_size
|
||||||
|
remaining_done -= file_done
|
||||||
|
chunks = max(1, file_size // (1024 * 1024))
|
||||||
|
files.append({
|
||||||
|
"path": f"Mock Torrent {index + 1:05d}/file-{file_index + 1:03d}.bin",
|
||||||
|
"size": file_size,
|
||||||
|
"completed_chunks": int(chunks * (file_done / file_size)) if file_size else 0,
|
||||||
|
"size_chunks": chunks,
|
||||||
|
"priority": rng.choice([0, 1, 1, 2]),
|
||||||
|
})
|
||||||
|
return files
|
||||||
|
|
||||||
|
def make_peers(self, rng: random.Random) -> list[list[Any]]:
|
||||||
|
"""Generate peer rows matching p.multicall fields used by pyTorrent."""
|
||||||
|
rows = []
|
||||||
|
for _ in range(rng.randint(3, 40)):
|
||||||
|
rows.append([
|
||||||
|
f"{rng.randint(11, 223)}.{rng.randint(0, 255)}.{rng.randint(0, 255)}.{rng.randint(1, 254)}",
|
||||||
|
rng.choice(CLIENTS),
|
||||||
|
rng.randint(0, 100),
|
||||||
|
rng.randint(0, 2_000_000),
|
||||||
|
rng.randint(0, 1_000_000),
|
||||||
|
rng.randint(1024, 65535),
|
||||||
|
rng.choice([0, 1]),
|
||||||
|
rng.choice([0, 1]),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
])
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def reindex(self) -> None:
|
||||||
|
self.by_hash = {str(t["hash"]): t for t in self.torrents}
|
||||||
|
|
||||||
|
def load(self) -> None:
|
||||||
|
"""Load optional persisted mock state for repeatable development sessions."""
|
||||||
|
data = json.loads(self.state_file.read_text(encoding="utf-8"))
|
||||||
|
self.config.update(data.get("config") or {})
|
||||||
|
self.torrents = list(data.get("torrents") or [])
|
||||||
|
self.reindex()
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
"""Persist state only when --persist is enabled; default state lasts until restart."""
|
||||||
|
if not self.persist or not self.state_file:
|
||||||
|
return
|
||||||
|
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = self.state_file.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps({"updated_at": human_now(), "config": self.config, "torrents": self.torrents}), encoding="utf-8")
|
||||||
|
tmp.replace(self.state_file)
|
||||||
|
|
||||||
|
def tick(self) -> None:
|
||||||
|
"""Advance speeds, totals and progress on each RPC request."""
|
||||||
|
now = int(time.time())
|
||||||
|
for index, torrent in enumerate(self.torrents):
|
||||||
|
if not torrent.get("state") or not torrent.get("is_active"):
|
||||||
|
torrent["down_rate"] = 0
|
||||||
|
torrent["up_rate"] = 0
|
||||||
|
continue
|
||||||
|
wobble = 0.75 + ((now + index) % 9) / 18
|
||||||
|
if torrent.get("complete"):
|
||||||
|
torrent["down_rate"] = 0
|
||||||
|
torrent["up_rate"] = int((20_000 + (index % 500) * 1500) * wobble)
|
||||||
|
torrent["up_total"] += max(0, int(torrent["up_rate"] / 3))
|
||||||
|
else:
|
||||||
|
torrent["down_rate"] = int((80_000 + (index % 700) * 9000) * wobble)
|
||||||
|
torrent["up_rate"] = int((5_000 + (index % 120) * 900) * wobble)
|
||||||
|
torrent["completed"] = min(torrent["size"], torrent["completed"] + max(1, int(torrent["down_rate"] / 2)))
|
||||||
|
torrent["down_total"] = torrent["completed"]
|
||||||
|
if torrent["completed"] >= torrent["size"]:
|
||||||
|
torrent["complete"] = 1
|
||||||
|
torrent["completed_at"] = now
|
||||||
|
torrent["last_activity"] = now
|
||||||
|
|
||||||
|
def torrent_row_value(self, torrent: dict[str, Any], field: str) -> Any:
|
||||||
|
"""Map rTorrent d.* fields to mock torrent values."""
|
||||||
|
mapping = {
|
||||||
|
"d.hash=": "hash", "d.name=": "name", "d.state=": "state", "d.complete=": "complete",
|
||||||
|
"d.size_bytes=": "size", "d.completed_bytes=": "completed", "d.ratio=": "ratio",
|
||||||
|
"d.up.rate=": "up_rate", "d.down.rate=": "down_rate", "d.up.total=": "up_total",
|
||||||
|
"d.down.total=": "down_total", "d.peers_connected=": "peers", "d.peers_complete=": "seeds",
|
||||||
|
"d.priority=": "priority", "d.directory=": "directory", "d.base_path=": "base_path",
|
||||||
|
"d.creation_date=": "created", "d.custom1=": "label", "d.custom=py_ratio_group": "ratio_group",
|
||||||
|
"d.message=": "message", "d.hashing=": "hashing", "d.is_active=": "is_active",
|
||||||
|
"d.is_multi_file=": "is_multi_file", "d.timestamp.last_active=": "last_activity",
|
||||||
|
"d.timestamp.finished=": "completed_at",
|
||||||
|
}
|
||||||
|
return torrent.get(mapping.get(field, ""), "")
|
||||||
|
|
||||||
|
def call(self, method: str, args: tuple[Any, ...]) -> Any:
|
||||||
|
"""Handle the subset of rTorrent XML-RPC methods needed by pyTorrent."""
|
||||||
|
with self.lock:
|
||||||
|
self.tick()
|
||||||
|
if method in self.config:
|
||||||
|
return self.config[method]
|
||||||
|
if method.endswith(".set") and method.replace(".set", "") in self.config:
|
||||||
|
value = args[-1] if args else 0
|
||||||
|
self.config[method.replace(".set", "")] = value
|
||||||
|
self.save()
|
||||||
|
return 0
|
||||||
|
if method == "d.multicall2":
|
||||||
|
fields = args[2:]
|
||||||
|
return [[self.torrent_row_value(t, f) for f in fields] for t in self.torrents]
|
||||||
|
if method == "d.multicall":
|
||||||
|
fields = args[1:]
|
||||||
|
return [[self.torrent_row_value(t, f) for f in fields] for t in self.torrents]
|
||||||
|
if method == "p.multicall":
|
||||||
|
torrent = self.by_hash.get(str(args[0]))
|
||||||
|
return torrent.get("peers_list", []) if torrent else []
|
||||||
|
if method == "f.multicall":
|
||||||
|
torrent = self.by_hash.get(str(args[0]))
|
||||||
|
fields = args[2:]
|
||||||
|
return [self.file_row(file, fields) for file in (torrent or {}).get("files", [])]
|
||||||
|
if method == "t.multicall":
|
||||||
|
torrent = self.by_hash.get(str(args[0]) or str(args[1] if len(args) > 1 else ""))
|
||||||
|
return [[tracker, 1, 120 + i, 30 + i, 5000 + i] for i, tracker in enumerate((torrent or {}).get("trackers", []))]
|
||||||
|
if method.startswith("d."):
|
||||||
|
return self.call_download_method(method, args)
|
||||||
|
if method.startswith("t."):
|
||||||
|
return self.call_tracker_method(method, args)
|
||||||
|
if method.startswith("f.priority.set"):
|
||||||
|
return 0
|
||||||
|
if method.startswith("load.raw"):
|
||||||
|
return self.add_loaded_torrent(args)
|
||||||
|
if method.startswith("execute"):
|
||||||
|
return self.call_execute(method, args)
|
||||||
|
raise Fault(1, f"Mock method not implemented: {method}")
|
||||||
|
|
||||||
|
|
||||||
|
def disk_usage_output(self, path: str) -> str:
|
||||||
|
"""Return df -Pk compatible disk usage for pyTorrent disk monitor calls."""
|
||||||
|
# Note: Mock disk usage is synthetic and stable, so the footer disk monitor can be tested without real mounts.
|
||||||
|
clean_path = str(path or self.config.get("directory.default") or "/mock/downloads")
|
||||||
|
if not clean_path.startswith("/"):
|
||||||
|
clean_path = f"/mock/downloads/{clean_path}"
|
||||||
|
total_kb = max(1, self.disk_total_bytes // 1024)
|
||||||
|
wave = ((int(time.time()) // 30) % 11 - 5) / 10
|
||||||
|
used_percent = max(0.0, min(99.9, self.disk_used_percent + wave))
|
||||||
|
used_kb = int(total_kb * used_percent / 100)
|
||||||
|
free_kb = max(0, total_kb - used_kb)
|
||||||
|
percent = int(round((used_kb / total_kb) * 100)) if total_kb else 0
|
||||||
|
return f"OK\t{total_kb}\t{used_kb}\t{free_kb}\t{percent}\t{clean_path}\n"
|
||||||
|
|
||||||
|
def browse_output(self, path: str) -> str:
|
||||||
|
"""Return a lightweight path browser response used by pyTorrent move/path pickers."""
|
||||||
|
clean_path = str(path or self.config.get("directory.default") or "/mock/downloads").rstrip("/") or "/"
|
||||||
|
dirs = ["movies", "series", "music", "linux", "archive", "incoming", "completed"]
|
||||||
|
lines = [f"D\t{name}\t{clean_path}/{name}" for name in dirs]
|
||||||
|
total_kb, used_kb, free_kb, percent = self.disk_df_parts()
|
||||||
|
lines.append(f"M\t{len(dirs)}\t{len(self.torrents)}")
|
||||||
|
lines.append(f"F\t{total_kb} {used_kb} {free_kb} {percent}%")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def disk_df_parts(self) -> tuple[int, int, int, int]:
|
||||||
|
"""Return total, used, free and percent values in KiB."""
|
||||||
|
total_kb = max(1, self.disk_total_bytes // 1024)
|
||||||
|
used_kb = int(total_kb * self.disk_used_percent / 100)
|
||||||
|
free_kb = max(0, total_kb - used_kb)
|
||||||
|
percent = int(round((used_kb / total_kb) * 100)) if total_kb else 0
|
||||||
|
return total_kb, used_kb, free_kb, percent
|
||||||
|
|
||||||
|
def call_execute(self, method: str, args: tuple[Any, ...]) -> str:
|
||||||
|
"""Handle shell-backed rTorrent helpers used for disk and path monitoring."""
|
||||||
|
marker_args = [str(item) for item in args]
|
||||||
|
if "pytorrent-df" in marker_args:
|
||||||
|
marker_index = marker_args.index("pytorrent-df")
|
||||||
|
path = marker_args[marker_index + 1] if marker_index + 1 < len(marker_args) else str(self.config.get("directory.default") or "/mock/downloads")
|
||||||
|
return self.disk_usage_output(path)
|
||||||
|
if "pytorrent-browse" in marker_args:
|
||||||
|
marker_index = marker_args.index("pytorrent-browse")
|
||||||
|
path = marker_args[marker_index + 1] if marker_index + 1 < len(marker_args) else str(self.config.get("directory.default") or "/mock/downloads")
|
||||||
|
return self.browse_output(path)
|
||||||
|
script = " ".join(marker_args)
|
||||||
|
if "/proc/stat" in script and "/proc/meminfo" in script:
|
||||||
|
return "17.4 61.2"
|
||||||
|
if "df -Pk" in script:
|
||||||
|
return self.disk_usage_output(str(self.config.get("directory.default") or "/mock/downloads"))
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def file_row(self, file: dict[str, Any], fields: tuple[Any, ...]) -> list[Any]:
|
||||||
|
"""Map rTorrent f.* fields to mock file values."""
|
||||||
|
mapping = {
|
||||||
|
"f.path=": "path", "f.size_bytes=": "size", "f.completed_chunks=": "completed_chunks",
|
||||||
|
"f.size_chunks=": "size_chunks", "f.priority=": "priority", "f.range_first=": "range_first",
|
||||||
|
"f.range_second=": "range_second",
|
||||||
|
}
|
||||||
|
return [file.get(mapping.get(str(field), ""), 0) for field in fields]
|
||||||
|
|
||||||
|
def call_download_method(self, method: str, args: tuple[Any, ...]) -> Any:
|
||||||
|
"""Read or mutate individual torrent attributes and state."""
|
||||||
|
torrent_hash = str(args[0] if args else "")
|
||||||
|
torrent = self.by_hash.get(torrent_hash)
|
||||||
|
if not torrent:
|
||||||
|
return "" if method not in {"d.state", "d.is_active", "d.is_multi_file"} else 0
|
||||||
|
readers = {
|
||||||
|
"d.name": "name", "d.state": "state", "d.directory": "directory", "d.base_path": "base_path",
|
||||||
|
"d.is_multi_file": "is_multi_file", "d.is_active": "is_active", "d.custom1": "label",
|
||||||
|
"d.bitfield": "bitfield",
|
||||||
|
}
|
||||||
|
if method in readers:
|
||||||
|
return torrent.get(readers[method], "")
|
||||||
|
if method == "d.custom1.set":
|
||||||
|
torrent["label"] = str(args[1] if len(args) > 1 else "")
|
||||||
|
elif method == "d.directory.set":
|
||||||
|
torrent["directory"] = str(args[1] if len(args) > 1 else torrent["directory"])
|
||||||
|
elif method == "d.custom.set":
|
||||||
|
if len(args) > 2 and str(args[1]) == "py_ratio_group":
|
||||||
|
torrent["ratio_group"] = str(args[2])
|
||||||
|
elif method in {"d.start", "d.open", "d.try_start", "d.resume"}:
|
||||||
|
torrent.update({"state": 1, "is_active": 1, "message": ""})
|
||||||
|
elif method in {"d.stop", "d.close", "d.pause"}:
|
||||||
|
torrent.update({"state": 0, "is_active": 0, "down_rate": 0, "up_rate": 0})
|
||||||
|
elif method == "d.check_hash":
|
||||||
|
torrent.update({"hashing": 1, "message": "Hash check queued"})
|
||||||
|
elif method == "d.update_priorities":
|
||||||
|
return 0
|
||||||
|
self.save()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def call_tracker_method(self, method: str, args: tuple[Any, ...]) -> Any:
|
||||||
|
"""Return tracker details for sidebar filters and detail panes."""
|
||||||
|
target = str(args[0] if args else "")
|
||||||
|
torrent_hash, _, suffix = target.partition(":t")
|
||||||
|
torrent = self.by_hash.get(torrent_hash)
|
||||||
|
index = int(suffix or 0) if suffix.isdigit() else 0
|
||||||
|
trackers = (torrent or {}).get("trackers", [])
|
||||||
|
if method == "t.url":
|
||||||
|
return trackers[index] if 0 <= index < len(trackers) else ""
|
||||||
|
if method == "t.is_enabled":
|
||||||
|
return 1
|
||||||
|
if method == "t.activity_time_last":
|
||||||
|
return int(time.time()) - 300
|
||||||
|
if method == "t.activity_time_next":
|
||||||
|
return int(time.time()) + 1800
|
||||||
|
if method == "t.scrape_time_last":
|
||||||
|
return int(time.time()) - 600
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def add_loaded_torrent(self, args: tuple[Any, ...]) -> int:
|
||||||
|
"""Add a lightweight mock torrent when the app uploads a torrent or magnet."""
|
||||||
|
index = len(self.torrents)
|
||||||
|
torrent_hash = hashlib.sha1(f"mock-added-{time.time()}-{index}".encode()).hexdigest().upper()
|
||||||
|
size = 1024 * 1024 * 1024
|
||||||
|
label = "mock-added"
|
||||||
|
directory = "/mock/downloads"
|
||||||
|
for item in args:
|
||||||
|
text = str(item)
|
||||||
|
if text.startswith("d.custom1.set="):
|
||||||
|
label = text.split("=", 1)[1]
|
||||||
|
if text.startswith("d.directory.set="):
|
||||||
|
directory = text.split("=", 1)[1]
|
||||||
|
self.torrents.append({
|
||||||
|
"hash": torrent_hash, "name": f"Mock Added Torrent {index + 1}", "state": 1, "complete": 0,
|
||||||
|
"size": size, "completed": 0, "ratio": 0, "up_rate": 0, "down_rate": 512_000,
|
||||||
|
"up_total": 0, "down_total": 0, "peers": 8, "seeds": 12, "priority": 1,
|
||||||
|
"directory": directory, "base_path": f"{directory}/Mock Added Torrent {index + 1}",
|
||||||
|
"created": int(time.time()), "label": label, "ratio_group": "", "message": "",
|
||||||
|
"hashing": 0, "is_active": 1, "is_multi_file": 1, "last_activity": int(time.time()),
|
||||||
|
"completed_at": 0, "trackers": TRACKERS[:2], "files": self.make_files(index, size, 0, random.Random(index)),
|
||||||
|
"peers_list": self.make_peers(random.Random(index)),
|
||||||
|
})
|
||||||
|
self.reindex()
|
||||||
|
self.save()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
class ScgiXmlRpcHandler(socketserver.BaseRequestHandler):
|
||||||
|
"""Single-request SCGI netstring parser that returns XML-RPC responses."""
|
||||||
|
|
||||||
|
state: MockRtorrentState
|
||||||
|
|
||||||
|
def handle(self) -> None:
|
||||||
|
try:
|
||||||
|
body = self.read_scgi_body()
|
||||||
|
params, method = loads(body)
|
||||||
|
result = self.state.call(method, tuple(params))
|
||||||
|
payload = dumps((xmlrpc_safe(result),), methodresponse=True, allow_none=True).encode("utf-8")
|
||||||
|
except Fault as exc:
|
||||||
|
payload = dumps(exc, allow_none=True).encode("utf-8")
|
||||||
|
except Exception as exc:
|
||||||
|
payload = dumps(Fault(1, f"Mock server error: {exc}"), allow_none=True).encode("utf-8")
|
||||||
|
header = f"Status: 200 OK\r\nContent-Type: text/xml\r\nContent-Length: {len(payload)}\r\n\r\n".encode("ascii")
|
||||||
|
self.request.sendall(header + payload)
|
||||||
|
|
||||||
|
def read_scgi_body(self) -> bytes:
|
||||||
|
"""Read SCGI headers and request body from a netstring frame."""
|
||||||
|
digits = bytearray()
|
||||||
|
while True:
|
||||||
|
char = self.request.recv(1)
|
||||||
|
if not char:
|
||||||
|
raise ConnectionError("empty SCGI request")
|
||||||
|
if char == b":":
|
||||||
|
break
|
||||||
|
digits.extend(char)
|
||||||
|
header_len = int(digits.decode("ascii"))
|
||||||
|
headers = self.recv_exact(header_len)
|
||||||
|
comma = self.recv_exact(1)
|
||||||
|
if comma != b",":
|
||||||
|
raise ValueError("invalid SCGI netstring")
|
||||||
|
parts = headers.split(b"\0")
|
||||||
|
header_map = {parts[i].decode(): parts[i + 1].decode() for i in range(0, len(parts) - 1, 2) if parts[i]}
|
||||||
|
return self.recv_exact(int(header_map.get("CONTENT_LENGTH", "0")))
|
||||||
|
|
||||||
|
def recv_exact(self, size: int) -> bytes:
|
||||||
|
"""Receive exactly size bytes or fail fast on disconnected clients."""
|
||||||
|
chunks = []
|
||||||
|
left = size
|
||||||
|
while left > 0:
|
||||||
|
chunk = self.request.recv(left)
|
||||||
|
if not chunk:
|
||||||
|
raise ConnectionError("client disconnected")
|
||||||
|
chunks.append(chunk)
|
||||||
|
left -= len(chunk)
|
||||||
|
return b"".join(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadingScgiServer(socketserver.ThreadingTCPServer):
|
||||||
|
allow_reuse_address = True
|
||||||
|
daemon_threads = True
|
||||||
|
|
||||||
|
|
||||||
|
def fallback_db_path() -> Path:
|
||||||
|
"""Resolve pyTorrent DB path without importing the Flask application package."""
|
||||||
|
raw = os.getenv("PYTORRENT_DB_PATH", str(BASE_DIR / "data" / "pytorrent.sqlite3"))
|
||||||
|
path = Path(raw)
|
||||||
|
return path if path.is_absolute() else BASE_DIR / path
|
||||||
|
|
||||||
|
|
||||||
|
def register_profile(host: str, port: int, name: str) -> None:
|
||||||
|
"""Create or update a pyTorrent profile pointing at this mock server."""
|
||||||
|
now = human_now()
|
||||||
|
scgi_url = f"scgi://{host}:{port}/RPC2"
|
||||||
|
db_path = fallback_db_path()
|
||||||
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with sqlite3.connect(db_path) as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT, email TEXT, display_name TEXT, external_auth_provider TEXT, external_subject TEXT, role TEXT DEFAULT 'user', is_active INTEGER DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT)")
|
||||||
|
conn.execute("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)")
|
||||||
|
conn.execute("INSERT OR IGNORE INTO users(id, username, role, is_active, created_at, updated_at) VALUES(1, 'admin', 'admin', 1, ?, ?)", (now, now))
|
||||||
|
row = conn.execute("SELECT id FROM rtorrent_profiles WHERE user_id=? AND name=?", (1, name)).fetchone()
|
||||||
|
if row:
|
||||||
|
conn.execute("UPDATE rtorrent_profiles SET scgi_url=?, timeout_seconds=?, is_remote=0, updated_at=? WHERE id=?", (scgi_url, 10, now, row["id"]))
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO rtorrent_profiles(user_id,name,scgi_url,is_default,timeout_seconds,is_remote,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)",
|
||||||
|
(1, name, scgi_url, 0, 10, 0, now, now),
|
||||||
|
)
|
||||||
|
print(f"Registered pyTorrent profile '{name}' -> {scgi_url}")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Run a large development rTorrent SCGI mock for pyTorrent.")
|
||||||
|
parser.add_argument("--host", default="127.0.0.1", help="SCGI bind host. Default: 127.0.0.1")
|
||||||
|
parser.add_argument("--port", type=int, default=5001, help="SCGI bind port. Default: 5001")
|
||||||
|
parser.add_argument("--count", type=int, default=int(os.getenv("PYTORRENT_MOCK_TORRENTS", "2500")), help="Number of generated torrents.")
|
||||||
|
parser.add_argument("--seed", type=int, default=42, help="Deterministic data seed.")
|
||||||
|
parser.add_argument("--persist", action="store_true", help="Persist mock state to JSON across restarts.")
|
||||||
|
parser.add_argument("--state-file", type=Path, default=DEFAULT_STATE_PATH, help="JSON state path used with --persist.")
|
||||||
|
parser.add_argument("--disk-total-gb", type=int, default=int(os.getenv("PYTORRENT_MOCK_DISK_TOTAL_GB", "4096")), help="Synthetic disk size exposed to pyTorrent disk monitor.")
|
||||||
|
parser.add_argument("--disk-used-percent", type=float, default=float(os.getenv("PYTORRENT_MOCK_DISK_USED_PERCENT", "68")), help="Synthetic used disk percentage exposed to pyTorrent disk monitor.")
|
||||||
|
parser.add_argument("--register-profile", action="store_true", help="Create or update a pyTorrent profile for this mock.")
|
||||||
|
parser.add_argument("--profile-name", default="Mock rTorrent", help="Profile name used with --register-profile.")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
state = MockRtorrentState(count=args.count, seed=args.seed, state_file=args.state_file, persist=args.persist, disk_total_gb=args.disk_total_gb, disk_used_percent=args.disk_used_percent)
|
||||||
|
ScgiXmlRpcHandler.state = state
|
||||||
|
if args.register_profile:
|
||||||
|
register_profile(args.host, args.port, args.profile_name)
|
||||||
|
with ThreadingScgiServer((args.host, args.port), ScgiXmlRpcHandler) as server:
|
||||||
|
print(f"Mock rTorrent SCGI listening on scgi://{args.host}:{args.port}/RPC2 with {len(state.torrents)} torrents")
|
||||||
|
print(f"Mock disk monitor: {args.disk_total_gb} GiB total, {args.disk_used_percent}% used")
|
||||||
|
print("Use Ctrl+C to stop. Without --persist, changes live only until restart.")
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nMock rTorrent stopped")
|
||||||
|
return 0
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -12,7 +12,7 @@ The installer is split into two layers:
|
|||||||
Run as root or through `sudo`:
|
Run as root or through `sudo`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh | sudo bash
|
curl -fsSL https://raw.githubusercontent.com/zdzichu6969/pyTorrent/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:
|
The bootstrap script downloads the current pyTorrent repository, detects the operating system family, and runs the matching installer:
|
||||||
@@ -46,14 +46,14 @@ Environment variables must be passed to the `sudo bash` process.
|
|||||||
Example:
|
Example:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
curl -fsSL https://raw.githubusercontent.com/zdzichu6969/pyTorrent/master/scripts/install_stack.sh \
|
||||||
| sudo PYTORRENT_PORT=8091 RTORRENT_SCGI_PORT=5001 bash
|
| sudo PYTORRENT_PORT=8091 RTORRENT_SCGI_PORT=5001 bash
|
||||||
```
|
```
|
||||||
|
|
||||||
Another example with a custom profile name:
|
Another example with a custom profile name:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
curl -fsSL https://raw.githubusercontent.com/zdzichu6969/pyTorrent/master/scripts/install_stack.sh \
|
||||||
| sudo PYTORRENT_PROFILE_NAME="Local rTorrent" PYTORRENT_PORT=8090 bash
|
| sudo PYTORRENT_PROFILE_NAME="Local rTorrent" PYTORRENT_PORT=8090 bash
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ These variables are used by `scripts/install_stack.sh`.
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `PYTORRENT_REPO_URL` | `https://git.linuxiarz.pl/gru/pyTorrent` | Git repository base URL. |
|
| `PYTORRENT_REPO_URL` | `https://github.com/zdzichu6969/pyTorrent` | GitHub repository base URL. |
|
||||||
| `PYTORRENT_REPO_BRANCH` | `master` | Branch used to download the repository archive. |
|
| `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_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_BOOTSTRAP_DIR` | `/tmp/pytorrent-stack-installer` | Temporary directory used by the bootstrap script. |
|
||||||
@@ -72,7 +72,7 @@ These variables are used by `scripts/install_stack.sh`.
|
|||||||
Example using a different branch:
|
Example using a different branch:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
curl -fsSL https://raw.githubusercontent.com/zdzichu6969/pyTorrent/master/scripts/install_stack.sh \
|
||||||
| sudo PYTORRENT_REPO_BRANCH=develop bash
|
| sudo PYTORRENT_REPO_BRANCH=develop bash
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -96,14 +96,14 @@ These variables are used by both stack installers.
|
|||||||
Example:
|
Example:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
curl -fsSL https://raw.githubusercontent.com/zdzichu6969/pyTorrent/master/scripts/install_stack.sh \
|
||||||
| sudo RTORRENT_USER=rtorrent RTORRENT_SCGI_PORT=5001 RTORRENT_TORRENT_PORT=51400 bash
|
| sudo RTORRENT_USER=rtorrent RTORRENT_SCGI_PORT=5001 RTORRENT_TORRENT_PORT=51400 bash
|
||||||
```
|
```
|
||||||
|
|
||||||
Classic xmlrpc-c backend instead of default tinyxml2. On Arch this forces source build:
|
Classic xmlrpc-c backend instead of default tinyxml2. On Arch this forces source build:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
curl -fsSL https://raw.githubusercontent.com/zdzichu6969/pyTorrent/master/scripts/install_stack.sh \
|
||||||
| sudo RTORRENT_WITH_XMLRPC_C=1 bash
|
| sudo RTORRENT_WITH_XMLRPC_C=1 bash
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/inst
|
|||||||
Example with API token:
|
Example with API token:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
|
curl -fsSL https://raw.githubusercontent.com/zdzichu6969/pyTorrent/master/scripts/install_stack.sh \
|
||||||
| sudo PYTORRENT_API_TOKEN="pt_xxx" bash
|
| sudo PYTORRENT_API_TOKEN="pt_xxx" bash
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user