Labels visual #23

Merged
gru merged 2 commits from labels_visual into master 2026-06-09 10:56:16 +02:00
15 changed files with 725 additions and 94 deletions
Showing only changes of commit 348d7b8119 - Show all commits
+2 -1
View File
@@ -40,8 +40,9 @@ data/logs/*
!data/logs/
!data/logs/README.md
todo.txt
!pytorrent/static/libs/pytorrent-themes/
!pytorrent/static/libs/pytorrent-themes/**
*/static/libs/
smart_queue_scoring_todo.md
data/mock_rtorrent_state.json
+1 -1
View File
@@ -108,5 +108,5 @@ LOG_ENABLE = _env_bool("PYTORRENT_LOG_ENABLE", True)
LOG_DIR = Path(os.getenv("PYTORRENT_LOG_DIR", "data/logs"))
if not LOG_DIR.is_absolute():
LOG_DIR = BASE_DIR / LOG_DIR
SMART_QUEUE_LABEL = os.getenv("PYTORRENT_SMART_QUEUE_L.ABEL", "Smart Queue Stopped")
SMART_QUEUE_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")
+6 -55
View File
@@ -4,6 +4,7 @@ import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
from .config import DB_PATH
from .migrations import run_database_migrations
SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
@@ -84,6 +85,8 @@ CREATE TABLE IF NOT EXISTS profile_preferences (
port_check_enabled INTEGER DEFAULT 0,
tracker_favicons_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,
updated_at TEXT NOT NULL,
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:
"""Create the current database schema without running legacy migrations."""
"""Create the current database schema definition."""
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:
"""Ensure the built-in admin user and default preferences exist."""
now = utcnow()
@@ -623,17 +577,14 @@ def connect():
def init_db():
"""Initialize SQLite using the current schema only.
Note: migration execution is intentionally not part of this flow.
"""
"""Initialize SQLite, applying the current schema and idempotent migrations."""
with connect() as conn:
try:
conn.execute("PRAGMA journal_mode = WAL")
except sqlite3.OperationalError:
pass
create_schema(conn)
ensure_profile_scoped_disk_monitor_preferences(conn)
run_database_migrations(conn)
seed_default_user(conn)
try:
from .services.auth import ensure_admin_user
+101 -6
View File
@@ -1,15 +1,110 @@
from __future__ import annotations
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:
"""Run pending database migrations."""
"""Run idempotent database migrations and return how many changed the schema/data."""
applied = 0
for sql in MIGRATIONS:
conn.execute(sql)
applied += 1
for migration in MIGRATIONS:
if migration(conn):
applied += 1
return applied
+8
View File
@@ -1178,6 +1178,14 @@
},
"tracker_favicons_enabled": {
"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"
+3 -1
View File
@@ -10,7 +10,7 @@ import zipfile
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 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
# for favicon
@@ -218,6 +218,8 @@ def index():
auth_provider=auth.provider(),
external_auth=auth.uses_external_provider(),
current_user=auth.current_user(),
smart_queue_label=SMART_QUEUE_LABEL,
smart_queue_stalled_label=SMART_QUEUE_STALLED_LABEL,
)
+14 -4
View File
@@ -377,7 +377,7 @@ def _seed_profile_preferences(conn, user_id: int, profile_id: int) -> dict:
return dict(row)
# Note: First profile preference row is seeded from legacy user-level values so upgrades keep the current layout/filter behavior.
conn.execute(
"INSERT INTO profile_preferences(user_id,profile_id,table_columns_json,torrent_sort_json,active_filter,peers_refresh_seconds,port_check_enabled,tracker_favicons_enabled,reverse_dns_enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
"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,
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("tracker_favicons_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,
),
@@ -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:
# Note: Reverse DNS is stored per profile because PTR lookups depend on swarm size and profile network latency.
updates["reverse_dns_enabled"] = 1 if data.get("reverse_dns_enabled") else 0
if data.get("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:
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 "{}")
@@ -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()
if not value or len(value) > 180:
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:"):
value = "all"
updates["active_filter"] = value
@@ -448,8 +456,8 @@ def save_profile_preferences(user_id: int, profile_id: int | None, data: dict) -
return
merged = {**current, **updates}
conn.execute(
"INSERT INTO profile_preferences(user_id,profile_id,table_columns_json,torrent_sort_json,active_filter,peers_refresh_seconds,port_check_enabled,tracker_favicons_enabled,reverse_dns_enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?) "
"ON CONFLICT(user_id,profile_id) DO UPDATE SET table_columns_json=excluded.table_columns_json, torrent_sort_json=excluded.torrent_sort_json, active_filter=excluded.active_filter, peers_refresh_seconds=excluded.peers_refresh_seconds, port_check_enabled=excluded.port_check_enabled, tracker_favicons_enabled=excluded.tracker_favicons_enabled, reverse_dns_enabled=excluded.reverse_dns_enabled, updated_at=excluded.updated_at",
"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, sidebar_labels_expanded=excluded.sidebar_labels_expanded, sidebar_shortcuts_expanded=excluded.sidebar_shortcuts_expanded, updated_at=excluded.updated_at",
(
user_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("tracker_favicons_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,
now,
),
+5 -6
View File
@@ -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:
# Note: Stalled is treated case-insensitively so manually edited labels still block Smart Queue.
target = SMART_QUEUE_STALLED_LABEL.casefold()
return any(label.casefold() == target for label in _label_names(value))
# Note: Stalled is an exact technical label; lower-case variants are normal user labels.
return SMART_QUEUE_STALLED_LABEL in _label_names(value)
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:
labels = [label for label in _label_names(current_label) if label != SMART_QUEUE_LABEL]
changed = False
if not any(label.casefold() == SMART_QUEUE_STALLED_LABEL.casefold() for label in labels):
if SMART_QUEUE_STALLED_LABEL not in labels:
labels.append(SMART_QUEUE_STALLED_LABEL)
changed = True
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:
"""Return labels without Smart Queue's Stalled marker."""
# 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:
"""Remove the Stalled marker from a torrent that is active again."""
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
try:
# Note: Active downloads must not keep the Stalled marker after they resume transferring.
+1 -1
View File
@@ -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
+38 -4
View File
@@ -353,8 +353,9 @@ body {
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.1rem 0.45rem;
align-items: center;
margin-bottom: 0.12rem;
padding: 0.34rem 0.5rem;
margin-bottom: 0.08rem;
/* Note: Main sidebar filters are intentionally compact so labels and tools fit without extra scrolling. */
padding: 0.25rem 0.45rem;
border: 0;
border-radius: 0.55rem;
background: transparent;
@@ -387,9 +388,9 @@ body {
display: block;
margin-top: 0.05rem;
color: var(--bs-secondary-color);
font-size: 0.68rem;
font-size: 0.64rem;
font-weight: 400;
line-height: 1.15;
line-height: 1.05;
opacity: 0.72;
overflow: hidden;
text-overflow: ellipsis;
@@ -405,6 +406,39 @@ body {
color: var(--bs-secondary-color);
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 {
min-width: 0;
min-height: 0;
+17 -12
View File
@@ -78,17 +78,22 @@
<div id="labelFilters" class="label-filters mt-2"></div>
<div id="trackerFilters" class="tracker-filters mt-2"></div>
<hr>
<div class="small text-muted px-2">Shortcuts</div>
<div class="shortcut">Ctrl+A — select visible</div>
<div class="shortcut">Ctrl+I — invert visible</div>
<div class="shortcut">Space — start</div>
<div class="shortcut">P — pause</div>
<div class="shortcut">S — stop</div>
<div class="shortcut">R — resume</div>
<div class="shortcut">Mmove</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 class="small text-muted px-2 mb-1">Shortcuts</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 id="shortcutList" class="sidebar-collapsible is-collapsed">
<!-- Note: Keyboard shortcut help is collapsed by default and persisted with the profile sidebar preferences. -->
<div class="shortcut">Ctrl+A — select visible</div>
<div class="shortcut">Ctrl+I — invert visible</div>
<div class="shortcut">Space — start</div>
<div class="shortcut">Ppause</div>
<div class="shortcut">S — stop</div>
<div class="shortcut">R — resume</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>
<section class="content">
@@ -407,7 +412,7 @@
<div id="toastHost" class="toast-host"></div>
<script src="{{ frontend_asset_url('socket_io_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. -->
<!-- <script src="{{ static_url('app.js') }}"></script> -->
<script type="module" src="{{ static_url('js/app.js') }}"></script>
Executable
+526
View File
@@ -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())