changes in labels
This commit is contained in:
+1
-1
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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
@@ -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;
|
||||
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;
|
||||
|
||||
@@ -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">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 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">P — pause</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>
|
||||
|
||||
Reference in New Issue
Block a user