changes in labels

This commit is contained in:
Mateusz Gruszczyński
2026-06-09 09:45:47 +02:00
parent b32408562a
commit 348d7b8119
15 changed files with 725 additions and 94 deletions
+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>