Cleanup in js #16

Merged
gru merged 11 commits from cleanup_in_js into master 2026-06-02 23:02:29 +02:00
75 changed files with 693 additions and 294 deletions
+1
View File
@@ -44,3 +44,4 @@ data/logs/*
todo.txt todo.txt
!pytorrent/static/libs/pytorrent-themes/ !pytorrent/static/libs/pytorrent-themes/
!pytorrent/static/libs/pytorrent-themes/** !pytorrent/static/libs/pytorrent-themes/**
smart_queue_scoring_todo.md
+9 -11
View File
@@ -16,11 +16,9 @@ from .config import (
SOCKETIO_CORS_ALLOWED_ORIGINS, SOCKETIO_CORS_ALLOWED_ORIGINS,
) )
from .db import init_db from .db import init_db
from .services.frontend_assets import asset_path, bootstrap_css_path, validate_offline_assets from .services.frontend_assets import asset_path, bootstrap_css_path, initialize_static_hash, static_hash, validate_offline_assets
from .utils import file_md5
socketio = SocketIO(cors_allowed_origins=SOCKETIO_CORS_ALLOWED_ORIGINS, ping_timeout=30, async_mode="threading") socketio = SocketIO(cors_allowed_origins=SOCKETIO_CORS_ALLOWED_ORIGINS, ping_timeout=30, async_mode="threading")
_static_md5_cache: dict[tuple, str] = {}
def _wants_json_response() -> bool: def _wants_json_response() -> bool:
@@ -58,6 +56,7 @@ def register_error_pages(app: Flask) -> None:
def create_app() -> Flask: def create_app() -> Flask:
validate_offline_assets() validate_offline_assets()
app = Flask(__name__) app = Flask(__name__)
initialize_static_hash(Path(app.static_folder or ""))
from .logging_config import configure_logging from .logging_config import configure_logging
configure_logging(app) configure_logging(app)
if PROXY_FIX_ENABLE: if PROXY_FIX_ENABLE:
@@ -78,17 +77,15 @@ def create_app() -> Flask:
@app.context_processor @app.context_processor
def static_helpers(): def static_helpers():
def current_static_hash() -> str:
return static_hash(Path(app.static_folder or ""))
def static_url(filename: str) -> str: def static_url(filename: str) -> str:
path = Path(app.static_folder or "") / filename path = Path(app.static_folder or "") / filename
try: try:
stat = path.stat() path.stat()
key = (filename, stat.st_mtime_ns, stat.st_size) # Note: A single JS/CSS hash keeps module imports, stylesheets and local libraries on the same cache version.
version = _static_md5_cache.get(key) return url_for("static", filename=filename, v=current_static_hash())
if not version:
_static_md5_cache.clear()
version = file_md5(path)
_static_md5_cache[key] = version
return url_for("static", filename=filename, v=version)
except OSError: except OSError:
return url_for("static", filename=filename) return url_for("static", filename=filename)
@@ -104,6 +101,7 @@ def create_app() -> Flask:
"static_url": static_url, "static_url": static_url,
"frontend_asset_url": frontend_asset_url, "frontend_asset_url": frontend_asset_url,
"bootstrap_theme_url": bootstrap_theme_url, "bootstrap_theme_url": bootstrap_theme_url,
"static_hash": current_static_hash,
} }
@app.after_request @app.after_request
+10 -2
View File
@@ -289,6 +289,10 @@ CREATE TABLE IF NOT EXISTS smart_queue_settings (
refill_enabled INTEGER DEFAULT 1, refill_enabled INTEGER DEFAULT 1,
refill_interval_minutes INTEGER DEFAULT 0, refill_interval_minutes INTEGER DEFAULT 0,
last_refill_at TEXT, last_refill_at TEXT,
surge_refill_enabled INTEGER DEFAULT 0,
surge_refill_interval_minutes INTEGER DEFAULT 1440,
surge_refill_batch_size INTEGER DEFAULT 2000,
last_surge_refill_at TEXT,
stop_batch_size INTEGER DEFAULT 50, stop_batch_size INTEGER DEFAULT 50,
start_grace_seconds INTEGER DEFAULT 900, start_grace_seconds INTEGER DEFAULT 900,
protect_active_below_cap INTEGER DEFAULT 1, protect_active_below_cap INTEGER DEFAULT 1,
@@ -573,6 +577,10 @@ MIGRATIONS = [
"ALTER TABLE smart_queue_settings ADD COLUMN refill_enabled INTEGER DEFAULT 1", "ALTER TABLE smart_queue_settings ADD COLUMN refill_enabled INTEGER DEFAULT 1",
"ALTER TABLE smart_queue_settings ADD COLUMN refill_interval_minutes INTEGER DEFAULT 0", "ALTER TABLE smart_queue_settings ADD COLUMN refill_interval_minutes INTEGER DEFAULT 0",
"ALTER TABLE smart_queue_settings ADD COLUMN last_refill_at TEXT", "ALTER TABLE smart_queue_settings ADD COLUMN last_refill_at TEXT",
"ALTER TABLE smart_queue_settings ADD COLUMN surge_refill_enabled INTEGER DEFAULT 0",
"ALTER TABLE smart_queue_settings ADD COLUMN surge_refill_interval_minutes INTEGER DEFAULT 1440",
"ALTER TABLE smart_queue_settings ADD COLUMN surge_refill_batch_size INTEGER DEFAULT 2000",
"ALTER TABLE smart_queue_settings ADD COLUMN last_surge_refill_at TEXT",
"ALTER TABLE smart_queue_settings ADD COLUMN stop_batch_size INTEGER DEFAULT 50", "ALTER TABLE smart_queue_settings ADD COLUMN stop_batch_size INTEGER DEFAULT 50",
"ALTER TABLE smart_queue_settings ADD COLUMN start_grace_seconds INTEGER DEFAULT 900", "ALTER TABLE smart_queue_settings ADD COLUMN start_grace_seconds INTEGER DEFAULT 900",
"ALTER TABLE smart_queue_settings ADD COLUMN protect_active_below_cap INTEGER DEFAULT 1", "ALTER TABLE smart_queue_settings ADD COLUMN protect_active_below_cap INTEGER DEFAULT 1",
@@ -665,8 +673,8 @@ PROFILE_ONLY_TABLES = {
"indexes": ["CREATE INDEX IF NOT EXISTS idx_rss_history_profile_created ON rss_history(profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_rss_history_profile_status ON rss_history(profile_id, status)", "CREATE UNIQUE INDEX IF NOT EXISTS idx_rss_history_unique_success ON rss_history(profile_id, COALESCE(rule_id,0), link) WHERE status IN ('queued','added')"], "indexes": ["CREATE INDEX IF NOT EXISTS idx_rss_history_profile_created ON rss_history(profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_rss_history_profile_status ON rss_history(profile_id, status)", "CREATE UNIQUE INDEX IF NOT EXISTS idx_rss_history_unique_success ON rss_history(profile_id, COALESCE(rule_id,0), link) WHERE status IN ('queued','added')"],
}, },
"smart_queue_settings": { "smart_queue_settings": {
"columns": "profile_id INTEGER NOT NULL, enabled INTEGER DEFAULT 0, max_active_downloads INTEGER DEFAULT 5, stalled_seconds INTEGER DEFAULT 300, min_speed_bytes INTEGER DEFAULT 1024, min_seeds INTEGER DEFAULT 1, min_peers INTEGER DEFAULT 0, ignore_seed_peer INTEGER DEFAULT 0, ignore_speed INTEGER DEFAULT 0, manage_stopped INTEGER DEFAULT 0, cooldown_minutes INTEGER DEFAULT 10, last_run_at TEXT, refill_enabled INTEGER DEFAULT 1, refill_interval_minutes INTEGER DEFAULT 0, last_refill_at TEXT, stop_batch_size INTEGER DEFAULT 50, start_grace_seconds INTEGER DEFAULT 900, protect_active_below_cap INTEGER DEFAULT 1, prefer_partial_progress INTEGER DEFAULT 1, auto_stop_idle INTEGER DEFAULT 0, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id)", "columns": "profile_id INTEGER NOT NULL, enabled INTEGER DEFAULT 0, max_active_downloads INTEGER DEFAULT 5, stalled_seconds INTEGER DEFAULT 300, min_speed_bytes INTEGER DEFAULT 1024, min_seeds INTEGER DEFAULT 1, min_peers INTEGER DEFAULT 0, ignore_seed_peer INTEGER DEFAULT 0, ignore_speed INTEGER DEFAULT 0, manage_stopped INTEGER DEFAULT 0, cooldown_minutes INTEGER DEFAULT 10, last_run_at TEXT, refill_enabled INTEGER DEFAULT 1, refill_interval_minutes INTEGER DEFAULT 0, last_refill_at TEXT, surge_refill_enabled INTEGER DEFAULT 0, surge_refill_interval_minutes INTEGER DEFAULT 1440, surge_refill_batch_size INTEGER DEFAULT 2000, last_surge_refill_at TEXT, stop_batch_size INTEGER DEFAULT 50, start_grace_seconds INTEGER DEFAULT 900, protect_active_below_cap INTEGER DEFAULT 1, prefer_partial_progress INTEGER DEFAULT 1, auto_stop_idle INTEGER DEFAULT 0, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id)",
"copy": ["profile_id", "enabled", "max_active_downloads", "stalled_seconds", "min_speed_bytes", "min_seeds", "min_peers", "ignore_seed_peer", "ignore_speed", "manage_stopped", "cooldown_minutes", "last_run_at", "refill_enabled", "refill_interval_minutes", "last_refill_at", "stop_batch_size", "start_grace_seconds", "protect_active_below_cap", "prefer_partial_progress", "auto_stop_idle", "updated_at"], "copy": ["profile_id", "enabled", "max_active_downloads", "stalled_seconds", "min_speed_bytes", "min_seeds", "min_peers", "ignore_seed_peer", "ignore_speed", "manage_stopped", "cooldown_minutes", "last_run_at", "refill_enabled", "refill_interval_minutes", "last_refill_at", "surge_refill_enabled", "surge_refill_interval_minutes", "surge_refill_batch_size", "last_surge_refill_at", "stop_batch_size", "start_grace_seconds", "protect_active_below_cap", "prefer_partial_progress", "auto_stop_idle", "updated_at"],
"indexes": [], "indexes": [],
}, },
"smart_queue_exclusions": { "smart_queue_exclusions": {
+48
View File
@@ -1359,6 +1359,9 @@
}, },
"settings": { "settings": {
"$ref": "#/components/schemas/SmartQueueSettings" "$ref": "#/components/schemas/SmartQueueSettings"
},
"surge_refill_remaining_seconds": {
"type": "integer"
} }
}, },
"type": "object" "type": "object"
@@ -1422,6 +1425,17 @@
"stop_batch_size": { "stop_batch_size": {
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
},
"surge_refill_enabled": {
"type": "boolean"
},
"surge_refill_interval_minutes": {
"type": "integer",
"minimum": 1
},
"surge_refill_batch_size": {
"type": "integer",
"minimum": 1
} }
}, },
"type": "object" "type": "object"
@@ -7479,6 +7493,40 @@
} }
} }
} }
},
"/api/static_hash": {
"get": {
"tags": [
"System"
],
"summary": "Get current frontend JS/CSS hash",
"description": "Returns the startup-computed hash for app JavaScript and CSS assets. The value is kept in memory and returned without scanning static files per request.",
"responses": {
"200": {
"description": "Static asset hash",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean"
},
"static_hash": {
"type": "string",
"description": "Short SHA-256-based hash of frontend JavaScript and CSS files computed once at app startup."
},
"version": {
"type": "string",
"description": "Alias of static_hash for simple client version checks."
}
}
}
}
}
}
}
}
} }
} }
} }
+2 -2
View File
@@ -14,7 +14,7 @@ def smart_queue_get():
exclusions = smart_queue.list_exclusions(profile['id']) exclusions = smart_queue.list_exclusions(profile['id'])
history = smart_queue.list_history(profile['id'], limit=history_limit) history = smart_queue.list_history(profile['id'], limit=history_limit)
history_total = smart_queue.count_history(profile['id']) history_total = smart_queue.count_history(profile['id'])
return ok({'settings': settings, 'exclusions': exclusions, 'history': history, 'history_total': history_total, 'cooldown_remaining_seconds': smart_queue.cooldown_remaining(settings), 'refill_remaining_seconds': smart_queue.refill_remaining(settings)}) return ok({'settings': settings, 'exclusions': exclusions, 'history': history, 'history_total': history_total, 'cooldown_remaining_seconds': smart_queue.cooldown_remaining(settings), 'refill_remaining_seconds': smart_queue.refill_remaining(settings), 'surge_refill_remaining_seconds': smart_queue.surge_refill_remaining(settings)})
except Exception as exc: except Exception as exc:
return jsonify({'ok': False, 'error': str(exc), 'settings': {}, 'exclusions': []}) return jsonify({'ok': False, 'error': str(exc), 'settings': {}, 'exclusions': []})
@@ -29,7 +29,7 @@ def smart_queue_save():
try: try:
payload = request.get_json(silent=True) or {} payload = request.get_json(silent=True) or {}
settings = smart_queue.save_settings(profile['id'], payload) settings = smart_queue.save_settings(profile['id'], payload)
return ok({'settings': settings, 'cooldown_remaining_seconds': smart_queue.cooldown_remaining(settings), 'refill_remaining_seconds': smart_queue.refill_remaining(settings)}) return ok({'settings': settings, 'cooldown_remaining_seconds': smart_queue.cooldown_remaining(settings), 'refill_remaining_seconds': smart_queue.refill_remaining(settings), 'surge_refill_remaining_seconds': smart_queue.surge_refill_remaining(settings)})
except Exception as exc: except Exception as exc:
return jsonify({'ok': False, 'error': str(exc)}) return jsonify({'ok': False, 'error': str(exc)})
+8
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from ._shared import * from ._shared import *
from ..services import operation_logs from ..services import operation_logs
from ..services.frontend_assets import static_hash
@bp.get("/system/disk") @bp.get("/system/disk")
def system_disk(): def system_disk():
@@ -46,6 +47,13 @@ def system_status():
@bp.get("/static_hash")
def static_hash_get():
# Note: This returns the startup-computed JS/CSS version without scanning files per request.
value = static_hash()
return ok({"static_hash": value, "version": value})
@bp.get("/health") @bp.get("/health")
def health_check(): def health_check():
# Note: Lightweight health endpoint avoids rTorrent calls, making it safe for frequent monitoring. # Note: Lightweight health endpoint avoids rTorrent calls, making it safe for frequent monitoring.
+67
View File
@@ -188,3 +188,70 @@ def validate_offline_assets() -> None:
"Run: ./scripts/download_frontend_libs.py or ./install.sh\n" "Run: ./scripts/download_frontend_libs.py or ./install.sh\n"
f"Missing files:\n{preview}{extra}" f"Missing files:\n{preview}{extra}"
) )
_STATIC_HASH_VALUE = "dev"
_STATIC_HASH_READY = False
def _versioned_static_files(root: Path) -> list[Path]:
"""Return static files that should invalidate frontend JS/CSS caches.
Note: Only JavaScript and CSS affect the executable frontend version. Images,
favicons and user-provided tracker icons stay outside this lightweight hash.
"""
return [
path
for path in root.rglob("*")
if path.is_file()
and path.suffix.lower() in {".js", ".css"}
and "tracker_favicons" not in path.parts
]
def compute_static_hash(static_root: Path | None = None) -> str:
"""Compute one short startup hash for frontend JavaScript and CSS files.
Note: This function reads JS/CSS files and should be called during app
startup, not from frequent request handlers.
"""
import hashlib
root = static_root or (BASE_DIR / "pytorrent" / "static")
digest = hashlib.sha256()
files = sorted(_versioned_static_files(root), key=lambda item: item.as_posix())
for path in files:
rel = path.relative_to(root).as_posix()
try:
stat = path.stat()
content = path.read_bytes()
except OSError:
continue
digest.update(rel.encode("utf-8"))
digest.update(str(stat.st_size).encode("ascii"))
digest.update(content)
value = digest.hexdigest()[:16]
return value or "dev"
def initialize_static_hash(static_root: Path | None = None) -> str:
"""Compute and store the frontend static hash once for this process.
Note: The API endpoint and template helpers only return this in-memory value,
which keeps mobile version checks ultra-light.
"""
global _STATIC_HASH_VALUE, _STATIC_HASH_READY
_STATIC_HASH_VALUE = compute_static_hash(static_root)
_STATIC_HASH_READY = True
return _STATIC_HASH_VALUE
def static_hash(static_root: Path | None = None) -> str:
"""Return the startup frontend static hash without rescanning files.
Note: The optional argument is kept for compatibility with existing callers;
it is only used for a lazy fallback before app startup initialization.
"""
if not _STATIC_HASH_READY:
return initialize_static_hash(static_root)
return _STATIC_HASH_VALUE
+308 -13
View File
@@ -154,6 +154,10 @@ def _default_settings(profile_id: int) -> dict[str, Any]:
'refill_enabled': 1, 'refill_enabled': 1,
'refill_interval_minutes': 0, 'refill_interval_minutes': 0,
'last_refill_at': None, 'last_refill_at': None,
'surge_refill_enabled': 0,
'surge_refill_interval_minutes': 1440,
'surge_refill_batch_size': 2000,
'last_surge_refill_at': None,
'stop_batch_size': 50, 'stop_batch_size': 50,
'start_grace_seconds': 900, 'start_grace_seconds': 900,
'protect_active_below_cap': 1, 'protect_active_below_cap': 1,
@@ -213,11 +217,15 @@ def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = N
# Note: Refill can be disabled, use the existing poller cadence, or run on a user-defined minute interval. # Note: Refill can be disabled, use the existing poller cadence, or run on a user-defined minute interval.
settings['refill_enabled'] = 0 if refill_mode == 'off' else 1 settings['refill_enabled'] = 0 if refill_mode == 'off' else 1
settings['refill_interval_minutes'] = _int_setting(data, current, 'refill_interval_minutes', 5, 1) if refill_mode == 'custom' else 0 settings['refill_interval_minutes'] = _int_setting(data, current, 'refill_interval_minutes', 5, 1) if refill_mode == 'custom' else 0
# Note: Surge refill is a separate periodic over-cap starter; it never changes the normal target limit.
settings['surge_refill_enabled'] = 1 if data.get('surge_refill_enabled', current.get('surge_refill_enabled', 0)) else 0
settings['surge_refill_interval_minutes'] = _int_setting(data, current, 'surge_refill_interval_minutes', 1440, 1)
settings['surge_refill_batch_size'] = _int_setting(data, current, 'surge_refill_batch_size', 2000, 1)
now = utcnow() now = utcnow()
with connect() as conn: with connect() as conn:
conn.execute( conn.execute(
'''INSERT INTO smart_queue_settings(profile_id,enabled,max_active_downloads,stalled_seconds,min_speed_bytes,min_seeds,min_peers,ignore_seed_peer,ignore_speed,manage_stopped,cooldown_minutes,stop_batch_size,start_grace_seconds,protect_active_below_cap,prefer_partial_progress,auto_stop_idle,refill_enabled,refill_interval_minutes,updated_at) '''INSERT INTO smart_queue_settings(profile_id,enabled,max_active_downloads,stalled_seconds,min_speed_bytes,min_seeds,min_peers,ignore_seed_peer,ignore_speed,manage_stopped,cooldown_minutes,stop_batch_size,start_grace_seconds,protect_active_below_cap,prefer_partial_progress,auto_stop_idle,refill_enabled,refill_interval_minutes,surge_refill_enabled,surge_refill_interval_minutes,surge_refill_batch_size,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(profile_id) DO UPDATE SET ON CONFLICT(profile_id) DO UPDATE SET
enabled=excluded.enabled, enabled=excluded.enabled,
max_active_downloads=excluded.max_active_downloads, max_active_downloads=excluded.max_active_downloads,
@@ -236,8 +244,11 @@ def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = N
auto_stop_idle=excluded.auto_stop_idle, auto_stop_idle=excluded.auto_stop_idle,
refill_enabled=excluded.refill_enabled, refill_enabled=excluded.refill_enabled,
refill_interval_minutes=excluded.refill_interval_minutes, refill_interval_minutes=excluded.refill_interval_minutes,
surge_refill_enabled=excluded.surge_refill_enabled,
surge_refill_interval_minutes=excluded.surge_refill_interval_minutes,
surge_refill_batch_size=excluded.surge_refill_batch_size,
updated_at=excluded.updated_at''', updated_at=excluded.updated_at''',
(profile_id, settings['enabled'], settings['max_active_downloads'], settings['stalled_seconds'], settings['min_speed_bytes'], settings['min_seeds'], settings['min_peers'], settings['ignore_seed_peer'], settings['ignore_speed'], settings['manage_stopped'], settings['cooldown_minutes'], settings['stop_batch_size'], settings['start_grace_seconds'], settings['protect_active_below_cap'], settings['prefer_partial_progress'], settings['auto_stop_idle'], settings['refill_enabled'], settings['refill_interval_minutes'], now), (profile_id, settings['enabled'], settings['max_active_downloads'], settings['stalled_seconds'], settings['min_speed_bytes'], settings['min_seeds'], settings['min_peers'], settings['ignore_seed_peer'], settings['ignore_speed'], settings['manage_stopped'], settings['cooldown_minutes'], settings['stop_batch_size'], settings['start_grace_seconds'], settings['protect_active_below_cap'], settings['prefer_partial_progress'], settings['auto_stop_idle'], settings['refill_enabled'], settings['refill_interval_minutes'], settings['surge_refill_enabled'], settings['surge_refill_interval_minutes'], settings['surge_refill_batch_size'], now),
) )
return get_settings(profile_id, user_id) return get_settings(profile_id, user_id)
@@ -406,6 +417,27 @@ def _ensure_stalled_label(client: Any, torrent_hash: str, current_label: str = '
except Exception: except Exception:
return False return False
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()])
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):
return False
try:
# Note: Active downloads must not keep the Stalled marker after they resume transferring.
client.call('d.custom1.set', torrent_hash, _without_stalled_label(current_label))
return True
except Exception:
return False
def _remember_auto_label(profile_id: int, torrent_hash: str, previous_label: str) -> None: def _remember_auto_label(profile_id: int, torrent_hash: str, previous_label: str) -> None:
now = utcnow() now = utcnow()
with connect() as conn: with connect() as conn:
@@ -1152,6 +1184,234 @@ def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_i
'start_source_skipped': len(source_skipped), 'start_source_skipped': len(source_skipped),
'checked': len(torrents), 'checked': len(torrents),
'excluded': len(user_excluded), 'excluded': len(user_excluded),
'rtorrent_cap': rtorrent_cap,
'settings': settings,
}
def surge_refill_remaining(settings: dict[str, Any]) -> int:
"""Return seconds until the next over-cap Surge refill may run."""
# Note: Surge refill has its own timer because it intentionally starts more torrents than the normal cap.
if not int(settings.get('surge_refill_enabled') or 0):
return 0
minutes = int(settings.get('surge_refill_interval_minutes') or 0)
if minutes <= 0:
return 0
last = _ts(settings.get('last_surge_refill_at'))
if not last:
return 0
return max(0, int((last + minutes * 60) - time.time()))
def _mark_surge_refill_run(profile_id: int, user_id: int) -> None:
# Note: The over-cap refill timer is updated even when no candidates are found, preventing tight retry loops.
with connect() as conn:
conn.execute('UPDATE smart_queue_settings SET last_surge_refill_at=?, updated_at=? WHERE profile_id=?', (utcnow(), utcnow(), profile_id))
def _surge_refill_over_limit(profile: dict, settings: dict[str, Any], profile_id: int, user_id: int) -> dict[str, Any]:
"""Start a large user-defined batch above the Smart Queue cap, then let normal checks drain it."""
# Note: Surge refill never raises max_active_downloads; it only overfills once per configured interval.
torrents = rtorrent.list_torrents(profile)
user_excluded = _excluded_hashes(profile_id, user_id)
max_active = max(1, int(settings.get('max_active_downloads') or 5))
batch_size = max(1, int(settings.get('surge_refill_batch_size') or 2000))
stalled_label_hashes = {str(t.get('hash') or '') for t in torrents if _has_stalled_label(str(t.get('label') or '')) and t.get('hash')}
downloading = [
t for t in torrents
if _is_running_download_slot(t)
and str(t.get('hash') or '') not in user_excluded
]
stopped = [
t for t in torrents
if str(t.get('hash') or '') not in user_excluded
and str(t.get('hash') or '') not in stalled_label_hashes
and _is_waiting_download_candidate(t, True)
and not _is_running_download_slot(t)
]
if int(settings.get('auto_stop_idle') or 0) and not downloading and not stopped:
idle_details = {
'decision': 'Smart Queue auto-stopped during Surge refill: no active or waiting downloads',
'enabled': False,
'auto_stop_idle': True,
'surge_refill': True,
'checked': len(torrents),
'active_before': 0,
'active_after_stop': 0,
'active_after_expected': 0,
'max_active_downloads': max_active,
'surge_refill_batch_size': batch_size,
'over_limit': 0,
'stopped': [],
'started': [],
'start_requested': [],
'stalled_detected': 0,
'stalled_stopped': 0,
'protected_stalled': 0,
'excluded': len(user_excluded),
'excluded_stalled': len(stalled_label_hashes),
}
_mark_surge_refill_run(profile_id, user_id)
_diagnostics_write('smart_queue.surge_refill_idle', {'profile_id': profile_id, 'checked': len(torrents)}, idle_details)
return _disable_when_idle(profile_id, user_id, torrents, idle_details)
startable_stopped, source_skipped = _split_start_candidates(stopped)
prefer_partial_progress = bool(int(settings.get('prefer_partial_progress', 1) or 0))
candidates = sorted(
startable_stopped,
key=lambda t: _start_candidate_sort_key(t, prefer_partial_progress),
reverse=True,
)
c = rtorrent.client_for(profile)
rtorrent_cap = _ensure_rtorrent_download_cap(c, max(max_active, len(downloading) + batch_size))
label_failed: list[str] = []
to_start = candidates[:batch_size]
to_label_waiting = candidates[batch_size:]
for t in to_label_waiting:
h = str(t.get('hash') or '')
if not h:
continue
try:
if not _mark_auto_stopped(c, profile_id, t):
label_failed.append(h)
except Exception:
label_failed.append(h)
start_summary = _start_and_verify_downloads(c, profile_id, to_start)
active_verified = start_summary['active_verified']
start_pending_confirmation = start_summary.get('start_pending_confirmation', [])
start_failed = start_summary['start_failed']
start_requested = start_summary['start_requested']
start_results = start_summary['start_results']
_record_start_grace(profile_id, start_requested)
for h in start_requested:
_restore_auto_label(c, profile_id, h, None)
try:
rtorrent.clear_post_check_download_label(c, h, None)
except Exception:
label_failed.append(h)
keep_labels = (
{str(t.get('hash') or '') for t in to_label_waiting}
| {str(t.get('hash') or '') for t in stopped if _has_smart_queue_label(str(t.get('label') or '')) and str(t.get('hash') or '') not in set(start_requested)}
)
restored = _cleanup_auto_labels(c, profile_id, torrents, keep_labels, True)
active_transferring = sum(1 for t in downloading if int(t.get('down_rate') or 0) > 0 or int(t.get('up_rate') or 0) > 0)
active_rtorrent = sum(1 for t in downloading if int(t.get('active') or 0))
active_state = sum(1 for t in downloading if int(t.get('state') or 0))
active_after_expected = len(downloading) + len(start_requested)
over_limit_expected = max(0, active_after_expected - max_active)
if start_requested:
decision = f'Surge refill requested {len(start_requested)} over-cap start(s); normal checks will drain overflow'
blocked_reason = ''
elif not candidates:
decision = 'Surge refill skipped: no stopped candidates available'
blocked_reason = 'no_candidates'
else:
decision = 'Surge refill ran but rTorrent did not confirm new starts yet'
blocked_reason = 'start_not_confirmed'
details = {
'decision': decision,
'blocked_reason': blocked_reason,
'enabled': bool(settings.get('enabled')),
'surge_refill': True,
'surge_refill_interval_minutes': int(settings.get('surge_refill_interval_minutes') or 0),
'surge_refill_batch_size': batch_size,
'active_before': len(downloading),
'active_after_expected': active_after_expected,
'active_transferring_count': active_transferring,
'active_rtorrent_count': active_rtorrent,
'active_state_count': active_state,
'max_active_downloads': max_active,
'over_limit': over_limit_expected,
'candidates': len(candidates),
'started_planned': len(to_start),
'waiting_labeled': len(to_label_waiting),
'start_requested': start_requested,
'start_results': start_results,
'active_verified_count': len(active_verified),
'pending_confirmation_count': len(start_pending_confirmation),
'start_pending_confirmation': start_pending_confirmation,
'start_failed': start_failed,
'labels_failed': label_failed,
'labels_restored': restored,
'start_source_skipped': len(source_skipped),
'rtorrent_cap_updated': bool(rtorrent_cap.get('updated')),
'rtorrent_cap': rtorrent_cap,
'excluded': len(user_excluded),
'excluded_stalled': len(stalled_label_hashes),
}
_diagnostics_write(
'smart_queue.surge_refill',
{
'profile_id': profile_id,
'checked': len(torrents),
'active_before': len(downloading),
'active_after_expected': active_after_expected,
'max_active_downloads': max_active,
'over_limit': over_limit_expected,
'batch_size': batch_size,
'candidates': len(candidates),
'requested': len(start_requested),
'verified': len(active_verified),
'pending': len(start_pending_confirmation),
'start_failed': len(start_failed),
'waiting_labeled': len(to_label_waiting),
'blocked_reason': blocked_reason,
'rtorrent_cap_updated': bool(rtorrent_cap.get('updated')),
},
{
'rtorrent_cap': rtorrent_cap,
'settings': {
'surge_refill_interval_minutes': int(settings.get('surge_refill_interval_minutes') or 0),
'surge_refill_batch_size': batch_size,
'prefer_partial_progress': prefer_partial_progress,
},
'to_start': _diagnostics_torrents(to_start),
'to_label_waiting': _diagnostics_torrents(to_label_waiting),
'source_skipped': _diagnostics_torrents(source_skipped),
'pending_confirmation': _diagnostics_sample(start_pending_confirmation),
'start_failed': _diagnostics_sample(start_failed),
'labels_failed': _diagnostics_sample(label_failed),
},
)
_mark_surge_refill_run(profile_id, user_id)
add_history(profile_id, 'surge_refill', [], start_requested, len(torrents), details, user_id)
settings = get_settings(profile_id, user_id)
return {
'ok': True,
'enabled': bool(settings.get('enabled')),
'surge_refill': True,
'cooldown_skipped': True,
'refill_mode': _refill_mode(settings),
'refill_remaining_seconds': refill_remaining(settings),
'surge_refill_remaining_seconds': surge_refill_remaining(settings),
'paused': [],
'resumed': start_requested,
'stopped': [],
'started': start_requested,
'start_requested': start_requested,
'start_batch_size': start_summary['start_batch_size'],
'start_verify_attempts': start_summary['start_verify_attempts'],
'start_verify_delay_seconds': start_summary['start_verify_delay_seconds'],
'waiting_labeled': len(to_label_waiting),
'labels_restored': restored,
'labels_failed': label_failed,
'start_failed': start_failed,
'start_no_effect': start_summary['start_no_effect'],
'start_pending_confirmation': start_pending_confirmation,
'active_verified': active_verified,
'active_before': len(downloading),
'active_after_expected': active_after_expected,
'over_limit': over_limit_expected,
'active_transferring_count': active_transferring,
'active_rtorrent_count': active_rtorrent,
'active_state_count': active_state,
'blocked_reason': blocked_reason,
'start_source_skipped': len(source_skipped),
'checked': len(torrents),
'excluded': len(user_excluded),
'settings': settings, 'settings': settings,
} }
@@ -1177,13 +1437,18 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
profile_id = int(profile['id']) profile_id = int(profile['id'])
settings = get_settings(profile_id, user_id) settings = get_settings(profile_id, user_id)
remaining = cooldown_remaining(settings) remaining = cooldown_remaining(settings)
if not force and int(settings.get('enabled') or 0) and int(settings.get('surge_refill_enabled') or 0) and not surge_refill_remaining(settings):
try:
return _surge_refill_over_limit(profile, settings, profile_id, user_id)
except Exception as exc:
return {'ok': True, 'enabled': True, 'surge_refill': False, 'settings': settings, 'error': str(exc)}
if remaining and not force: if remaining and not force:
if int(settings.get('enabled') or 0): if int(settings.get('enabled') or 0):
refill_wait = refill_remaining(settings) refill_wait = refill_remaining(settings)
if not int(settings.get('refill_enabled') or 0): if not int(settings.get('refill_enabled') or 0):
return {'ok': True, 'enabled': True, 'cooldown_skipped': True, 'cooldown_refill': False, 'refill_disabled': True, 'cooldown_remaining_seconds': remaining, 'settings': settings} return {'ok': True, 'enabled': True, 'cooldown_skipped': True, 'cooldown_refill': False, 'refill_disabled': True, 'cooldown_remaining_seconds': remaining, 'surge_refill_remaining_seconds': surge_refill_remaining(settings), 'settings': settings}
if refill_wait: if refill_wait:
return {'ok': True, 'enabled': True, 'cooldown_skipped': True, 'cooldown_refill': False, 'refill_wait_seconds': refill_wait, 'cooldown_remaining_seconds': remaining, 'settings': settings} return {'ok': True, 'enabled': True, 'cooldown_skipped': True, 'cooldown_refill': False, 'refill_wait_seconds': refill_wait, 'cooldown_remaining_seconds': remaining, 'surge_refill_remaining_seconds': surge_refill_remaining(settings), 'settings': settings}
try: try:
# Note: Cooldown still blocks the full Smart Queue pass, but configured refill may fill free slots safely. # Note: Cooldown still blocks the full Smart Queue pass, but configured refill may fill free slots safely.
refill = _refill_underfilled_queue(profile, settings, profile_id, user_id) refill = _refill_underfilled_queue(profile, settings, profile_id, user_id)
@@ -1191,7 +1456,7 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
return refill return refill
except Exception as exc: except Exception as exc:
return {'ok': True, 'enabled': True, 'cooldown_skipped': True, 'cooldown_refill': False, 'cooldown_remaining_seconds': remaining, 'settings': settings, 'error': str(exc)} return {'ok': True, 'enabled': True, 'cooldown_skipped': True, 'cooldown_refill': False, 'cooldown_remaining_seconds': remaining, 'settings': settings, 'error': str(exc)}
return {'ok': True, 'enabled': bool(settings.get('enabled')), 'cooldown_skipped': True, 'cooldown_remaining_seconds': remaining, 'settings': settings} return {'ok': True, 'enabled': bool(settings.get('enabled')), 'cooldown_skipped': True, 'cooldown_remaining_seconds': remaining, 'surge_refill_remaining_seconds': surge_refill_remaining(settings), 'settings': settings}
if not force and not int(settings.get('enabled') or 0): if not force and not int(settings.get('enabled') or 0):
restored: list[str] = [] restored: list[str] = []
try: try:
@@ -1271,6 +1536,9 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
ignored_seed_peer_count = 0 ignored_seed_peer_count = 0
ignored_speed_count = 0 ignored_speed_count = 0
snapshot_activity_protected: list[str] = []
snapshot_activity_protected_hashes: set[str] = set()
with connect() as conn: with connect() as conn:
for t in downloading: for t in downloading:
# Note: Ignore switches keep matching criteria from advancing stalled cleanup while preserving diagnostics. # Note: Ignore switches keep matching criteria from advancing stalled cleanup while preserving diagnostics.
@@ -1314,23 +1582,23 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
max_active = max(1, int(settings.get('max_active_downloads') or 5)) max_active = max(1, int(settings.get('max_active_downloads') or 5))
stalled_hashes = {str(t.get('hash') or '') for t in stalled} stalled_hashes = {str(t.get('hash') or '') for t in stalled}
# Enforce the hard active-download cap across the whole started queue, including manual starts. # Enforce the active-download cap using only torrents that the current snapshot already proves idle/weak.
# Note: Weak/no-source torrents are stopped first, but the cap is still enforced when the overflow is larger. # Note: A transferring or recently active torrent is never stopped just because the cap is exceeded.
over_limit = max(0, len(downloading) - max_active) over_limit = max(0, len(downloading) - max_active)
stop_eligible_hashes = {str(t.get('hash') or '') for t in stop_eligible} stop_eligible_hashes = {str(t.get('hash') or '') for t in stop_eligible}
stop_rank = sorted( stop_rank = sorted(
downloading, stop_eligible,
key=lambda t: ( key=lambda t: (
0 if str(t.get('hash') or '') in stalled_hashes else 1, 0 if str(t.get('hash') or '') in stalled_hashes else 1,
0 if str(t.get('hash') or '') in stop_eligible_hashes else 1,
int(t.get('down_rate') or 0), int(t.get('down_rate') or 0),
int(t.get('seeds') or 0), int(t.get('seeds') or 0),
int(t.get('peers') or 0), int(t.get('peers') or 0),
), ),
) )
capped_over_limit = min(over_limit, len(stop_rank))
# Note: The user-defined batch limit caps all automatic stops in one pass. # Note: The user-defined batch limit caps all automatic stops in one pass.
# Hard cap overflow is handled first, then stalled replacement uses only proven spare candidate capacity. # Hard cap overflow is handled first, then stalled replacement uses only proven spare candidate capacity.
to_stop: list[dict[str, Any]] = stop_rank[:min(over_limit, stop_batch_size)] to_stop: list[dict[str, Any]] = stop_rank[:min(capped_over_limit, stop_batch_size)]
stop_hashes = {str(t.get('hash') or '') for t in to_stop} stop_hashes = {str(t.get('hash') or '') for t in to_stop}
remaining_stop_budget = max(0, stop_batch_size - len(to_stop)) remaining_stop_budget = max(0, stop_batch_size - len(to_stop))
free_slots_before_stop = max(0, max_active - len(downloading)) free_slots_before_stop = max(0, max_active - len(downloading))
@@ -1353,6 +1621,17 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
c = rtorrent.client_for(profile) c = rtorrent.client_for(profile)
rtorrent_cap = _ensure_rtorrent_download_cap(c, max_active) rtorrent_cap = _ensure_rtorrent_download_cap(c, max_active)
for t in downloading:
h = str(t.get('hash') or '')
if not h or not _has_stalled_label(str(t.get('label') or '')):
continue
if _has_recent_transfer_activity(t, stalled_seconds):
# Note: Snapshot activity is enough to remove Stalled; no per-torrent live RPC guard is needed.
snapshot_activity_protected.append(h)
snapshot_activity_protected_hashes.add(h)
_clear_stalled_label(c, h, str(t.get('label') or ''))
with connect() as conn:
conn.execute('DELETE FROM smart_queue_stalled WHERE profile_id=? AND torrent_hash=?', (profile_id, h))
stopped_by_queue: list[str] = [] stopped_by_queue: list[str] = []
started_by_queue: list[str] = [] started_by_queue: list[str] = []
label_failed: list[str] = [] label_failed: list[str] = []
@@ -1366,8 +1645,18 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
for t in to_stop: for t in to_stop:
h = str(t.get('hash') or '') h = str(t.get('hash') or '')
try: try:
if not h or h in snapshot_activity_protected_hashes:
continue
if _has_recent_transfer_activity(t, stalled_seconds):
# Note: Snapshot activity wins; active torrents are protected without slow per-item live checks.
snapshot_activity_protected.append(h)
snapshot_activity_protected_hashes.add(h)
_clear_stalled_label(c, h, str(t.get('label') or ''))
with connect() as conn:
conn.execute('DELETE FROM smart_queue_stalled WHERE profile_id=? AND torrent_hash=?', (profile_id, h))
continue
# Note: Smart Queue stops with the same low-level d.stop command used by the manual Stop action. # Note: Smart Queue stops with the same low-level d.stop command used by the manual Stop action.
# This avoids extra pre-check RPCs and keeps large queues from failing after only a few items. # This avoids extra pre-check RPCs and keeps large queues fast even with many candidates.
c.call('d.stop', h) c.call('d.stop', h)
if h in stalled_hashes: if h in stalled_hashes:
if _ensure_stalled_label(c, h, _read_label(c, h, str(t.get('label') or ''))): if _ensure_stalled_label(c, h, _read_label(c, h, str(t.get('label') or ''))):
@@ -1435,6 +1724,7 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
'active_after_stop': active_after_stop, 'active_after_stop': active_after_stop,
'active_after_expected': active_after_stop + len(started_by_queue), 'active_after_expected': active_after_stop + len(started_by_queue),
'over_limit': over_limit, 'over_limit': over_limit,
'stoppable_over_limit': capped_over_limit,
'stopped': stopped_by_queue, 'stopped': stopped_by_queue,
'started': started_by_queue, 'started': started_by_queue,
'start_requested': start_requested, 'start_requested': start_requested,
@@ -1450,6 +1740,8 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
'stalled_stopped_hashes': _hash_sample(stalled_stopped_hashes), 'stalled_stopped_hashes': _hash_sample(stalled_stopped_hashes),
'stalled_labeled': stalled_labeled, 'stalled_labeled': stalled_labeled,
'protected_stalled': protected_stalled, 'protected_stalled': protected_stalled,
'snapshot_activity_protected': len(snapshot_activity_protected),
'snapshot_activity_protected_hashes': _hash_sample(snapshot_activity_protected),
'stalled_replacement_allowed': stalled_replacement_allowed, 'stalled_replacement_allowed': stalled_replacement_allowed,
'excluded': len(user_excluded), 'excluded': len(user_excluded),
'excluded_stalled': len(stalled_label_hashes), 'excluded_stalled': len(stalled_label_hashes),
@@ -1482,9 +1774,11 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
'active_after_expected': active_after_stop + len(started_by_queue), 'active_after_expected': active_after_stop + len(started_by_queue),
'max_active_downloads': max_active, 'max_active_downloads': max_active,
'over_limit': over_limit, 'over_limit': over_limit,
'stoppable_over_limit': capped_over_limit,
'stopped': len(stopped_by_queue), 'stopped': len(stopped_by_queue),
'stalled': len(stalled), 'stalled': len(stalled),
'protected_stalled': protected_stalled, 'protected_stalled': protected_stalled,
'snapshot_activity_protected': len(snapshot_activity_protected),
'stalled_stopped': len(stalled_stopped_hashes), 'stalled_stopped': len(stalled_stopped_hashes),
'stalled_stopped_hashes': _hash_sample(stalled_stopped_hashes, 20), 'stalled_stopped_hashes': _hash_sample(stalled_stopped_hashes, 20),
'stop_eligible': len(stop_eligible), 'stop_eligible': len(stop_eligible),
@@ -1517,6 +1811,7 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
}, },
'rtorrent_cap': rtorrent_cap, 'rtorrent_cap': rtorrent_cap,
'to_stop': _diagnostics_torrents(to_stop), 'to_stop': _diagnostics_torrents(to_stop),
'snapshot_activity_protected': _diagnostics_sample(snapshot_activity_protected),
'stalled': _diagnostics_torrents(stalled), 'stalled': _diagnostics_torrents(stalled),
'stop_eligible': _diagnostics_torrents(stop_eligible), 'stop_eligible': _diagnostics_torrents(stop_eligible),
'to_start': _diagnostics_torrents(to_start), 'to_start': _diagnostics_torrents(to_start),
@@ -1534,4 +1829,4 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
mark_run(profile_id, user_id) mark_run(profile_id, user_id)
settings = get_settings(profile_id, user_id) settings = get_settings(profile_id, user_id)
remaining = cooldown_remaining(settings) remaining = cooldown_remaining(settings)
return {'ok': True, 'enabled': bool(settings.get('enabled')), 'paused': stopped_by_queue, 'resumed': started_by_queue, 'stopped': stopped_by_queue, 'started': started_by_queue, 'start_requested': start_requested, 'start_batch_size': start_summary['start_batch_size'], 'start_verify_attempts': start_summary['start_verify_attempts'], 'start_verify_delay_seconds': start_summary['start_verify_delay_seconds'], 'waiting_labeled': len(to_label_waiting), 'stalled_labeled': stalled_labeled, 'excluded_stalled': len(stalled_label_hashes), 'manual_labeled_running': len(manual_labeled_running), 'labels_restored': restored, 'labels_failed': label_failed, 'stop_failed': stop_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'start_pending_confirmation': start_pending_confirmation, 'active_verified': active_verified, 'active_before': len(downloading), 'active_after_stop': active_after_stop, 'over_limit': over_limit, 'stop_eligible': len(stop_eligible), 'start_source_skipped': len(source_skipped), 'ignore_seed_peer': ignore_seed_peer, 'ignore_speed': ignore_speed, 'ignored_seed_peer_count': ignored_seed_peer_count if ignore_seed_peer else 0, 'ignored_speed_count': ignored_speed_count if ignore_speed else 0, 'stalled_seconds': stalled_seconds, 'stalled_timer_key': timer_key, 'stop_batch_size': stop_batch_size, 'start_grace_seconds': start_grace_seconds, 'protect_active_below_cap': protect_active_below_cap, 'prefer_partial_progress': prefer_partial_progress, 'auto_stop_idle': bool(int(settings.get('auto_stop_idle') or 0)), 'stalled_replacement_allowed': stalled_replacement_allowed, 'start_grace_protected': len(start_grace_hashes), 'replacement_capacity': replacement_capacity, 'protected_stalled': protected_stalled, 'healthy_active_protected': 0, 'rtorrent_cap': rtorrent_cap, 'checked': len(torrents), 'excluded': len(user_excluded), 'settings': settings, 'cooldown_remaining_seconds': remaining} return {'ok': True, 'enabled': bool(settings.get('enabled')), 'paused': stopped_by_queue, 'resumed': started_by_queue, 'stopped': stopped_by_queue, 'started': started_by_queue, 'start_requested': start_requested, 'start_batch_size': start_summary['start_batch_size'], 'start_verify_attempts': start_summary['start_verify_attempts'], 'start_verify_delay_seconds': start_summary['start_verify_delay_seconds'], 'waiting_labeled': len(to_label_waiting), 'stalled_labeled': stalled_labeled, 'excluded_stalled': len(stalled_label_hashes), 'manual_labeled_running': len(manual_labeled_running), 'labels_restored': restored, 'labels_failed': label_failed, 'stop_failed': stop_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'start_pending_confirmation': start_pending_confirmation, 'active_verified': active_verified, 'active_before': len(downloading), 'active_after_stop': active_after_stop, 'over_limit': over_limit, 'stoppable_over_limit': capped_over_limit, 'stop_eligible': len(stop_eligible), 'start_source_skipped': len(source_skipped), 'ignore_seed_peer': ignore_seed_peer, 'ignore_speed': ignore_speed, 'ignored_seed_peer_count': ignored_seed_peer_count if ignore_seed_peer else 0, 'ignored_speed_count': ignored_speed_count if ignore_speed else 0, 'stalled_seconds': stalled_seconds, 'stalled_timer_key': timer_key, 'stop_batch_size': stop_batch_size, 'start_grace_seconds': start_grace_seconds, 'protect_active_below_cap': protect_active_below_cap, 'prefer_partial_progress': prefer_partial_progress, 'auto_stop_idle': bool(int(settings.get('auto_stop_idle') or 0)), 'stalled_replacement_allowed': stalled_replacement_allowed, 'start_grace_protected': len(start_grace_hashes), 'replacement_capacity': replacement_capacity, 'protected_stalled': protected_stalled, 'healthy_active_protected': len(snapshot_activity_protected), 'snapshot_activity_protected': snapshot_activity_protected, 'rtorrent_cap': rtorrent_cap, 'checked': len(torrents), 'excluded': len(user_excluded), 'settings': settings, 'cooldown_remaining_seconds': remaining, 'surge_refill_remaining_seconds': surge_refill_remaining(settings)}
+116 -75
View File
@@ -1,86 +1,127 @@
import { stateSource } from './state.js'; const staticImportVersion = encodeURIComponent(String(window.PYTORRENT?.staticHash || 'dev'));
import { torrentsSource } from './torrents.js'; const versionedImport = (path) => import(`${path}?v=${staticImportVersion}`);
import { mobileSource } from './mobile.js'; const moduleImportSpecs = [
import { messagesSource } from './messages.js'; ['./stateCore.js', 'stateCoreSource'],
import { torrentAddSource } from './torrentAdd.js'; ['./columnState.js', 'columnStateSource'],
import { apiSource } from './api.js'; ['./runtimeState.js', 'runtimeStateSource'],
import { createTorrentSource } from './createTorrent.js'; ['./sharedUi.js', 'sharedUiSource'],
import { torrentDetailsSource } from './torrentDetails.js'; ['./torrentFilterHelpers.js', 'torrentFilterHelpersSource'],
import { modalsSource } from './modals.js'; ['./torrentFilterUi.js', 'torrentFilterUiSource'],
import { rssSource } from './rss.js'; ['./torrentTrackerFilters.js', 'torrentTrackerFiltersSource'],
import { smartQueueSource } from './smartQueue.js'; ['./torrentTableState.js', 'torrentTableStateSource'],
import { rtorrentConfigSource } from './rtorrentConfig.js'; ['./torrentActionState.js', 'torrentActionStateSource'],
import { appearancePreferencesSource } from './appearancePreferences.js'; ['./torrentRowRenderer.js', 'torrentRowRendererSource'],
import { peerRefreshSource } from './peerRefresh.js'; ['./torrentTableRenderer.js', 'torrentTableRendererSource'],
import { automationRulesSource } from './automationRules.js'; ['./mobile.js', 'mobileSource'],
import { cleanupToolsSource } from './cleanupTools.js'; ['./messages.js', 'messagesSource'],
import { appDiagnosticsSource } from './appDiagnostics.js'; ['./torrentAdd.js', 'torrentAddSource'],
import { footerPreferencesSource } from './footerPreferences.js'; ['./api.js', 'apiSource'],
import { liveSpeedStatsSource } from './liveSpeedStats.js'; ['./createTorrent.js', 'createTorrentSource'],
import { statusBarSource } from './statusBar.js'; ['./torrentGeneralDetails.js', 'torrentGeneralDetailsSource'],
import { preferencesToolsSource } from './preferencesTools.js'; ['./torrentFileDetails.js', 'torrentFileDetailsSource'],
import { diskMonitorSource } from './diskMonitor.js'; ['./torrentChunkDetails.js', 'torrentChunkDetailsSource'],
import { portCheckActionsSource } from './portCheckActions.js'; ['./torrentPeerDetails.js', 'torrentPeerDetailsSource'],
import { appStatusSource } from './appStatus.js'; ['./torrentTrackerDetails.js', 'torrentTrackerDetailsSource'],
import { torrentStatsSource } from './torrentStats.js'; ['./mobileTorrentDetails.js', 'mobileTorrentDetailsSource'],
import { toolUiHelpersSource } from './toolUiHelpers.js'; ['./torrentDetailsLoader.js', 'torrentDetailsLoaderSource'],
import { authUsersSource } from './authUsers.js'; ['./pathPickerTools.js', 'pathPickerToolsSource'],
import { plannerSource } from './planner.js'; ['./columnManager.js', 'columnManagerSource'],
import { pollerSource } from './poller.js'; ['./jobTools.js', 'jobToolsSource'],
import { profilesSource } from './profiles.js'; ['./labelTools.js', 'labelToolsSource'],
import { dashboardSource } from './dashboard.js'; ['./ratioTools.js', 'ratioToolsSource'],
import { chartsSource } from './charts.js'; ['./rssTools.js', 'rssToolsSource'],
import { operationLogsSource } from './operationLogs.js'; ['./backupTools.js', 'backupToolsSource'],
import { bootstrapSource } from './bootstrap.js'; ['./smartQueue.js', 'smartQueueSource'],
['./rtorrentConfig.js', 'rtorrentConfigSource'],
export const moduleSources = [ ['./appearancePreferences.js', 'appearancePreferencesSource'],
stateSource, ['./peerRefresh.js', 'peerRefreshSource'],
torrentsSource, ['./automationRules.js', 'automationRulesSource'],
mobileSource, ['./cleanupTools.js', 'cleanupToolsSource'],
messagesSource, ['./appDiagnostics.js', 'appDiagnosticsSource'],
torrentAddSource, ['./footerPreferences.js', 'footerPreferencesSource'],
apiSource, ['./liveSpeedStats.js', 'liveSpeedStatsSource'],
createTorrentSource, ['./statusBar.js', 'statusBarSource'],
torrentDetailsSource, ['./preferencesTools.js', 'preferencesToolsSource'],
modalsSource, ['./diskMonitor.js', 'diskMonitorSource'],
rssSource, ['./portCheckActions.js', 'portCheckActionsSource'],
smartQueueSource, ['./appStatus.js', 'appStatusSource'],
rtorrentConfigSource, ['./torrentStats.js', 'torrentStatsSource'],
appearancePreferencesSource, ['./toolUiHelpers.js', 'toolUiHelpersSource'],
peerRefreshSource, ['./authUsers.js', 'authUsersSource'],
automationRulesSource, ['./plannerToolsUi.js', 'plannerToolsUiSource'],
cleanupToolsSource, ['./plannerSpeedControls.js', 'plannerSpeedControlsSource'],
appDiagnosticsSource, ['./plannerSettings.js', 'plannerSettingsSource'],
footerPreferencesSource, ['./plannerPreviewHistory.js', 'plannerPreviewHistorySource'],
liveSpeedStatsSource, ['./plannerActions.js', 'plannerActionsSource'],
statusBarSource, ['./smartViews.js', 'smartViewsSource'],
preferencesToolsSource, ['./notificationCenter.js', 'notificationCenterSource'],
diskMonitorSource, ['./diagnosticsDashboard.js', 'diagnosticsDashboardSource'],
portCheckActionsSource, ['./dashboardTools.js', 'dashboardToolsSource'],
appStatusSource, ['./operationLogs.js', 'operationLogsSource'],
torrentStatsSource, ['./pollerSettings.js', 'pollerSettingsSource'],
toolUiHelpersSource, ['./toolsModal.js', 'toolsModalSource'],
authUsersSource, ['./toolPaneEvents.js', 'toolPaneEventsSource'],
plannerSource, ['./rssEvents.js', 'rssEventsSource'],
dashboardSource, ['./smartQueueEvents.js', 'smartQueueEventsSource'],
operationLogsSource, ['./backupCleanupRtconfigEvents.js', 'backupCleanupRtconfigEventsSource'],
pollerSource, ['./automationEvents.js', 'automationEventsSource'],
profilesSource, ['./labelSmartEvents.js', 'labelSmartEventsSource'],
chartsSource, ['./torrentSelectionEvents.js', 'torrentSelectionEventsSource'],
bootstrapSource, ['./torrentTableEvents.js', 'torrentTableEventsSource'],
['./preferenceEvents.js', 'preferenceEventsSource'],
['./keyboardEvents.js', 'keyboardEventsSource'],
['./speedLimitControls.js', 'speedLimitControlsSource'],
['./themeMobileControls.js', 'themeMobileControlsSource'],
['./jobSettings.js', 'jobSettingsSource'],
['./profileList.js', 'profileListSource'],
['./profileForm.js', 'profileFormSource'],
['./profileActions.js', 'profileActionsSource'],
['./profileSelection.js', 'profileSelectionSource'],
['./realtimeCharts.js', 'realtimeChartsSource'],
['./trafficHistoryData.js', 'trafficHistoryDataSource'],
['./trafficChartRenderer.js', 'trafficChartRendererSource'],
['./initialSnapshot.js', 'initialSnapshotSource'],
['./footerStatusRefresh.js', 'footerStatusRefreshSource'],
['./systemStatsSocket.js', 'systemStatsSocketSource'],
['./mobileSelectEvents.js', 'mobileSelectEventsSource'],
['./bootstrapRuntime.js', 'bootstrapRuntimeSource'],
]; ];
export function buildRuntimeSource(){ export let moduleSources = [];
return `(() => {\n${moduleSources.join('\n')}\n})();\n`; let moduleSourcesPromise = null;
async function loadModuleSources(){
if(moduleSourcesPromise) return moduleSourcesPromise;
moduleSourcesPromise = Promise.all(moduleImportSpecs.map(([path]) => versionedImport(path))).then((modules) => {
moduleSources = modules.map((mod, index) => mod[moduleImportSpecs[index][1]]);
return moduleSources;
});
return moduleSourcesPromise;
} }
export function startApp(){ function normalizeRuntimeSource(source){
const runtimeSource = buildRuntimeSource(); const text = String(source || '');
// Note: Some generated source chunks may end with a literal \\n marker;
// normalize only this trailing marker to avoid invalid Function() source.
return text.endsWith('\\n') ? `${text.slice(0, -2)}\n` : text;
}
export async function buildRuntimeSource(){
const sources = await loadModuleSources();
return `(() => {\n${sources.map(normalizeRuntimeSource).join('\n')}\n})();\n`;
}
export async function startApp(){
const runtimeSource = await buildRuntimeSource();
// Keep the original shared lexical scope while loading the source from smaller ES modules. // Keep the original shared lexical scope while loading the source from smaller ES modules.
// `io` is passed explicitly so Socket.IO remains available inside the generated runtime. // `io` is passed explicitly so Socket.IO remains available inside the generated runtime.
return Function('io', runtimeSource)(window.io); return Function('io', runtimeSource)(window.io);
} }
if(typeof window !== 'undefined' && !window.PYTORRENT_DISABLE_AUTOSTART){ if(typeof window !== 'undefined' && !window.PYTORRENT_DISABLE_AUTOSTART){
startApp(); startApp().catch((error) => {
console.error('pyTorrent frontend failed to start', error);
const loaderText = document.getElementById('initialLoaderText');
if(loaderText) loaderText.textContent = 'Frontend failed to start. Reload the page or clear browser cache.';
});
} }
+1
View File
@@ -0,0 +1 @@
export const automationEventsSource = "$('statusPlannerOpen')?.addEventListener('click',()=>{ ensurePlannerToolsUI(); activateToolTab('planner'); new bootstrap.Modal($('toolsModal')).show(); }); $('peersRefreshSelect')?.addEventListener('change',async e=>{peersRefreshSeconds=Number(e.target.value||0); await post('/api/preferences',{peers_refresh_seconds:peersRefreshSeconds}).catch(()=>{}); setupPeersRefresh(activeTab()); toast('Peers refresh preference saved','success');});\n $('autoConditionType')?.addEventListener('change',updateAutomationForm); $('autoEffectType')?.addEventListener('change',updateAutomationForm); $('automationAddConditionBtn')?.addEventListener('click',()=>{automationConditions.push(automationCondition()); renderAutomationBuilder();}); $('automationAddEffectBtn')?.addEventListener('click',()=>{automationEffects.push(automationEffect()); renderAutomationBuilder();}); $('automationConditionList')?.addEventListener('click',e=>{const b=e.target.closest('.automation-remove-condition'); if(!b)return; automationConditions.splice(Number(b.dataset.index||0),1); renderAutomationBuilder();}); $('automationEffectList')?.addEventListener('click',e=>{const b=e.target.closest('.automation-remove-effect'); if(!b)return; automationEffects.splice(Number(b.dataset.index||0),1); renderAutomationBuilder();}); $('automationCancelEditBtn')?.addEventListener('click',resetAutomationForm); $('automationSaveBtn')?.addEventListener('click',saveAutomation); $('automationExportBtn')?.addEventListener('click',exportAutomations); $('automationImportBtn')?.addEventListener('click',()=>$('automationImportFile')?.click()); $('automationImportFile')?.addEventListener('change',e=>importAutomations(e.target.files?.[0])); $('automationCheckBtn')?.addEventListener('click',async()=>{setBusy(true);try{const j=await post('/api/automations/check',{}); const torrents=j.result?.applied?.length||0; const batches=j.result?.batches?.length||0; toastMessage('toast.automationsApplied','success',{count:torrents,batches}); await loadAutomations();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('automationManager')?.addEventListener('click',async e=>{const run=e.target.closest('.automation-run'); if(run){ setBusy(true); try{ const j=await post(`/api/automations/${run.dataset.id}/run`,{}); toastMessage('toast.automationForceRunDone','success',{count:j.result?.applied?.length}); await loadAutomations(); }catch(err){ toast(err.message,'danger'); } finally{ setBusy(false); } return; } const toggle=e.target.closest('.automation-toggle'); if(toggle){ await toggleAutomationRule(automationRulesCache.find(r=>String(r.id)===String(toggle.dataset.id))); return; } const edit=e.target.closest('.automation-edit'); if(edit){ editAutomationRule(automationRulesCache.find(r=>String(r.id)===String(edit.dataset.id))); return; } const id=e.target.closest('.automation-delete')?.dataset.id;if(!id)return;if(!confirm('Delete this automation rule?'))return;const r=await fetch('/api/automations/'+id,{method:'DELETE'});const j=await r.json();if(!j.ok)toast(j.error||'Delete failed','danger');await loadAutomations();}); $('automationHistory')?.addEventListener('click',e=>{ if(e.target.closest('#automationClearHistoryBtn')) clearAutomationHistory(); });\n ";
@@ -0,0 +1 @@
export const backupCleanupRtconfigEventsSource = "$('profileBackupCreateBtn')?.addEventListener('click',async()=>{await post('/api/backup/profile',{name:$('profileBackupName')?.value||'Profile backup'}); toast('Profile backup created','success'); loadBackup();}); $('appBackupCreateBtn')?.addEventListener('click',async()=>{await post('/api/backup/app',{name:$('appBackupName')?.value||'Application backup'}); toast('Application backup created','success'); loadBackup();}); $('profileBackupSettingsSaveBtn')?.addEventListener('click',async()=>{await post('/api/backup/profile/settings',{enabled:$('profileBackupAutoEnabled')?.checked,interval_hours:Number($('profileBackupAutoInterval')?.value||24),retention_days:Number($('profileBackupRetentionDays')?.value||30)}); toast('Profile backup schedule saved','success'); loadBackup();}); $('backupSettingsSaveBtn')?.addEventListener('click',async()=>{await post('/api/backup/settings',{enabled:$('backupAutoEnabled')?.checked,interval_hours:Number($('backupAutoInterval')?.value||24),retention_days:Number($('backupRetentionDays')?.value||30)}); toast('Application backup schedule saved','success'); loadBackup();}); document.querySelectorAll('[data-backup-pane]').forEach(tab=>tab.addEventListener('click',()=>{ if(tab.classList.contains('disabled')) return; switchBackupPane(tab.dataset.backupPane||'profile'); })); const backupClickHandler=async e=>{const preview=e.target.closest('.backup-preview-btn'); const restore=e.target.closest('.backup-restore'); const del=e.target.closest('.backup-delete'); if(preview){ const j=await (await fetch(`/api/backup/${preview.dataset.id}/preview`)).json(); if(!j.ok) throw new Error(j.error||'Backup preview failed'); const box=$('backupPreview'); if(box){ box.classList.remove('d-none'); box.innerHTML=backupPreviewTable(j.preview||{}); box.scrollIntoView({block:'nearest'}); } return; } if(restore){ const type=restore.dataset.type==='app'?'application':'profile'; const msg=type==='application'?'Restore this application backup and replace users, profiles and global settings?':'Restore this profile backup into the current active profile?'; if(!confirm(msg)) return; await post(`/api/backup/${restore.dataset.id}/restore`,{}); toast('Backup restored','success'); loadBackup(); return; } if(del){ if(!confirm('Delete this backup permanently?')) return; await post(`/api/backup/${del.dataset.id}`,{},'DELETE'); toast('Backup deleted','success'); loadBackup(); }}; $('profileBackupManager')?.addEventListener('click',backupClickHandler); $('appBackupManager')?.addEventListener('click',backupClickHandler); $('cleanupManager')?.addEventListener('click',async e=>{ if(e.target.closest('#cleanupRefreshBtn')) return loadCleanup(); if(e.target.closest('#cleanupProfileCacheBtn')) return runCleanupAction('/api/cleanup/cache','Clear active profile cache'); if(e.target.closest('#cleanupPollerDiagnosticsBtn')) return runCleanupAction('/api/cleanup/poller-diagnostics','Reset poller diagnostics'); if(e.target.closest('#cleanupJobsBtn')) return runCleanupAction('/api/cleanup/jobs','Clear finished job logs'); if(e.target.closest('#cleanupSmartQueueBtn')) return runCleanupAction('/api/cleanup/smart-queue','Clear Smart Queue logs'); if(e.target.closest('#cleanupOperationLogsBtn')) return runCleanupAction('/api/cleanup/operation-logs','Clear operation logs'); if(e.target.closest('#cleanupPlannerBtn')) return runCleanupAction('/api/cleanup/planner','Clear Planner logs'); if(e.target.closest('#cleanupAutomationsBtn')) return runCleanupAction('/api/cleanup/automations','Clear automation logs'); if(e.target.closest('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear job, Smart Queue, operation, Planner and automation logs'); }); $('rtConfigReloadBtn')?.addEventListener('click',loadRtConfig); $('rtConfigResetBtn')?.addEventListener('click',resetRtConfig); $('rtConfigSaveBtn')?.addEventListener('click',saveRtConfig); $('rtConfigGenerateBtn')?.addEventListener('click',generateRtConfig); $('rtConfigManager')?.addEventListener('input',e=>{ if(e.target.classList.contains('rt-config-input')) updateRtConfigDirty(); }); $('rtConfigManager')?.addEventListener('change',e=>{ if(e.target.classList.contains('rt-config-input')){ const label=e.target.closest('.rt-config-switch')?.querySelector('.form-check-label'); if(label) label.textContent=e.target.checked?'On':'Off'; updateRtConfigDirty(); } }); $('rtConfigApplyOnStart')?.addEventListener('change',updateRtConfigDirty); ";
+1
View File
@@ -0,0 +1 @@
export const backupToolsSource = " function fillBackupSettings(settings={}, prefix='app'){\n const cap=prefix==='profile'?'Profile':'App';\n const enabled=$(prefix==='profile'?'profileBackupAutoEnabled':'backupAutoEnabled');\n const interval=$(prefix==='profile'?'profileBackupAutoInterval':'backupAutoInterval');\n const retention=$(prefix==='profile'?'profileBackupRetentionDays':'backupRetentionDays');\n if(enabled) enabled.checked=!!settings.enabled;\n if(interval) interval.value=settings.interval_hours||24;\n if(retention) retention.value=settings.retention_days||30;\n }\n function backupPreviewDetails(table={}){\n const sample=table.sample||[];\n if(!sample.length) return '<div class=\"backup-preview-empty\">No saved rows in this table.</div>';\n const keys=[...new Set(sample.flatMap(row=>Object.keys(row||{})))].slice(0,8);\n return responsiveTable(keys.map(esc), sample.map(row=>keys.map(key=>esc(row?.[key] ?? ''))), 'backup-preview-sample-table');\n }\n function backupPreviewTable(preview={}){\n const tables=preview.tables||[];\n const rows=tables.map(t=>`<details class=\"backup-preview-table-details\"><summary><span><b>${esc(t.name)}</b><small>${esc(t.rows)} row(s) · ${(t.columns||[]).length} column(s)</small></span></summary>${backupPreviewDetails(t)}</details>`).join('');\n const type=preview.backup_type==='app'?'application':'profile';\n return `<div class=\"surface-section backup-preview-card\"><div class=\"section-title\"><i class=\"fa-solid fa-eye\"></i> Backup preview</div><div class=\"small text-muted mb-2\">${esc(type)} backup · Created: ${esc(preview.created_at||'-')} · ${preview.automatic?'automatic':'manual'} · sensitive values hidden</div>${rows || '<div class=\"empty-mini\">Backup has no previewable settings.</div>'}</div>`;\n }\n function backupRows(rows=[]){\n return responsiveTable(['Name','Created','Type','Actions'],rows.map(b=>[esc(b.name),humanDateCell(b.created_at),b.automatic?'Auto':'Manual',`<div class=\"table-action-group backup-actions\"><button class=\"btn btn-xs btn-outline-info backup-preview-btn\" data-id=\"${esc(b.id)}\"><i class=\"fa-solid fa-eye\"></i> Preview</button><a class=\"btn btn-xs btn-outline-secondary\" href=\"/api/backup/${esc(b.id)}/download\"><i class=\"fa-solid fa-download\"></i> Download</a><button class=\"btn btn-xs btn-outline-warning backup-restore\" data-id=\"${esc(b.id)}\" data-type=\"${esc(b.backup_type||'profile')}\"><i class=\"fa-solid fa-rotate-left\"></i> Restore</button><button class=\"btn btn-xs btn-outline-danger backup-delete\" data-id=\"${esc(b.id)}\"><i class=\"fa-solid fa-trash-can\"></i> Delete</button></div>`]),'backup-table');\n }\n function switchBackupPane(pane){\n document.querySelectorAll('[data-backup-pane]').forEach(x=>x.classList.toggle('active',x.dataset.backupPane===pane));\n document.querySelectorAll('[data-backup-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.backupPanel!==pane));\n }\n async function loadBackup(){\n const j=await (await fetch('/api/backup')).json();\n fillBackupSettings(j.profile_auto||{}, 'profile');\n fillBackupSettings(j.app_auto||j.auto||{}, 'app');\n if($('profileBackupManager')) $('profileBackupManager').innerHTML=backupRows(j.profile_backups||[]);\n if($('appBackupManager')) $('appBackupManager').innerHTML=j.can_app_backup ? backupRows(j.app_backups||[]) : '<div class=\"empty-mini\">Application backups are admin-only.</div>';\n if(!j.can_app_backup) document.querySelector('[data-backup-pane=\"app\"]')?.classList.add('disabled');\n }\n";
+1
View File
@@ -0,0 +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";
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
export const columnStateSource = " const COLUMN_DEFS = [[\"status\",\"Status\",false],[\"size\",\"Size\",false],[\"progress\",\"Progressbar\",false],[\"down_rate\",\"DL\",false],[\"up_rate\",\"UL\",false],[\"eta\",\"ETA\",false],[\"seeds\",\"Seeds\",false],[\"peers\",\"Peers\",false],[\"ratio\",\"Ratio\",false],[\"path\",\"Path\",false],[\"label\",\"Label\",false],[\"ratio_group\",\"Ratio group\",false],[\"down_total\",\"Downloaded\",true],[\"to_download\",\"To download\",true],[\"up_total\",\"Uploaded\",true],[\"created\",\"Created\",true],[\"last_activity\",\"Last activity\",true],[\"priority\",\"Priority\",true],[\"state\",\"State\",true],[\"active\",\"Active\",true],[\"complete\",\"Complete\",true],[\"hashing\",\"Hashing\",true],[\"message\",\"Message\",true],[\"hash\",\"Hash\",true]];\n const DEFAULT_HIDDEN_COLUMNS = new Set(COLUMN_DEFS.filter(([, , hiddenByDefault]) => hiddenByDefault).map(([key]) => key));\n const savedColumns = window.PYTORRENT?.tableColumns || {};\n const DEFAULT_COLUMN_WIDTHS = {\n select: 34, name: 360, status: 110, size: 90, progress: 120,\n down_rate: 86, up_rate: 86, eta: 92, seeds: 70, peers: 70,\n ratio: 72, path: 300, label: 140, ratio_group: 130,\n down_total: 120, to_download: 120, up_total: 120, created: 150,\n last_activity: 150, priority: 80, state: 70, active: 70, complete: 82, hashing: 82,\n message: 220, hash: 280\n };\n const COLUMN_WIDTH_MIN = 44;\n const COLUMN_WIDTH_MAX = 720;\n const explicitlyShownColumns = new Set(savedColumns.shown || []);\n let hiddenColumns = new Set([...(savedColumns.hidden || []), ...[...DEFAULT_HIDDEN_COLUMNS].filter(key => !explicitlyShownColumns.has(key))]);\n // Note: Column widths are persisted with the existing column preferences payload, so no database migration is needed.\n function normalizeColumnWidths(value={}){\n const allowed = new Set(['select', ...COLUMN_DEFS.map(([key]) => key)]);\n const normalized = {...DEFAULT_COLUMN_WIDTHS};\n Object.entries(value || {}).forEach(([key, width])=>{\n if(allowed.has(key)) normalized[key] = clampNumber(width, COLUMN_WIDTH_MIN, COLUMN_WIDTH_MAX, DEFAULT_COLUMN_WIDTHS[key] || 120);\n });\n return normalized;\n }\n let columnWidths = normalizeColumnWidths(savedColumns.widths || {});\n if(browserViewPrefs.columnWidths) columnWidths = normalizeColumnWidths({...columnWidths, ...browserViewPrefs.columnWidths});\n function mobileSortStepId(step){ return `${step.key}:${step.dir}`; }\n function normalizeMobileSortFilters(value={}){\n const normalized = Object.fromEntries(MOBILE_SORT_STEPS.map(step => {\n const id = mobileSortStepId(step);\n return [id, DEFAULT_MOBILE_SORT_FILTER_IDS.has(id)];\n }));\n Object.entries(value || {}).forEach(([id, enabled]) => { if(id in normalized) normalized[id] = !!enabled; });\n return normalized;\n }\n let mobileSortFilters = normalizeMobileSortFilters(savedColumns.mobileSortFilters || {});\n if(browserViewPrefs.mobileSortFilters) mobileSortFilters = normalizeMobileSortFilters({...mobileSortFilters, ...browserViewPrefs.mobileSortFilters});\n const DEFAULT_MOBILE_COLUMNS = new Set([\"status\",\"progress\",\"down_rate\",\"up_rate\",\"eta\",\"seeds\",\"peers\",\"ratio\",\"path\"]);\n const MOBILE_COLUMN_DEFS = COLUMN_DEFS.map(([key,label]) => [key, label, DEFAULT_MOBILE_COLUMNS.has(key)]);\n function normalizeMobileColumns(value={}){\n const normalized = {...Object.fromEntries(MOBILE_COLUMN_DEFS.map(([key,,shown])=>[key, shown]))};\n Object.entries(value || {}).forEach(([key, shown])=>{\n if(key === \"speed\"){ normalized.down_rate = !!shown; normalized.up_rate = !!shown; }\n else if(key === \"seed_peer\"){ normalized.seeds = !!shown; normalized.peers = !!shown; }\n else if(key in normalized) normalized[key] = !!shown;\n });\n return normalized;\n }\n let mobileColumns = normalizeMobileColumns(savedColumns.mobile || {});\n if(browserViewPrefs.mobileColumns) mobileColumns = normalizeMobileColumns({...mobileColumns, ...browserViewPrefs.mobileColumns});\n let mobileSmartFiltersEnabled = browserViewPrefs.mobileSmartFiltersEnabled ?? savedColumns.mobileSmartFiltersEnabled ?? true;\n";
+1
View File
@@ -0,0 +1 @@
export const dashboardToolsSource = "function ensureDashboardToolsUI(){\n const host=$('toolRss')?.parentElement || document.querySelector('#toolsModal .modal-body');\n if(!host) return;\n addToolTab('smartviews','fa-layer-group','Smart Views','torrentstats');\n addToolTab('notifications','fa-bell','Notifications','appstatus');\n const stats=$('toolTorrentStats');\n if(stats && !$('healthDashboardManager')){\n const section=document.createElement('div');\n section.className='surface-section mt-3';\n section.innerHTML='<div class=\"section-title\"><i class=\"fa-solid fa-heart-pulse\"></i> Torrent health</div><div class=\"tool-note mb-3\">Live health buckets calculated from the current torrent snapshot.</div><div id=\"healthDashboardManager\"></div>';\n stats.appendChild(section);\n section.addEventListener('click',e=>{ const tab=e.target.closest('[data-health-pane]'); if(tab){ const pane=tab.dataset.healthPane; section.querySelectorAll('[data-health-pane]').forEach(x=>x.classList.toggle('active',x.dataset.healthPane===pane)); section.querySelectorAll('[data-health-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.healthPanel!==pane)); return; } const row=e.target.closest('[data-hash]'); if(!row) return; selectedHash=row.dataset.hash; selected.clear(); selected.add(selectedHash); scheduleRender(true); });\n }\n if(!$('toolSmartviews')){\n const p=document.createElement('div');\n p.id='toolSmartviews';\n p.className='d-none';\n p.innerHTML='<div class=\"surface-section\"><div class=\"section-title\"><i class=\"fa-solid fa-layer-group\"></i> Smart Views</div><div class=\"tool-note mb-3\">One-click filters for common torrent maintenance tasks.</div><div id=\"smartViewsManager\"></div></div>';\n host.appendChild(p);\n p.addEventListener('click',e=>{ const card=e.target.closest('.smart-view-card'); if(!card) return; activeTrackerFilter=''; activeFilter=card.dataset.filter||'all'; mobileActiveFilterKey=activeFilter; saveActiveFilterPreference(); syncFilterButtons(); scheduleRender(true); renderSmartViewsManager(); });\n }\n if(!$('toolNotifications')){\n const p=document.createElement('div');\n p.id='toolNotifications';\n p.className='d-none';\n p.innerHTML='<div class=\"surface-section\"><div class=\"section-title\"><i class=\"fa-solid fa-bell\"></i> Notification center</div><div class=\"tool-note mb-3\">Persistent local history for rTorrent, RSS, automation, disk, queue, planner and port events.</div><div id=\"notificationCenterManager\"></div></div>';\n host.appendChild(p);\n }\n renderHealthDashboard();\n renderSmartViewsManager();\n renderNotificationCenter();\n updateNotificationBadge();\n}\n";
@@ -0,0 +1 @@
export const diagnosticsDashboardSource = "function diagnosticsSection(title, cards){\n return `<section class=\"diagnostics-section\"><div class=\"section-title\"><i class=\"fa-solid fa-stethoscope\"></i> ${esc(title)}</div><div class=\"diag-grid\">${cards.join('')}</div></section>`;\n}\nasync function loadDiagnosticsPage(){\n const box=$('diagnosticsPageManager');\n if(!box) return;\n box.innerHTML='<span class=\"spinner-border spinner-border-sm\"></span> Loading diagnostics...';\n try{\n const [status,poller,planner,smart]=await Promise.all([\n fetch('/api/app/status').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({})),\n fetch('/api/download-planner/preview').then(r=>r.json()).catch(()=>({})),\n fetch('/api/smart-queue?history_limit=100').then(r=>r.json()).catch(()=>({ok:false})),\n ]);\n if(status && status.ok===false) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, profile=st.profile||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, cleanup=st.cleanup||{}, db=cleanup.database||{}, pc=st.port_check||{};\n const rt=poller.runtime||{}, ps=poller.settings||{}, pv=planner.preview||{}, smartStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null;\n const profileCards=[diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), diagCard('Incoming port', pc.port||'-'), diagCard('Port status', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':'')];\n const rtCards=[diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('Connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('First byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('Total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'), diagCard('Request bytes', scgi.request_bytes), diagCard('Response bytes', scgi.response_bytes), diagCard('XML bytes', scgi.xml_bytes), diagCard('rTorrent version', scgi.client_version||'-')];\n const pollerCards=[diagCard('Adaptive', ps.adaptive_enabled===false?'off':'on'), diagCard('Mode', rt.adaptive_mode||'-'), diagCard('Effective interval', `${rt.effective_interval_seconds??'-'}s`), diagCard('Minimum interval', `${rt.configured_min_interval_seconds??'-'}s`), diagCard('Tick duration', `${rt.duration_ms||rt.last_tick_ms||0} ms`), diagCard('Tick gap', `${rt.last_tick_gap_ms||0} ms`), diagCard('Payload', fmtBytes(rt.emitted_payload_size||0)), diagCard('rTorrent calls', rt.rtorrent_call_count||0), diagCard('Skipped emissions', rt.skipped_emissions||0), diagCard('Ticks', rt.tick_count||0)];\n const plannerCards=[diagCard('Matched rule', pv.matched_rule||'-'), diagCard('Next change', pv.next_change_at||'-'), diagCard('Planner state', pv.enabled===false?'disabled':'enabled')];\n const databaseCards=[diagCard('DB size', db.size_h||'-'), diagCard('Job logs', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-')];\n const workerCards=[diagCard('Worker threads', py.worker_threads ?? '-'), diagCard('Jobs total', py.jobs_total ?? '-'), diagCard('Threads', py.threads ?? '-'), diagCard('CPU', `${py.cpu_percent ?? '-'}%`)];\n const smartBlock=`<section class=\"diagnostics-section\"><div class=\"section-title\"><i class=\"fa-solid fa-list-check\"></i> Smart Queue decisions</div>${renderSmartQueueNerdStats(smartStats)}</section>`;\n box.innerHTML=[diagnosticsSection('Profile and port',profileCards), diagnosticsSection('rTorrent connection',rtCards), diagnosticsSection('Adaptive poller',pollerCards), diagnosticsSection('Planner',plannerCards), diagnosticsSection('Database and cleanup',databaseCards), diagnosticsSection('Worker state',workerCards), smartBlock, scgi.error?`<div class=\"alert alert-danger mt-3 mb-0\">${esc(scgi.error)}</div>`:''].join('');\n }catch(e){ box.innerHTML=`<div class=\"alert alert-danger mb-0\">${esc(e.message)}</div>`; }\n}\n";
@@ -0,0 +1 @@
export const footerStatusRefreshSource = " function rtorrentPairText(current, max){\n if(current == null) return '-';\n return max == null ? String(current) : `${current}/${max}`;\n }\n function footerStatusUpdatedText(s={}){\n const value=s.footer_updated_at || s.updated_at;\n if(!value) return '';\n const date=new Date(value);\n return Number.isNaN(date.getTime()) ? '' : ` · last known ${date.toLocaleString()}`;\n }\n function updateRtorrentFooterStats(s={}, cached=false){\n const suffix=cached ? footerStatusUpdatedText(s) : '';\n const sockets=rtorrentPairText(s.open_sockets, s.max_open_sockets);\n if($('statSockets')) $('statSockets').textContent=sockets;\n if($('statusSockets')) $('statusSockets').title=s.open_sockets == null ? `Open sockets unavailable${suffix}` : `Open rTorrent sockets${s.max_open_sockets == null ? '' : ' / max'}: ${sockets}${suffix}`;\n if($('statRtDownloads')) $('statRtDownloads').textContent=rtorrentPairText(s.active_downloads, s.max_downloads_global);\n if($('statusRtDownloads')) $('statusRtDownloads').title=`Active rTorrent downloads / max global downloads${suffix}`;\n if($('statRtUploads')) $('statRtUploads').textContent=rtorrentPairText(s.active_uploads, s.max_uploads_global);\n if($('statusRtUploads')) $('statusRtUploads').title=`Active rTorrent uploads / max global uploads${suffix}`;\n if($('statRtHttp')) $('statRtHttp').textContent=rtorrentPairText(s.open_http, s.max_open_http);\n if($('statusRtHttp')) $('statusRtHttp').title=`Open rTorrent HTTP connections / max HTTP connections${suffix}`;\n if($('statRtFiles')) $('statRtFiles').textContent=rtorrentPairText(s.open_files, s.max_open_files);\n if($('statusRtFiles')) $('statusRtFiles').title=`Open rTorrent files / max open files${suffix}`;\n if($('statRtPort')) $('statRtPort').textContent=(s.listen_port ?? '-') || '-';\n if($('statusRtPort')) $('statusRtPort').title=`rTorrent incoming port${suffix}`;\n if(cached){\n if(s.cpu!==undefined && $('statCpu')) $('statCpu').textContent=s.cpu;\n if(s.ram!==undefined && $('statRam')) $('statRam').textContent=s.ram;\n if(s.version!==undefined && $('statVersion')) $('statVersion').textContent=s.version || '-';\n if(s.down_rate_h!==undefined && $('statDl')) $('statDl').textContent=s.down_rate_h || '0 B/s';\n if(s.up_rate_h!==undefined && $('statUl')) $('statUl').textContent=s.up_rate_h || '0 B/s';\n if(s.down_rate_h!==undefined && $('mobileSpeedDl')) $('mobileSpeedDl').textContent=s.down_rate_h || '0 B/s';\n if(s.up_rate_h!==undefined && $('mobileSpeedUl')) $('mobileSpeedUl').textContent=s.up_rate_h || '0 B/s';\n updateBrowserSpeedTitle(s.down_rate_h, s.up_rate_h);\n }\n }\n function saveFooterStatusCache(s={}){\n const payload={\n open_sockets:s.open_sockets, max_open_sockets:s.max_open_sockets,\n active_downloads:s.active_downloads, max_downloads_global:s.max_downloads_global,\n active_uploads:s.active_uploads, max_uploads_global:s.max_uploads_global,\n open_http:s.open_http, max_open_http:s.max_open_http,\n open_files:s.open_files, max_open_files:s.max_open_files,\n listen_port:s.listen_port,\n cpu:s.cpu, ram:s.ram, version:s.version,\n down_rate_h:s.down_rate_h, up_rate_h:s.up_rate_h,\n footer_updated_at:new Date().toISOString()\n };\n try{ localStorage.setItem(FOOTER_STATUS_STORAGE_KEY, JSON.stringify(payload)); }catch(_){}\n }\n function restoreFooterStatusCache(){\n try{\n const cached=JSON.parse(localStorage.getItem(FOOTER_STATUS_STORAGE_KEY)||'null');\n if(cached && typeof cached==='object') updateRtorrentFooterStats(cached, true);\n }catch(_){}\n }\n async function refreshFooterStatusNow(){\n try{\n const res=await fetch('/api/system/status', {cache:'no-store'});\n const j=await res.json();\n const s=j.status||{};\n if(j.ok && s){\n updateRtorrentFooterStats(s, false);\n saveFooterStatusCache(s);\n applyFooterPreferences();\n }\n }catch(_){}\n }\n";
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
export const jobSettingsSource = " async function activeProfileForSettings(){\n const j=await (await fetch('/api/profiles')).json();\n return j.active || (j.profiles||[])[0] || null;\n }\n function fillJobSettings(profile){\n if(!profile) return;\n if($('jobHeavyParallel')) $('jobHeavyParallel').value=profile.max_parallel_jobs||5;\n if($('jobLightParallel')) $('jobLightParallel').value=profile.light_parallel_jobs||4;\n if($('jobLightTimeout')) $('jobLightTimeout').value=profile.light_job_timeout_seconds||300;\n if($('jobHeavyTimeout')) $('jobHeavyTimeout').value=profile.heavy_job_timeout_seconds||7200;\n if($('jobPendingTimeout')) $('jobPendingTimeout').value=profile.pending_job_timeout_seconds||900;\n if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent=profile.name?`Active profile: ${profile.name}`:'';\n }\n async function loadJobSettings(){\n try{\n const profile=await activeProfileForSettings();\n if(!profile){ if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent='No active profile.'; return; }\n fillJobSettings(profile);\n }catch(e){ if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent=e.message; }\n }\n function jobSettingsPayload(profile){\n return {\n name:profile.name,\n scgi_url:profile.scgi_url,\n timeout_seconds:profile.timeout_seconds||5,\n max_parallel_jobs:$('jobHeavyParallel')?.value||5,\n light_parallel_jobs:$('jobLightParallel')?.value||4,\n light_job_timeout_seconds:$('jobLightTimeout')?.value||300,\n heavy_job_timeout_seconds:$('jobHeavyTimeout')?.value||7200,\n pending_job_timeout_seconds:$('jobPendingTimeout')?.value||900,\n is_remote:!!profile.is_remote,\n is_default:!!profile.is_default\n };\n }\n async function saveJobSettings(){\n const btn=$('saveJobSettingsBtn');\n buttonBusy(btn,true);\n try{\n const profile=await activeProfileForSettings();\n if(!profile) throw new Error('No active profile');\n const j=await post(`/api/profiles/${profile.id}`,jobSettingsPayload(profile),'PUT');\n fillJobSettings(j.profile||profile);\n await refreshProfiles();\n toast('Job settings saved','success');\n }catch(e){ toast(e.message,'danger'); }\n finally{ buttonBusy(btn,false); }\n }\n";
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
export const keyboardEventsSource = "document.addEventListener('keydown',e=>{ const tag=(e.target?.tagName||'').toLowerCase(); const editable=tag==='input'||tag==='textarea'||tag==='select'||e.target?.isContentEditable; if(editable){ if(e.key==='Enter' && e.target?.id==='labelInput'){ e.preventDefault(); $('addLabelToSelectionBtn')?.click(); } return; } if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='a'){e.preventDefault();selected.clear();visibleRows.forEach(t=>selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='i'){e.preventDefault();visibleRows.forEach(t=>selected.has(t.hash)?selected.delete(t.hash):selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='o'){e.preventDefault();new bootstrap.Modal($('addModal')).show();} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='s'){e.preventDefault();downloadTorrentFiles();return;} if(e.key==='Escape'){selected.clear();scheduleRender(true);} if(e.key==='Delete') new bootstrap.Modal($('removeModal')).show(); if(e.key===' ') {e.preventDefault();runAction('start');} if(e.key.toLowerCase()==='p')runAction('pause'); if(e.key.toLowerCase()==='s' && !(e.ctrlKey||e.metaKey))runAction('stop'); if(e.key.toLowerCase()==='r')runAction('resume'); if(e.key.toLowerCase()==='m')runAction('move'); });\n $('removeModal')?.addEventListener('show.bs.modal',()=>{$('removeCount').textContent=selected.size;$('removeData').checked=true;}); $('confirmRemoveBtn')?.addEventListener('click',async()=>{await runAction('remove',{remove_data:$('removeData').checked});bootstrap.Modal.getInstance($('removeModal'))?.hide();});\n $('addModal')?.addEventListener('show.bs.modal',()=>applyDefaultDownloadPath(true));\n\n $('toolsModal')?.addEventListener('show.bs.modal',()=>applyDefaultDownloadPath(false));\n // Note: Torrent add modal and drag/drop upload handling moved to torrentAdd.js.\n ";
+1
View File
@@ -0,0 +1 @@
export const labelSmartEventsSource = "document.addEventListener('click',async e=>{ const btn=e.target.closest('.delete-label'); if(!btn)return; if(!confirm('Delete this label?')) return; setBusy(true); try{ const r=await fetch('/api/labels/'+btn.dataset.id,{method:'DELETE'}); const j=await r.json(); if(!j.ok) throw new Error(j.error||'Delete failed'); await loadLabels(); toast('Label deleted','success'); }catch(err){toast(err.message,'danger');} finally{setBusy(false);} });\n $('bulkClearBtn')?.addEventListener('click',()=>{selected.clear(); selectedHash=null; lastSelectedHash=null; updateBulkBar(); if($('selectAll')) $('selectAll').checked=false; if($('detailPane')) $('detailPane').innerHTML='Select a torrent.'; setupPeersRefresh('general'); scheduleRender(true);});\n $('smartExcludeSelectedBtn')?.addEventListener('click',openSmartQueueExclusionModal);\n $('smartExclusionSearch')?.addEventListener('input',filterSmartQueueExclusionChoices);\n $('smartExclusionOnlySelected')?.addEventListener('change',filterSmartQueueExclusionChoices);\n $('smartExclusionSelectVisibleBtn')?.addEventListener('click',()=>setSmartQueueVisibleExceptions(true));\n $('smartExclusionClearVisibleBtn')?.addEventListener('click',()=>setSmartQueueVisibleExceptions(false));\n $('smartExclusionSaveBtn')?.addEventListener('click',saveSmartQueueExclusionChoices);\n $('smartHistory')?.addEventListener('click',async e=>{\n const clear=e.target.closest('#smartHistoryClear');\n if(clear){\n // Note: Clear history removes only Smart Queue audit rows for the active profile.\n if(!confirm('Clear Smart Queue history?')) return;\n try{ await post('/api/smart-queue/history',{},'DELETE'); smartHistoryExpanded=false; toast('Smart Queue history cleared','success'); await loadSmartQueue(); }catch(err){ toast(err.message,'danger'); }\n return;\n }\n const btn=e.target.closest('#smartHistoryToggle'); if(!btn) return; smartHistoryExpanded=!smartHistoryExpanded; loadSmartQueue();\n });\n\n // Note: Mobile filter changes are handled by setMobileFilterValue in bootstrap.js to avoid duplicate preference writes.\n ";
+1
View File
@@ -0,0 +1 @@
export const labelToolsSource = " async function loadLabels(){ const j=await (await fetch('/api/labels')).json(); const labels=j.labels||[]; knownLabels=labels; renderLabelFilters(); renderLabelChooser(); if($('labelsManager')) $('labelsManager').innerHTML=labels.length?labels.map(l=>`<div class=\"label-manager-row\"><span class=\"chip\"><i class=\"fa-solid fa-tag\"></i> ${esc(l.name)}</span><button class=\"btn btn-xs btn-outline-danger delete-label\" data-id=\"${esc(l.id)}\" title=\"Delete label\"><i class=\"fa-solid fa-trash-can\"></i> Remove</button></div>`).join(''):'<div class=\"empty-state\"><i class=\"fa-solid fa-tags\"></i><b>No labels.</b><span>Add first label above.</span></div>'; }\n function renderLabelChooser(){ if($('selectedLabelList')) $('selectedLabelList').innerHTML=[...modalLabels].map(l=>`<button class=\"chip label-selected\" data-label=\"${esc(l)}\" title=\"Remove\"><i class=\"fa-solid fa-tag\"></i> ${esc(l)} <i class=\"fa-solid fa-xmark ms-1\"></i></button>`).join('') || '<span class=\"text-muted small\">No labels selected.</span>'; if($('labelList')) $('labelList').innerHTML=knownLabels.map(l=>`<button class=\"chip label-chip ${modalLabels.has(l.name)?'active':''}\" data-label=\"${esc(l.name)}\"><i class=\"fa-solid fa-tag\"></i> ${esc(l.name)}</button>`).join('') || '<span class=\"text-muted small\">No saved labels.</span>'; }\n async function saveKnownLabel(name){ name=String(name||'').trim(); if(!name) return; await post('/api/labels',{name}); await loadLabels(); }\n";
@@ -0,0 +1 @@
export const mobileSelectEventsSource = " document.addEventListener('change',e=>{ const sort=e.target.closest('#mobileSortSelect'); if(sort){ setMobileSortValue(sort.value); return; } const sel=e.target.closest('#mobileFilterSelect'); if(!sel) return; setMobileFilterValue(sel.value); });\n ";
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
export const notificationCenterSource = "function notificationItems(){\n try{ return JSON.parse(localStorage.getItem(NOTIFICATION_STORAGE_KEY)||'[]'); }catch(e){ return []; }\n}\nfunction saveNotificationItems(items){ localStorage.setItem(NOTIFICATION_STORAGE_KEY, JSON.stringify(items.slice(0,120))); }\nfunction recordNotification(type, title, message){\n const item={at:new Date().toISOString(), type:String(type||'info'), title:String(title||type||'Notification'), message:String(message||'')};\n const items=[item,...notificationItems()].slice(0,120);\n saveNotificationItems(items);\n renderNotificationCenter();\n updateNotificationBadge();\n}\nfunction notificationIcon(type){\n if(type==='error') return 'fa-triangle-exclamation';\n if(type==='warning') return 'fa-circle-exclamation';\n if(type==='planner') return 'fa-calendar-days';\n if(type==='queue') return 'fa-shuffle';\n return 'fa-circle-info';\n}\nfunction updateNotificationBadge(){\n const btn=document.querySelector('.tool-tab[data-tool=\"notifications\"]');\n if(!btn) return;\n const count=notificationItems().length;\n btn.innerHTML=`<i class=\"fa-solid fa-bell\"></i> Notifications${count?` <span class=\"badge text-bg-danger\">${count}</span>`:''}`;\n}\nfunction renderNotificationCenter(){\n const box=$('notificationCenterManager');\n if(!box) return;\n const items=notificationItems();\n box.innerHTML=`<div class=\"notification-toolbar\"><button id=\"clearNotificationsBtn\" class=\"btn btn-sm btn-outline-danger\" type=\"button\"><i class=\"fa-solid fa-trash\"></i> Clear</button><span>${esc(items.length)} saved event(s)</span></div><div class=\"notification-list\">${items.map(x=>`<article class=\"notification-item notification-${esc(x.type)}\"><i class=\"fa-solid ${notificationIcon(x.type)}\"></i><div><b>${esc(x.title)}</b><span>${esc(x.message)}</span><small>${esc(new Date(x.at).toLocaleString())}</small></div></article>`).join('')||'<span class=\"empty-mini\">No notifications yet.</span>'}</div>`;\n $('clearNotificationsBtn')?.addEventListener('click',()=>{ saveNotificationItems([]); renderNotificationCenter(); updateNotificationBadge(); });\n}\n";
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
export const plannerPreviewHistorySource = "";
+1
View File
@@ -0,0 +1 @@
export const plannerSettingsSource = " function plannerPayload(){ return {enabled:$('plannerEnabled')?.checked,profile_name:$('plannerProfileName')?.value||'night mode',dry_run:$('plannerDryRun')?.checked,night_only_enabled:$('plannerNightOnly')?.checked,night_start:$('plannerNightStart')?.value||'23:00',night_end:$('plannerNightEnd')?.value||'07:00',quiet_hours_enabled:$('plannerQuietEnabled')?.checked,quiet_start:$('plannerQuietStart')?.value||'22:00',quiet_end:$('plannerQuietEnd')?.value||'06:00',weekday_down:Number($('plannerWeekdayDown')?.value||0),weekday_up:Number($('plannerWeekdayUp')?.value||0),weekend_down:Number($('plannerWeekendDown')?.value||0),weekend_up:Number($('plannerWeekendUp')?.value||0),hourly_schedule_enabled:$('plannerHourlyEnabled')?.checked,hourly_schedule:plannerHourlyPayload(),auto_pause_cpu_enabled:$('plannerCpuEnabled')?.checked,auto_pause_cpu_percent:Number($('plannerCpuPercent')?.value||90),auto_pause_disk_enabled:$('plannerDiskEnabled')?.checked,auto_pause_disk_percent:Number($('plannerDiskPercent')?.value||95),network_protection_enabled:$('plannerNetworkEnabled')?.checked,network_max_down:Number($('plannerNetworkDown')?.value||0),network_max_up:Number($('plannerNetworkUp')?.value||0),load_protection_enabled:$('plannerLoadEnabled')?.checked,load_cpu_percent:Number($('plannerLoadCpu')?.value||95),auto_resume:$('plannerAutoResume')?.checked,auto_resume_grace_seconds:Number($('plannerResumeGrace')?.value||0)}; }\n function plannerOnOff(value){ return value ? 'on' : 'off'; }\n function plannerSummaryValue(label, value){\n return `<span class=\"planner-diagnostic-item\"><b>${esc(label)}:</b> ${esc(value)}</span>`;\n }\n\n // Note: Current Settings intentionally reuses the Poller Diagnostics row structure for matching radius, spacing and typography.\n function updatePlannerCurrentSummary(state={}){\n const box=$('plannerCurrentSummary');\n if(!box) return;\n const enabled=$('plannerEnabled')?.checked ?? !!state.enabled;\n const dryRun=$('plannerDryRun')?.checked;\n const nightStart=$('plannerNightStart')?.value || state.night_start || '--:--';\n const nightEnd=$('plannerNightEnd')?.value || state.night_end || '--:--';\n const quietStart=$('plannerQuietStart')?.value || state.quiet_start || '--:--';\n const quietEnd=$('plannerQuietEnd')?.value || state.quiet_end || '--:--';\n const items=[\n plannerSummaryValue('Status', `${enabled ? 'on' : 'off'}${dryRun ? ' / dry-run' : ''}`),\n plannerSummaryValue('Profile', $('plannerProfileName')?.value || state.profile_name || '-'),\n plannerSummaryValue('Hourly', plannerOnOff($('plannerHourlyEnabled')?.checked)),\n plannerSummaryValue('Night', `${plannerOnOff($('plannerNightOnly')?.checked)} ${nightStart}-${nightEnd}`),\n plannerSummaryValue('Quiet', `${plannerOnOff($('plannerQuietEnabled')?.checked)} ${quietStart}-${quietEnd}`),\n plannerSummaryValue('Protection', `CPU ${plannerOnOff($('plannerCpuEnabled')?.checked)}, disk ${plannerOnOff($('plannerDiskEnabled')?.checked)}, network ${plannerOnOff($('plannerNetworkEnabled')?.checked)}, load ${plannerOnOff($('plannerLoadEnabled')?.checked)}`),\n ];\n box.innerHTML=`<div><b><i class=\"fa-solid fa-sliders\"></i> Current settings</b><small class=\"planner-diagnostic-line\">${items.join('<i class=\"fa-solid fa-circle fa-2xs diagnostic-separator\" aria-hidden=\"true\"></i>')}</small></div>`;\n }\n\n function updatePlannerFooter(enabled,preview={}){ updatePlannerCurrentSummary(preview); const btn=$('statusPlannerOpen'); if(btn){ btn.classList.toggle('d-none',!enabled); btn.classList.toggle('text-warning',!!preview.manual_override_until); btn.title=enabled?`Planner ${preview.matched_rule||'enabled'}${preview.dry_run?' · dry-run':''}`:'Download planner is disabled.'; const span=btn.querySelector('span'); if(span) span.textContent=preview.dry_run?'Planner dry-run':preview.manual_override_until?'Planner paused':'Planner'; } const badge=$('plannerStatusBadge'); if(badge){ badge.className=`badge ${enabled?'text-bg-success':'text-bg-secondary'}`; badge.textContent=enabled?(preview.dry_run?'dry-run':preview.manual_override_until?'override':'enabled'):'off'; } }\n function plannerDateText(value){ if(!value) return '-'; if(typeof value==='number') return formatDateTime(value); const d=new Date(value); return isNaN(d.getTime())?'-':d.toLocaleString(); }\n";
@@ -0,0 +1 @@
export const plannerSpeedControlsSource = " const plannerMbpsToBytes=mbps=>mbps?Math.round(Number(mbps)*1000000/8):0;\n const plannerBytesToMbps=bytes=>bytes?Math.round(Number(bytes)*8/1000000):0;\n function plannerLimitText(bytes){ const mbps=plannerBytesToMbps(Number(bytes||0)); return mbps?`${mbps} Mbit/s`:'Unlimited'; }\n function plannerHourLabel(hour){ return `${String(hour).padStart(2,'0')}:00-${String((hour+1)%24).padStart(2,'0')}:00`; }\n function renderPlannerHourlyGrid(){\n const box=$('plannerHourlyGrid'); if(!box) return;\n box.innerHTML=Array.from({length:24},(_,hour)=>`<div class=\"planner-hour-row\" data-hour=\"${hour}\"><span>${plannerHourLabel(hour)}</span><input id=\"plannerHour${hour}Down\" class=\"form-control form-control-sm planner-hour-input\" type=\"number\" min=\"0\" step=\"1024\" placeholder=\"DL B/s\"><input id=\"plannerHour${hour}Up\" class=\"form-control form-control-sm planner-hour-input\" type=\"number\" min=\"0\" step=\"1024\" placeholder=\"UL B/s\"><small id=\"plannerHour${hour}Summary\">Unlimited</small></div>`).join('');\n document.querySelectorAll('.planner-hour-input').forEach(input=>input.addEventListener('input',()=>updatePlannerHourSummary(Number(input.closest('.planner-hour-row')?.dataset.hour||0))));\n }\n function updatePlannerHourSummary(hour){ const down=Number($(`plannerHour${hour}Down`)?.value||0), up=Number($(`plannerHour${hour}Up`)?.value||0); const out=$(`plannerHour${hour}Summary`); if(out) out.textContent=`DL ${plannerLimitText(down)} / UL ${plannerLimitText(up)}`; }\n function fillPlannerHours(mbps){ const bytes=plannerMbpsToBytes(mbps); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=bytes; if(u)u.value=bytes; updatePlannerHourSummary(hour); } }\n function copyPlannerSpeedToHours(prefix){ const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=down; if(u)u.value=up; updatePlannerHourSummary(hour); } }\n function plannerHourlyPayload(){ return Array.from({length:24},(_,hour)=>({hour,down:Number($(`plannerHour${hour}Down`)?.value||0),up:Number($(`plannerHour${hour}Up`)?.value||0)})); }\n function setPlannerSpeed(prefix,mbps){\n const bytes=plannerMbpsToBytes(mbps);\n ['Down','Up'].forEach(dir=>{ const input=$(`${prefix}${dir}`); if(input) input.value=bytes; });\n updatePlannerSpeedControls(prefix);\n }\n function updatePlannerSpeedControls(prefix){\n const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0);\n [['Down',down],['Up',up]].forEach(([dir,value])=>{ const slider=$(`${prefix}${dir}Slider`), out=$(`${prefix}${dir}Mbps`); const mbps=plannerBytesToMbps(value); if(slider){ if(mbps>Number(slider.max||0)) slider.max=String(mbps); slider.value=String(mbps); } if(out) out.textContent=plannerLimitText(value); });\n const sum=$(`${prefix}Summary`); if(sum) sum.textContent=`DL ${plannerLimitText(down)} / UL ${plannerLimitText(up)}`;\n }\n function setupPlannerSpeedControls(){\n document.querySelectorAll('.planner-speed-preset').forEach(btn=>btn.addEventListener('click',()=>setPlannerSpeed(btn.dataset.prefix,Number(btn.dataset.mbps||0))));\n document.querySelectorAll('.planner-mbps-slider').forEach(slider=>slider.addEventListener('input',()=>{ const target=$(slider.dataset.target); if(target) target.value=plannerMbpsToBytes(Number(slider.value||0)); const prefix=(slider.dataset.target||'').replace(/(Down|Up)$/,''); updatePlannerSpeedControls(prefix); }));\n document.querySelectorAll('.planner-byte-input').forEach(input=>input.addEventListener('input',()=>updatePlannerSpeedControls(input.id.replace(/(Down|Up)$/,''))));\n }\n";
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
export const preferenceEventsSource = "$('appStatusRefreshBtn')?.addEventListener('click',loadAppStatus); $('portCheckEnabled')?.addEventListener('change',savePortCheckPref); $('portCheckNowBtn')?.addEventListener('click',()=>loadPortCheck(true)); $('bootstrapThemeSelect')?.addEventListener('change',saveAppearancePreferences); $('fontFamilySelect')?.addEventListener('change',saveAppearancePreferences); $('interfaceScaleRange')?.addEventListener('input',e=>applyInterfaceScale(e.target.value)); $('interfaceScaleRange')?.addEventListener('change',saveAppearancePreferences); $('compactTorrentListEnabled')?.addEventListener('change',saveAppearancePreferences); $('resetViewPreferencesBtn')?.addEventListener('click',resetViewPreferences); $('titleSpeedEnabled')?.addEventListener('change',saveTitleSpeedPreference); $('trackerFaviconsEnabled')?.addEventListener('change',saveTrackerFaviconsPreference); $('reverseDnsEnabled')?.addEventListener('change',saveReverseDnsPreference); $('automationToastsEnabled')?.addEventListener('change',saveNotificationPrefs); $('smartQueueToastsEnabled')?.addEventListener('change',saveNotificationPrefs); $('saveEasterEggPrefsBtn')?.addEventListener('click',saveEasterEggPrefs); $('easterEggEnabled')?.addEventListener('change',saveEasterEggPrefs); document.querySelectorAll('.disk-monitor-mode').forEach(input=>input.addEventListener('change',async e=>{ diskMonitorMode=e.target.value||'default'; if(diskMonitorMode==='selected' && !diskMonitorSelectedPath && diskMonitorPaths.length) diskMonitorSelectedPath=diskMonitorPaths[0]; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); })); $('diskMonitorSelectedPath')?.addEventListener('change',async e=>{ diskMonitorSelectedPath=e.target.value||''; if(diskMonitorSelectedPath) diskMonitorMode='selected'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('addDiskPathBtn')?.addEventListener('click',async()=>{ const p=($('diskMonitorPathInput')?.value||'').trim(); if(!p) return; if(!diskMonitorPaths.includes(p)) diskMonitorPaths.push(p); if(!diskMonitorSelectedPath) diskMonitorSelectedPath=p; if(diskMonitorMode==='default') diskMonitorMode='selected'; if($('diskMonitorPathInput')) $('diskMonitorPathInput').value=''; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('diskMonitorPaths')?.addEventListener('click',async e=>{ const use=e.target.closest('.disk-path-select'); if(use){ diskMonitorSelectedPath=use.dataset.path||''; diskMonitorMode='selected'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); return; } const btn=e.target.closest('.disk-path-remove'); if(!btn) return; diskMonitorPaths=diskMonitorPaths.filter(p=>p!==btn.dataset.path); if(diskMonitorSelectedPath===btn.dataset.path) diskMonitorSelectedPath=diskMonitorPaths[0]||''; if(diskMonitorMode==='selected' && !diskMonitorSelectedPath) diskMonitorMode='default'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('saveFooterPrefsBtn')?.addEventListener('click',saveFooterPreferences);\n ";
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
export const profileFormSource = " function profileFormPayload(){ return {id:$('profileId')?.value||null,name:$('profileName')?.value||'',scgi_url:$('profileUrl')?.value||'',timeout_seconds:$('profileTimeout')?.value||5,max_parallel_jobs:$('profileParallel')?.value||5,light_parallel_jobs:$('jobLightParallel')?.value||4,light_job_timeout_seconds:$('jobLightTimeout')?.value||300,heavy_job_timeout_seconds:$('jobHeavyTimeout')?.value||7200,pending_job_timeout_seconds:$('jobPendingTimeout')?.value||900,is_remote:$('profileRemote')?.checked}; }\n function renderProfileDiagnostics(d={}){ const box=$('profileDiagnosticsResult'); if(!box) return; const status=profileDiagnosticStatusLabel(d.status || (d.ok?'normal':'error')); const cls=profileDiagnosticStatusClass(status); const paths=d.base_paths||{}; const wp=d.write_permissions||{}; const disk=d.free_disk||{}; const firstDisk=Object.values(disk)[0]||{}; const cards=[['Status',`<span class=\"badge text-bg-${cls}\">${esc(status)}</span>`],['rTorrent',esc(d.version||'-')],['Library',esc(d.library_version||'-')],['Response',d.response_time_ms!=null?`${esc(d.response_time_ms)} ms`:'-'],['Slow threshold',d.slow_threshold_ms!=null?`${esc(d.slow_threshold_ms)} ms`:'-'],['Default path',esc(paths.default_directory||'-')],['CWD',esc(paths.cwd||'-')],['Write',esc(Object.values(wp)[0]||'-')],['Free disk',esc(firstDisk.free_h||firstDisk.error||'-')]]; box.classList.remove('text-muted'); box.innerHTML=`<div class=\"profile-diagnostics-grid\">${cards.map(([k,v])=>`<div class=\"profile-diagnostics-card\"><small>${esc(k)}</small><b>${v}</b></div>`).join('')}</div>${d.error?`<div class=\"alert alert-danger mt-2 mb-0\">${esc(d.error)}</div>`:''}`; }\n async function testProfilePayload(payload=null){ const p=payload||profileFormPayload(); const res=await post('/api/profiles/test', p); renderProfileDiagnostics(res.diagnostics||{}); return res.diagnostics||{}; }\n\n function resetProfileForm(){ if($('profileId')) $('profileId').value=''; if($('profileName')) $('profileName').value=''; if($('profileUrl')) $('profileUrl').value=''; if($('profileTimeout')) $('profileTimeout').value='5'; if($('profileParallel')) $('profileParallel').value='5'; if($('profileRemote')) $('profileRemote').checked=false; if($('profileFormTitle')) $('profileFormTitle').textContent='Add profile'; if($('saveProfileBtn')) $('saveProfileBtn').innerHTML='<i class=\"fa-solid fa-plus\"></i> Add profile'; $('cancelProfileEditBtn')?.classList.add('d-none'); }\n function editProfileForm(profile){ if(!profile) return; if($('profileId')) $('profileId').value=profile.id; if($('profileName')) $('profileName').value=profile.name||''; if($('profileUrl')) $('profileUrl').value=profile.scgi_url||''; if($('profileTimeout')) $('profileTimeout').value=profile.timeout_seconds||5; if($('profileParallel')) $('profileParallel').value=profile.max_parallel_jobs||5; if($('profileRemote')) $('profileRemote').checked=!!profile.is_remote; fillJobSettings(profile); if($('profileFormTitle')) $('profileFormTitle').textContent='Edit rTorrent profile'; if($('saveProfileBtn')) $('saveProfileBtn').innerHTML='<i class=\"fa-solid fa-floppy-disk\"></i> Save profile'; $('cancelProfileEditBtn')?.classList.remove('d-none'); $('profileName')?.focus(); }\n";
+1
View File
@@ -0,0 +1 @@
export const profileListSource = " function markActiveProfileRow(id){\n // Note: Keeps the active rTorrent profile frame in sync immediately after switching, before diagnostics refresh finishes.\n const activeId=String(id||'');\n document.querySelectorAll('#profileList .profile-row').forEach(row=>{\n const isActive=String(row.dataset.profileId||'')===activeId;\n row.classList.toggle('active', isActive);\n row.setAttribute('aria-current', isActive ? 'true' : 'false');\n const badge=row.querySelector('[data-active-profile-badge]');\n if(badge) badge.classList.toggle('d-none', !isActive);\n });\n }\n function profileDiagnosticStatusClass(status){\n // Note: rTorrent profile badges reuse Bootstrap colors and the same normal/slow/error idea as the poller panel.\n const value=String(status||'unknown').toLowerCase();\n if(value==='normal' || value==='online') return 'success';\n if(value==='slow' || value==='slowdown') return 'warning';\n if(value==='error' || value==='recovery') return 'danger';\n return 'secondary';\n }\n function profileDiagnosticStatusLabel(status){\n const value=String(status||'unknown').toLowerCase();\n return value==='online' ? 'normal' : value;\n }\n async function refreshProfiles(){ const j=await (await fetch('/api/profiles')).json(); profileCache=new Map((j.profiles||[]).map(p=>[String(p.id),p])); const active=String(j.active?.id ?? activeProfileId ?? ''); const rows=j.profiles||[]; const statusMap=new Map(); try{ const d=await (await fetch('/api/profiles/diagnostics')).json(); (d.diagnostics||[]).forEach(x=>statusMap.set(String(x.profile_id), x)); }catch(e){} $('profileList').innerHTML=rows.map(p=>{ const d=statusMap.get(String(p.id))||{}; const st=profileDiagnosticStatusLabel(d.status || 'unknown'); const cls=profileDiagnosticStatusClass(st); const response=d.response_time_ms?` · ${esc(d.response_time_ms)} ms`:''; const threshold=d.slow_threshold_ms?` · slow > ${esc(d.slow_threshold_ms)} ms`:''; const isActive=String(p.id)===active; return `<div class=\"profile-row ${isActive?'active':''}\" data-profile-id=\"${esc(p.id)}\" aria-current=\"${isActive?'true':'false'}\"><b>${esc(p.name)} <span data-active-profile-badge class='badge text-bg-primary ms-1 ${isActive?'':'d-none'}'>active</span> ${p.is_remote?\"<span class='badge text-bg-secondary ms-1'>remote</span>\":''} <span class=\"badge text-bg-${cls}\">${esc(st)}</span></b><span>${esc(p.scgi_url)} · heavy ${esc(p.max_parallel_jobs||5)} · light ${esc(p.light_parallel_jobs||4)} · poll ${esc(p.polling_min_interval_seconds||'-')}s${response}${threshold}</span><div class=\"profile-actions\"><button class=\"btn btn-xs btn-outline-primary\" data-use-profile=\"${p.id}\"><i class=\"fa-solid fa-plug-circle-check\"></i> use</button><button class=\"btn btn-xs btn-outline-info\" data-test-saved-profile=\"${p.id}\" title=\"Diagnostics\"><i class=\"fa-solid fa-stethoscope\"></i></button><button class=\"btn btn-xs btn-outline-secondary\" data-edit-profile=\"${p.id}\" title=\"Edit\"><i class=\"fa-solid fa-pen-to-square\"></i></button><button class=\"btn btn-xs btn-outline-danger\" data-del-profile=\"${p.id}\" title=\"Delete\"><i class=\"fa-solid fa-trash-can\"></i> Remove</button></div></div>`; }).join('')||'No profiles.'; }\n";
+1
View File
@@ -0,0 +1 @@
export const profileSelectionSource = " function renderProfileSelectionState(count=0){\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body = $('torrentBody');\n if(body){\n body.innerHTML = `<tr><td colspan=\"${torrentColumnSpan()}\" class=\"empty\"><div class=\"empty-state\"><b>Select an rTorrent profile.</b><span>${esc(count)} profile(s) are configured for this trusted bypass session. Choose which one to open.</span><button id=\"chooseProfileBtn\" class=\"btn btn-sm btn-primary\" type=\"button\"><i class=\"fa-solid fa-server\"></i> Choose profile</button></div></td></tr>`;\n }\n const list = $('mobileList');\n if(list) list.innerHTML = `<div class=\"empty\"><div class=\"empty-state\"><b>Select an rTorrent profile.</b><span>Choose a profile to load torrents.</span></div></div>`;\n if($('detailPane')) $('detailPane').innerHTML = 'Choose an rTorrent profile to load details.';\n }\n\n async function openProfilePicker(){\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n const select=$('profileSelect');\n if(select) select.innerHTML=(j.profiles||[]).map(p=>`<option value=\"${esc(p.id)}\" ${j.active?.id===p.id?'selected':''}>${esc(p.name)}</option>`).join('') || '<option value=\"\">No profiles configured</option>';\n }catch(e){}\n new bootstrap.Modal($('profilePickerModal')).show();\n }\n\n // Note: On trusted auth-bypass entry, existing profiles are not auto-selected; the visitor must choose the target profile.\n async function showFirstRunSetup(){\n if(hasActiveProfile || firstRunSetupShown) return;\n firstRunSetupShown = true;\n let profiles=[];\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n if(j.active?.id){\n activeProfileId=j.active.id;\n hasActiveProfile=true;\n window.PYTORRENT.activeProfile=Number(j.active.id);\n return;\n }\n profiles=j.profiles||[];\n }catch(e){}\n $('connBadge').className='badge text-bg-warning';\n if(profiles.length){\n $('connBadge').textContent='select profile';\n setInitialLoader('Select rTorrent profile','Choose which configured rTorrent profile to open.');\n renderProfileSelectionState(profiles.length);\n hideInitialLoader();\n setTimeout(()=>openProfilePicker(), 120);\n return;\n }\n $('connBadge').textContent='setup required';\n setInitialLoader('Configure rTorrent','Add the first rTorrent profile to start loading torrents.');\n renderNoProfileState();\n hideInitialLoader();\n setTimeout(()=>{ activateToolTab('rtorrents'); new bootstrap.Modal($('toolsModal')).show(); }, 120);\n }\n";
+1
View File
@@ -0,0 +1 @@
export const ratioToolsSource = " async function loadRatios(){ const j=await (await fetch('/api/ratio-groups')).json(); const groups=j.groups||[], history=j.history||[]; if($('ratioAssignSelect')) $('ratioAssignSelect').innerHTML=groups.map(g=>`<option value=\"${esc(g.name)}\">${esc(g.name)} (${esc(g.min_ratio)}-${esc(g.max_ratio)})</option>`).join(''); if($('ratioManager')) $('ratioManager').innerHTML=`<h6>Groups</h6>${table(['Name','Min','Max','Seed min','Action','Move path','Set label','Enabled'],groups.map(g=>[esc(g.name),esc(g.min_ratio),esc(g.max_ratio),esc(g.seed_time_minutes||g.min_seed_time_minutes||0),esc(g.action),esc(g.move_path||''),esc(g.set_label||''),g.enabled?'yes':'no']))}<h6 class=\"mt-3\">Applied history</h6>${table(['Time','Torrent','Group','Action','Status','Reason'],history.map(h=>[humanDateCell(h.created_at),esc(h.torrent_name||h.torrent_hash),esc(h.group_name||''),esc(h.action),esc(h.status),esc(h.reason||'')]))}`; }\n $('labelModal')?.addEventListener('show.bs.modal',async()=>{ modalLabels=new Set(selectedHashes().flatMap(h=>labelNames(torrents.get(h)?.label))); if($('labelInput')) $('labelInput').value=''; await loadLabels(); renderLabelChooser(); });\n $('saveLabelBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } await runAction('set_label',{label:labelValue([...modalLabels])}); bootstrap.Modal.getInstance($('labelModal'))?.hide(); });\n $('addLabelToSelectionBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } if($('labelInput')) $('labelInput').value=''; renderLabelChooser(); });\n $('clearLabelsBtn')?.addEventListener('click',()=>{ modalLabels.clear(); renderLabelChooser(); });\n $('labelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-chip'); if(!chip) return; const v=chip.dataset.label||''; modalLabels.has(v)?modalLabels.delete(v):modalLabels.add(v); renderLabelChooser(); });\n $('selectedLabelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-selected'); if(!chip) return; modalLabels.delete(chip.dataset.label||''); renderLabelChooser(); });\n $('newLabelBtn')?.addEventListener('click',async()=>{ await saveKnownLabel($('newLabelName')?.value||''); if($('newLabelName')) $('newLabelName').value=''; });\n $('ratioAssignModal')?.addEventListener('show.bs.modal',loadRatios); $('applyRatioBtn')?.addEventListener('click',async()=>{ await runAction('set_ratio_group',{ratio_group:$('ratioAssignSelect').value}); bootstrap.Modal.getInstance($('ratioAssignModal'))?.hide(); }); $('ratioSaveBtn')?.addEventListener('click',async()=>{ await post('/api/ratio-groups',{name:$('ratioName').value,min_ratio:$('ratioMin').value,max_ratio:$('ratioMax').value,seed_time_minutes:$('ratioSeed').value,action:$('ratioAction').value,move_path:$('ratioMovePath')?.value||'',set_label:$('ratioSetLabel')?.value||'',ignore_private:$('ratioIgnorePrivate')?.checked!==false,ignore_active_upload:$('ratioIgnoreUpload')?.checked!==false}); loadRatios(); }); $('ratioCheckBtn')?.addEventListener('click',async()=>{ const j=await post('/api/ratio-groups/check',{}); toast(`Ratio applied ${j.result?.applied||0} torrent(s)`,'success'); loadRatios(); });\n";
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
export const rssEventsSource = "$('rssFeedBtn')?.addEventListener('click',async()=>{await post('/api/rss/feeds',{id:$('rssFeedId')?.value||null,name:$('rssName').value,url:$('rssUrl').value,interval_minutes:$('rssInterval')?.value||30,enabled:true}); if($('rssFeedId')) $('rssFeedId').value=''; loadRss();}); $('rssRuleBtn')?.addEventListener('click',async()=>{await post('/api/rss/rules',{id:$('rssRuleId')?.value||null,name:$('rssRuleName').value,pattern:$('rssPattern').value,exclude_pattern:$('rssExclude')?.value||'',min_size_mb:$('rssMinSize')?.value||0,max_size_mb:$('rssMaxSize')?.value||0,category:$('rssCategory')?.value||'',quality:$('rssQuality')?.value||'',season:$('rssSeason')?.value||null,episode:$('rssEpisode')?.value||null,save_path:$('rssPath').value,label:$('rssLabel').value}); if($('rssRuleId')) $('rssRuleId').value=''; loadRss();}); $('rssTestBtn')?.addEventListener('click',async()=>{try{const j=await post('/api/rss/rules/test',{feed_url:$('rssUrl').value,rule:{pattern:$('rssPattern').value,exclude_pattern:$('rssExclude')?.value||'',min_size_mb:$('rssMinSize')?.value||0,max_size_mb:$('rssMaxSize')?.value||0,category:$('rssCategory')?.value||'',quality:$('rssQuality')?.value||'',season:$('rssSeason')?.value||null,episode:$('rssEpisode')?.value||null}}); $('rssTestResult').innerHTML=table(['Title','Reason'],(j.result?.matches||[]).map(x=>[esc(x.title),esc(x.reason)]));}catch(e){toast(e.message,'danger');}}); $('rssCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/rss/check',{}); toastMessage('toast.rssQueued','success',{queued:j.queued}); loadRss();}catch(e){toast(e.message,'danger');} finally{setBusy(false);}}); $('rssManager')?.addEventListener('click',async e=>{const ef=e.target.closest('.rss-edit-feed'); const er=e.target.closest('.rss-edit-rule'); const df=e.target.closest('.rss-delete-feed'); const dr=e.target.closest('.rss-delete-rule'); if(ef){const f=JSON.parse(ef.dataset.feed||'{}'); $('rssFeedId').value=f.id||''; $('rssName').value=f.name||''; $('rssUrl').value=f.url||''; $('rssInterval').value=f.interval_minutes||30;} if(er){const r=JSON.parse(er.dataset.rule||'{}'); $('rssRuleId').value=r.id||''; $('rssRuleName').value=r.name||''; $('rssPattern').value=r.pattern||''; $('rssExclude').value=r.exclude_pattern||''; $('rssMinSize').value=r.min_size_mb||''; $('rssMaxSize').value=r.max_size_mb||''; $('rssCategory').value=r.category||''; $('rssQuality').value=r.quality||''; $('rssSeason').value=r.season||''; $('rssEpisode').value=r.episode||''; $('rssPath').value=r.save_path||''; $('rssLabel').value=r.label||'';} if(df&&confirm('Delete RSS feed?')){await fetch(`/api/rss/feeds/${df.dataset.id}`,{method:'DELETE'}); loadRss();} if(dr&&confirm('Delete RSS rule?')){await fetch(`/api/rss/rules/${dr.dataset.id}`,{method:'DELETE'}); loadRss();}}); ";
+1
View File
@@ -0,0 +1 @@
export const rssToolsSource = "\n async function loadRss(){\n const j=await (await fetch('/api/rss')).json();\n const feeds=j.feeds||[], rules=j.rules||[], history=j.history||[];\n if($('rssManager')) $('rssManager').innerHTML=`<h6>Feeds</h6>${table(['Name','URL','Interval','Last check','Last error','Actions'],feeds.map(f=>[esc(f.name),esc(f.url),esc(f.interval_minutes||30)+' min',humanDateCell(f.last_checked_at),esc(f.last_error||''),`<button class=\"btn btn-xs btn-outline-primary rss-edit-feed\" data-feed='${esc(JSON.stringify(f))}'><i class=\"fa-solid fa-pen-to-square\"></i> Edit</button> <button class=\"btn btn-xs btn-outline-danger rss-delete-feed\" data-id=\"${esc(f.id)}\"><i class=\"fa-solid fa-trash-can\"></i> Delete</button>`]))}<h6 class=\"mt-3\">Rules</h6>${table(['Name','Include','Exclude','Filters','Path','Label','Actions'],rules.map(r=>[esc(r.name),esc(r.pattern),esc(r.exclude_pattern||''),esc([r.min_size_mb?`min ${r.min_size_mb}MB`:'',r.max_size_mb?`max ${r.max_size_mb}MB`:'',r.category,r.quality,r.season?`S${r.season}`:'',r.episode?`E${r.episode}`:''].filter(Boolean).join(', ')),esc(r.save_path),esc(r.label),`<button class=\"btn btn-xs btn-outline-primary rss-edit-rule\" data-rule='${esc(JSON.stringify(r))}'><i class=\"fa-solid fa-pen-to-square\"></i> Edit</button> <button class=\"btn btn-xs btn-outline-danger rss-delete-rule\" data-id=\"${esc(r.id)}\"><i class=\"fa-solid fa-trash-can\"></i> Delete</button>`]))}<h6 class=\"mt-3\">RSS log</h6>${table(['Time','Title','Status','Message'],history.map(h=>[humanDateCell(h.created_at),esc(h.title||h.link||''),esc(h.status),esc(h.message||'')]))}`;\n }\n\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
+1
View File
@@ -0,0 +1 @@
export const smartQueueEventsSource = "$('smartRefillMode')?.addEventListener('change',updateSmartRefillControls); $('smartSurgeRefillEnabled')?.addEventListener('change',updateSmartRefillControls); $('smartSaveBtn')?.addEventListener('click',saveSmartQueue); $('smartCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/smart-queue/check',{}); if(j.queued){toastMessage('toast.smartQueueCheckQueued','success'); await loadJobs().catch(()=>{}); await loadSmartQueue(); return;} const r=j.result||{}; if(j.torrent_patch) patchRows(j.torrent_patch); toast(smartQueueToastMessage(r),'success'); await loadSmartQueue();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('smartManager')?.addEventListener('click',async e=>{const h=e.target.closest('.smart-unexclude')?.dataset.hash; if(!h)return; await post('/api/smart-queue/exclusion',{hash:h,excluded:false}); await loadSmartQueue();}); ";
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
export const speedLimitControlsSource = "const mbpsToKib=mbps=>mbps?Math.round((Number(mbps)*1000000/8)/1024):0;\n const kibToMbps=kib=>kib?Math.round((Number(kib)*1024*8)/1000000):0;\n function setLimitSliderMax(slider,mbps){ if(slider && mbps>Number(slider.max||0)) slider.max=String(mbps); }\n function setLimitValue(targetId,kib){ const input=$(targetId); if(input) input.value=Math.max(0,Math.round(Number(kib)||0)); }\n function updateLimitSlider(slider){ if(!slider) return; const input=$(slider.dataset.target); const out=$(slider.dataset.output); const mbps=kibToMbps(Number(input?.value||0)); setLimitSliderMax(slider,mbps); slider.value=String(mbps); if(out) out.textContent=mbps?`${mbps} Mbit/s`:'Unlimited'; }\n function updateLimitSliders(){ document.querySelectorAll('.limit-slider').forEach(updateLimitSlider); }\n function syncLimitInputFromSlider(slider){ const mbps=Number(slider.value||0); setLimitValue(slider.dataset.target,mbpsToKib(mbps)); updateLimitSlider(slider); }\n document.querySelectorAll('.limit-preset').forEach(b=>b.addEventListener('click',()=>{const kib=mbpsToKib(Number(b.dataset.mbps||0));setLimitValue('limitDown',kib);setLimitValue('limitUp',kib);updateLimitSliders();}));\n document.querySelectorAll('.limit-slider').forEach(slider=>slider.addEventListener('input',()=>syncLimitInputFromSlider(slider)));\n ['limitDown','limitUp'].forEach(id=>$(id)?.addEventListener('input',updateLimitSliders));\n $('saveSpeedBtn')?.addEventListener('click',async()=>{const btn=$('saveSpeedBtn');buttonBusy(btn,true);setBusy(true);try{await post('/api/speed/limits',{down:Math.round(Number($('limitDown').value||0)*1024),up:Math.round(Number($('limitUp').value||0)*1024)});toast('Speed limits queued','success');bootstrap.Modal.getInstance($('speedModal'))?.hide();}catch(e){toast(e.message,'danger');}finally{buttonBusy(btn,false);setBusy(false);}}); $('speedModal')?.addEventListener('show.bs.modal',()=>{setLimitValue('limitDown',lastLimits.down?Math.round(lastLimits.down/1024):0);setLimitValue('limitUp',lastLimits.up?Math.round(lastLimits.up/1024):0);updateLimitSliders();});\n // Note: rTorrent profile management was moved to profiles.js so poller.js only keeps polling and tools wiring.\n ";
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
export const systemStatsSocketSource = " socket.on('system_stats',s=>{\n const usageAvailable=s.usage_available!==false && s.cpu!==undefined && s.ram!==undefined;\n $('statCpuBox')?.classList.toggle('d-none',!usageAvailable);\n $('statRamBox')?.classList.toggle('d-none',!usageAvailable);\n $('systemChart')?.classList.toggle('d-none',!usageAvailable);\n if(usageAvailable){\n $('statCpu').textContent=s.cpu??'-';\n $('statRam').textContent=s.ram??'-';\n drawSystemUsage(s.cpu,s.ram);\n }\n $('statVersion').textContent=s.version||'-';\n applyLiveSpeedStats(s);\n lastLimits={down:Number(s.down_limit||0),up:Number(s.up_limit||0)};\n $('statDlLimit').textContent=s.down_limit_h||'∞';\n $('statUlLimit').textContent=s.up_limit_h||'∞';\n $('statTotalDl').textContent=compactTransferText(s.total_down_h);\n $('statTotalUl').textContent=compactTransferText(s.total_up_h);\n updateSpeedPeaks(s.speed_peaks||{});\n drawTraffic(s.down_rate,s.up_rate);\n if(diskMonitorMode==='default'){\n drawDiskUsage(s.disk);\n }else{\n refreshUserDiskUsage(false);\n }\n updateRtorrentFooterStats(s, false);\n saveFooterStatusCache(s);\n if(s.poller) fillPoller(null,s.poller);\n applyFooterPreferences();\n });\n";
@@ -0,0 +1 @@
export const themeMobileControlsSource = "$('themeToggle')?.addEventListener('click',async()=>{const cur=document.documentElement.dataset.bsTheme==='dark'?'light':'dark';document.documentElement.dataset.bsTheme=cur;await post('/api/preferences',{theme:cur}).catch(()=>{});}); $('mobileToggle')?.addEventListener('click',()=>{document.body.classList.toggle('mobile-mode-manual');syncMobileMode();}); window.addEventListener('resize',()=>syncMobileMode(),{passive:true}); syncMobileMode();\n";
+1
View File
@@ -0,0 +1 @@
export const toolPaneEventsSource = "function switchAppStatusPane(pane){ document.querySelectorAll('#appStatusTabs [data-appstatus-pane], #appStatusManager [data-appstatus-pane]').forEach(x=>x.classList.toggle('active',x.dataset.appstatusPane===pane)); $('appStatusManager')?.querySelectorAll('[data-appstatus-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.appstatusPanel!==pane)); } $('appStatusTabs')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-appstatus-pane]'); if(tab) switchAppStatusPane(tab.dataset.appstatusPane); }); $('appStatusManager')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-appstatus-pane]'); if(tab) switchAppStatusPane(tab.dataset.appstatusPane); }); $('healthDashboardManager')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-health-pane]'); if(tab && typeof setHealthPane==='function') setHealthPane(tab.dataset.healthPane); }); $('torrentStatsManager')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-torrentstats-pane]'); if(tab && typeof setTorrentStatsPane==='function') setTorrentStatsPane(tab.dataset.torrentstatsPane); }); $('torrentStatsRefreshBtn')?.addEventListener('click',()=>loadTorrentStats(true)); $('authUserSaveBtn')?.addEventListener('click',saveAuthUser); $('authUserCancelBtn')?.addEventListener('click',resetAuthUserForm); $('authUsersManager')?.addEventListener('click',async e=>{ const edit=e.target.closest('.auth-edit'); const token=e.target.closest('.auth-token:not(.auth-token-list)'); const tokenList=e.target.closest('.auth-token-list'); const del=e.target.closest('.auth-delete'); if(edit){ editAuthUser(JSON.parse(edit.dataset.user||'{}')); return; } if(token){ await generateAuthToken(token.dataset.id); return; } if(tokenList){ await showAuthTokens(tokenList.dataset.id); return; } if(del && confirm('Delete user?')){ try{ const j=await post(`/api/auth/users/${del.dataset.id}`,{},'DELETE'); if(!j.ok) throw new Error(j.error||'Delete failed'); toast('User deleted','success'); await loadAuthUsers(); }catch(e){ toast(e.message,'danger'); } } }); ";
+1
View File
@@ -0,0 +1 @@
export const toolsModalSource = "ensurePlannerToolsUI(); try{const j=await fetch('/api/poller/settings').then(r=>r.json()); fillPoller(j.settings||{},j.runtime||{});}catch(e){} }\n async function savePollerSettings(){ try{const j=await post('/api/poller/settings',pollerPayload()); fillPoller(j.settings||pollerPayload(),null); toast('Poller settings saved','success');}catch(e){toast(e.message,'danger');} }\n ensurePlannerToolsUI(); ensureDashboardToolsUI(); loadDownloadPlanner(); $('toolsModal')?.addEventListener('show.bs.modal',()=>{ensurePlannerToolsUI();ensureDashboardToolsUI();refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadBackup();loadAppStatus();loadOperationLogs();renderHealthDashboard();renderSmartViewsManager();renderNotificationCenter();loadPreferences();loadJobSettings();if(document.querySelector('.tool-tab[data-tool=\"users\"]')?.classList.contains('active')) loadAuthUsers();loadDownloadPlanner();loadPollerSettings();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',torrentstats:'toolTorrentStats',preferences:'toolPreferences',jobs:'toolJobs',users:'toolUsers',labels:'toolLabels',ratio:'toolRatio',rss:'toolRss',columns:'toolColumns',smart:'toolSmart',automations:'toolAutomations',rtconfig:'toolRtconfig',cleanup:'toolCleanup',backup:'toolBackup',logs:'toolLogs',appstatus:'toolAppstatus',planner:'toolPlanner',poller:'toolPoller',smartviews:'toolSmartviews',notifications:'toolNotifications'}; const hideToolPanels=()=>Object.values(toolPanelIds).filter((v,i,a)=>a.indexOf(v)===i).forEach(id=>$(id)?.classList.add('d-none')); const showToolPanel=tool=>{hideToolPanels(); $(toolPanelIds[tool]||'toolRtorrents')?.classList.remove('d-none');}; const activateToolTab=tool=>{document.querySelectorAll('.tool-tab').forEach(x=>x.classList.toggle('active',(x.dataset.tool||'rtorrents')===tool)); showToolPanel(tool); if(tool==='torrentstats') loadTorrentStats(false); if(tool==='appstatus') loadAppStatus(); if(tool==='cleanup') loadCleanup(); if(tool==='backup') loadBackup(); if(tool==='preferences') loadPreferences(); if(tool==='jobs') loadJobSettings(); if(tool==='logs') loadOperationLogs(true); if(tool==='users') loadAuthUsers(); if(tool==='planner') loadDownloadPlanner(); if(tool==='poller') loadPollerSettings(); if(tool==='smartviews') renderSmartViewsManager(); if(tool==='notifications') renderNotificationCenter(); if(tool==='diagnostics') loadAppStatus(); }; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); bindOperationLogEvents(); ";
@@ -0,0 +1 @@
export const torrentActionStateSource = " function actionLabel(action){\n // Note: These labels are shown inside a torrent row, so they stay short and do not repeat the word torrent.\n const labels={start:'Start',pause:'Pause',stop:'Stop',resume:'Resume',recheck:'Check',reannounce:'Reannounce',remove:'Remove',move:'Move',set_label:'Set label',set_ratio_group:'Set ratio'};\n return labels[action] || `Working: ${action}`;\n }\n function actionIcon(action){\n return ({start:'fa-play',pause:'fa-pause',stop:'fa-stop',resume:'fa-play',recheck:'fa-rotate',reannounce:'fa-bullhorn',remove:'fa-trash',move:'fa-folder-open',set_label:'fa-tag',set_ratio_group:'fa-scale-balanced'}[action]) || 'fa-gears';\n }\n function markTorrentOperation(hashes, action, jobId, state='queued'){\n const label=actionLabel(action);\n [...new Set(hashes||[])].filter(Boolean).forEach(hash=>activeOperations.set(hash,{action,jobId,state,label,updatedAt:Date.now()}));\n scheduleRender(true);\n }\n function markQueuedJobs(response, fallbackHashes, action){\n // Note: Supports API responses that split one large user action into multiple queued bulk parts.\n const jobs=Array.isArray(response?.jobs)?response.jobs:[];\n if(jobs.length){ jobs.forEach(job=>markTorrentOperation(job.hashes||[],action,job.job_id,'queued')); return; }\n markTorrentOperation(fallbackHashes,action,response?.job_id,'queued');\n }\n function clearJobOperation(jobId, hashes=[]){\n if(jobId){ [...activeOperations].forEach(([hash,op])=>{ if(op.jobId===jobId) activeOperations.delete(hash); }); }\n (hashes||[]).forEach(hash=>activeOperations.delete(hash));\n scheduleRender(true);\n }\n function actionCompletionPatch(action, torrent){\n // Note: rTorrent can acknowledge light state actions before the next list read exposes the new status.\n const complete=Number(torrent?.complete||0) !== 0;\n if(['start','resume','unpause'].includes(action)) return {state:1, active:1, paused:false, post_check:false, status:complete?'Seeding':'Downloading'};\n if(action==='pause') return {state:1, active:0, paused:true, status:'Paused'};\n if(action==='stop') return {state:0, active:0, paused:false, post_check:false, status:'Stopped'};\n return null;\n }\n function applyActionCompletionState(action, hashes=[]){\n // Note: This optimistic patch keeps completed light actions visible while delayed cache refreshes settle.\n const unique=[...new Set(hashes||[])].filter(Boolean);\n let changed=false;\n unique.forEach(hash=>{\n const current=torrents.get(hash);\n const patch=actionCompletionPatch(action,current);\n if(!current || !patch) return;\n torrents.set(hash,{...current,...patch});\n changed=true;\n });\n if(changed) scheduleRender(true);\n }\n function activeOperationFor(t){ return activeOperations.get(t.hash) || null; }\n";
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
export const torrentDetailsLoaderSource = " async function loadDetails(tab, options={}){\n const t=torrents.get(selectedHash);\n const silent = !!options.silent;\n if(tab !== 'files') clearFilesAutoRefresh();\n if(tab !== 'peers') clearReverseDnsPeerRefresh();\n if($('peersRefreshBox')) $('peersRefreshBox').classList.toggle('d-none', tab!=='peers');\n setupPeersRefresh(tab);\n if(!t) return;\n if(tab==='general') return renderGeneral();\n if(tab==='log'){\n $('detailPane').innerHTML=`<pre class=\"torrent-log-message\">${esc(t.message||'No logs')}</pre>`;\n return;\n }\n const pane=$('detailPane');\n if(!silent) pane.innerHTML=`<div class=\"loading-line\"><span class=\"spinner-border spinner-border-sm\"></span> Loading ${esc(tab)}...</div>`;\n try{\n const detailUrl = tab==='chunks' ? `/api/torrents/${encodeURIComponent(selectedHash)}/chunks?max_cells=${chunkMaxCellsForDensity()}` : `/api/torrents/${encodeURIComponent(selectedHash)}/${tab}`;\n const res=await fetch(detailUrl,{headers:{'Accept':'application/json'}});\n const text=await res.text();\n let json;\n try{\n json=JSON.parse(text);\n }catch(parseErr){\n throw new Error(`Invalid API response for ${tab}. HTTP ${res.status}`);\n }\n if(!res.ok || !json.ok) throw new Error(json.error||`HTTP ${res.status}`);\n if(tab!==activeTab()) return;\n if(tab==='files') renderFiles(json.files||[]);\n if(tab==='chunks') renderChunks(json.chunks||{});\n if(tab==='peers') renderPeers(json.peers||[]);\n if(tab==='trackers') renderTrackers(json.trackers||[]);\n }catch(e){\n if(!silent) pane.innerHTML=`<div class=\"text-danger\">${esc(e.message)}</div>`;\n }\n }\n";
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
export const torrentFilterHelpersSource = " // Note: Displays status filter summaries calculated and cached by the backend API.\n const FILTER_COUNT_IDS = {all:'countAll', downloading:'countDownloading', seeding:'countSeeding', paused:'countPaused', checking:'countChecking', error:'countError', post_check:'countPostCheck', stopped:'countStopped', moving:'countMoving'};\n function formatFilterBytes(value){ return fmtBytes(value).replace(/\\.0 (?=GiB|TiB)/, ' '); }\n function filterMetaLine(bucket){\n if(!bucket || !Number(bucket.count||0)) return '';\n const disk=Number(bucket.disk_bytes ?? bucket.completed_bytes ?? 0);\n return `Data ${formatFilterBytes(disk)}`;\n }\n function filterNeedsDownloadDetails(type, bucket){\n if(!bucket || !Number(bucket.count||0)) return false;\n if(type==='downloading' || type==='post_check') return true;\n if(type!=='paused' && type!=='stopped') return false;\n const size=Number(bucket.size||0);\n const completed=Number(bucket.completed_bytes ?? bucket.disk_bytes ?? 0);\n const remaining=Number(bucket.remaining_bytes ?? Math.max(0, size-completed));\n const progress=Number(bucket.progress_percent ?? (size ? (completed / size) * 100 : 0));\n return size > 0 && remaining > 0 && progress < 100;\n }\n function filterTooltipLine(bucket, type){\n if(!bucket || !Number(bucket.count||0)) return '';\n const size=Number(bucket.size||0);\n const disk=Number(bucket.disk_bytes ?? bucket.completed_bytes ?? 0);\n const completed=Number(bucket.completed_bytes ?? disk);\n const remaining=Number(bucket.remaining_bytes ?? Math.max(0, size-completed));\n const progress=Number(bucket.progress_percent ?? (size ? (completed / size) * 100 : 0));\n const left=Number(bucket.remaining_percent ?? Math.max(0, 100-progress));\n const lines=[`Data: ${formatFilterBytes(disk)}`];\n if(filterNeedsDownloadDetails(type, bucket)){\n lines.push(`Total to download: ${formatFilterBytes(size)}`);\n lines.push(`Downloaded: ${formatFilterBytes(completed)} (${progress.toFixed(1)}%)`);\n lines.push(`Left: ${formatFilterBytes(remaining)} (${left.toFixed(1)}%)`);\n }\n return lines.join('\\n');\n }\n function applyFilterTooltip(button, tooltip, ariaLabel){\n if(tooltip){\n button.title = tooltip;\n button.setAttribute('aria-label', ariaLabel);\n } else {\n button.removeAttribute('title');\n button.removeAttribute('aria-label');\n }\n }\n function ensureStableFilterTooltip(button){\n if(filterTooltipState.has(button)) return filterTooltipState.get(button);\n const state = {hovering:false, pending:null};\n filterTooltipState.set(button, state);\n button.addEventListener('mouseenter', () => {\n state.hovering = true;\n state.pending = null;\n });\n button.addEventListener('mouseleave', () => {\n state.hovering = false;\n if(state.pending){\n applyFilterTooltip(button, state.pending.tooltip, state.pending.ariaLabel);\n state.pending = null;\n }\n });\n return state;\n }\n // Note: Freezes tooltip content during hover; the next hover receives the newest live summary.\n function setStableFilterTooltip(button, tooltip, ariaLabel){\n const state = ensureStableFilterTooltip(button);\n if(state.hovering){\n state.pending = {tooltip, ariaLabel};\n return;\n }\n applyFilterTooltip(button, tooltip, ariaLabel);\n }\n";
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
export const torrentGeneralDetailsSource = " function formatDateTime(seconds){ const n=Number(seconds||0); if(!n) return '-'; try{ return new Date(n*1000).toLocaleString(); }catch(e){ return '-'; } }\n function joinRemotePath(base,name){\n const b=String(base||'').trim();\n const n=String(name||'').trim();\n if(!b && !n) return '-';\n if(!n) return b || '-';\n if(!b) return n;\n return `${b.replace(/\\/+$/,'')}/${n.replace(/^\\/+/,'')}`;\n }\n function renderGeneral(){\n const t=torrents.get(selectedHash);\n if(!t){ $('detailPane').innerHTML='Select a torrent.'; return; }\n const labels=labelNames(t.label).map(l=>`<span class=\"chip label-mini\"><i class=\"fa-solid fa-tag\"></i> ${esc(l)}</span>`).join(' ') || '<span class=\"text-muted\">-</span>';\n const ratioGroup=t.ratio_group ? `<span class=\"badge text-bg-info\">${esc(t.ratio_group)}</span>` : '<span class=\"text-muted\">Not assigned</span>';\n const statusClass=t.status==='Seeding'?'success':t.status==='Downloading'?'primary':t.status==='Checking'?'warning':t.status==='Paused'?'secondary':t.status==='Stopped'?'dark':'secondary';\n const fullPath=joinRemotePath(t.path,t.name);\n const cards=[\n ['Size', esc(t.size_h||'-')],\n ['Downloaded', esc(t.down_total_h||'-')],\n ['Uploaded', esc(t.up_total_h||'-')],\n ['Ratio', esc(t.ratio??'-')],\n ['Download speed', esc(t.down_rate_h||'-')],\n ['Upload speed', esc(t.up_rate_h||'-')],\n ['Seeds / Peers', `${esc(t.seeds??0)} / ${esc(t.peers??0)}`],\n ['ETA', esc(t.eta_h||'-')],\n ['Created', esc(formatDateTime(t.created))],\n ['Last activity', esc(formatDateTime(t.last_activity))],\n ['Priority', esc(t.priority??'-')],\n ].map(([label,value])=>`<div class=\"general-stat\"><b>${label}</b><span>${value}</span></div>`).join('');\n $('detailPane').innerHTML=`\n <section class=\"general-summary\">\n <div class=\"general-summary-main\">\n <div class=\"general-title-row\"><h6>${esc(t.name||'-')}</h6><span class=\"badge text-bg-${statusClass}\">${esc(t.status||'-')}</span></div>\n <div class=\"general-path\"><b>Directory</b><span>${esc(t.path||'-')}</span></div>\n <div class=\"general-path\"><b>Full data path</b><span>${esc(fullPath)}</span></div>\n </div>\n <div class=\"general-summary-side\"><b>Hash</b><code>${esc(t.hash||'-')}</code></div>\n </section>\n <div class=\"general-grid\">${cards}</div>\n <div class=\"general-meta\"><div><b>Labels</b><span>${labels}</span></div><div><b>Ratio rule</b><span>${ratioGroup}</span></div><div><b>Message</b><span>${esc(t.message||'-')}</span></div></div>`;\n }\n";
@@ -0,0 +1 @@
export const torrentPeerDetailsSource = " function peerBadges(p){\n const badges=[];\n if(p.encrypted) badges.push('<span class=\"badge text-bg-success\">enc</span>');\n if(p.incoming) badges.push('<span class=\"badge text-bg-info\">in</span>');\n if(p.snubbed) badges.push('<span class=\"badge text-bg-warning\">snub</span>');\n if(p.banned) badges.push('<span class=\"badge text-bg-danger\">ban</span>');\n return badges.join(' ') || '<span class=\"text-muted\">-</span>';\n }\n function peerHostCell(p){\n const host=String(p.host||'').trim();\n // Note: Hostnames use the available peer-table space instead of a fixed character-style cap.\n if(host) return `<span class=\"peer-host\" title=\"${esc(host)}\">${esc(host)}</span>`;\n if(p.host_pending) return '<span class=\"text-muted\">resolving</span>';\n return '<span class=\"text-muted\">-</span>';\n }\n function hasPendingReverseDns(peers){\n return reverseDnsEnabled && (peers||[]).some(p=>p && p.host_pending);\n }\n function clearReverseDnsPeerRefresh(){\n clearTimeout(reverseDnsRefreshTimer);\n reverseDnsRefreshTimer=null;\n reverseDnsRefreshInFlight=false;\n reverseDnsRefreshAttempts=0;\n reverseDnsRefreshHash=null;\n }\n function scheduleReverseDnsPeerRefresh(peers){\n // Note: PTR results are checked on a short independent loop, not on the manual/auto peers refresh interval.\n if(!hasPendingReverseDns(peers)){ clearReverseDnsPeerRefresh(); return; }\n if(activeTab()!=='peers' || !selectedHash) return;\n const hash=selectedHash;\n if(reverseDnsRefreshHash!==hash){ reverseDnsRefreshHash=hash; reverseDnsRefreshAttempts=0; }\n if(reverseDnsRefreshTimer || reverseDnsRefreshAttempts>=REVERSE_DNS_REFRESH_MAX_ATTEMPTS) return;\n reverseDnsRefreshAttempts+=1;\n reverseDnsRefreshTimer=setTimeout(async()=>{\n reverseDnsRefreshTimer=null;\n if(activeTab()!=='peers' || selectedHash!==hash) return;\n reverseDnsRefreshInFlight=true;\n try{ await loadDetails('peers',{silent:true}); }\n finally{ reverseDnsRefreshInFlight=false; }\n }, REVERSE_DNS_REFRESH_SECONDS*1000);\n }\n function renderPeers(peers){\n const headers=['Flag','IP'];\n if(reverseDnsEnabled) headers.push('Host');\n headers.push('Country','City','Client','%','DL','UL','Port','Flags');\n const rows=(peers||[]).map(p=>{\n const row=[flag(p.country_iso),`<span class=\"peer-ip\">${esc(p.ip)}<a class=\"peer-ip-link\" href=\"https://ipinfo.io/${encodeURIComponent(p.ip||'')}\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"Open IP info\"><i class=\"fa-solid fa-link\"></i></a></span>`];\n if(reverseDnsEnabled) row.push(peerHostCell(p));\n row.push(esc(p.country),esc(p.city),esc(p.client),progressBar(p.completed,'peer-progress peer-progress-wide'),esc(p.down_rate_h),esc(p.up_rate_h),esc(p.port),peerBadges(p));\n return row;\n });\n $('detailPane').innerHTML=responsiveTable(headers,rows,reverseDnsEnabled ? 'peers-table peers-table-hosts' : 'peers-table');\n scheduleReverseDnsPeerRefresh(peers);\n }\n";
@@ -0,0 +1 @@
export const torrentRowRendererSource = " function statusMeta(t){\n const op=activeOperationFor(t);\n if(op) return {cls:'text-bg-info operation-status-badge', icon:actionIcon(op.action), color:'text-info', label:op.label};\n const status=String(t.status||'').toLowerCase();\n if(t.paused || status==='paused') return {cls:'text-bg-warning', icon:'fa-pause', color:'text-warning'};\n if(status==='checking' || Number(t.hashing||0)>0) return {cls:'text-bg-info', icon:'fa-rotate', color:'text-info'};\n if(status==='post-check' || t.post_check) return {cls:'text-bg-dark', icon:'fa-clipboard-check', color:'text-secondary', label:'Post-check'};\n if(status==='seeding') return {cls:'text-bg-success', icon:'fa-seedling', color:'text-success'};\n if(status==='downloading') return {cls:'text-bg-primary', icon:'fa-download', color:'text-primary'};\n if(status==='stopped') return {cls:'text-bg-secondary', icon:'fa-stop', color:'text-secondary'};\n return t.state ? {cls:'text-bg-success', icon:'fa-play', color:'text-success'} : {cls:'text-bg-secondary', icon:'fa-circle', color:'text-secondary'};\n }\n function statusBadge(t){ const m=statusMeta(t); return `<span class=\"badge status-badge ${m.cls}\"><i class=\"fa-solid ${m.icon} me-1\"></i>${esc(m.label || t.status)}</span>`; }\n function torrentWarning(t){ const msg=String(t.message||'').trim(); if(!msg) return null; const l=msg.toLowerCase(); const patterns=['error','failed','failure','timeout','timed out','tracker','could not','cannot','refused','unreachable','denied']; return patterns.some(p=>l.includes(p)) ? msg : null; }\n function torrentNameIcon(t){ const m=statusMeta(t); return `<i class=\"fa-solid ${m.icon} ${m.color}\"></i>`; }\n function boolCell(value){ return Number(value||0) ? '<span class=\"badge text-bg-success\">yes</span>' : '<span class=\"badge text-bg-secondary\">no</span>'; }\n function renderRow(t){\n const labels=labelNames(t.label).map(l=>`<span class=\"chip label-mini\"><i class=\"fa-solid fa-tag\"></i> ${esc(l)}</span>`).join(' ');\n const warn=torrentWarning(t);\n const op=activeOperationFor(t);\n const classes=[selected.has(t.hash)?'selected':'', t.paused?'torrent-paused':'', op?'torrent-operating':'', warn?'torrent-warning':''].filter(Boolean).join(' ');\n const title=[t.name,warn,op?op.label:''].filter(Boolean).join('\\n');\n return `<tr data-hash=\"${esc(t.hash)}\" class=\"${classes}\">`+\n `<td data-col=\"select\" class=\"sel\"><input class=\"row-check\" type=\"checkbox\" ${selected.has(t.hash)?'checked':''}></td>`+\n `<td data-col=\"name\" class=\"name\" title=\"${esc(title)}\">${warn?'<i class=\"fa-solid fa-triangle-exclamation torrent-warning-icon\"></i> ':''}${torrentNameIcon(t)} ${esc(t.name)}</td>`+\n `<td data-col=\"status\">${statusBadge(t)}</td>`+\n `<td data-col=\"size\">${esc(t.size_h)}</td>`+\n `<td data-col=\"progress\">${progress(t)}</td>`+\n `<td data-col=\"down_rate\">${esc(t.down_rate_h)}</td>`+\n `<td data-col=\"up_rate\">${esc(t.up_rate_h)}</td>`+\n `<td data-col=\"eta\">${esc(t.eta_h||\"-\")}</td>`+\n `<td data-col=\"seeds\">${esc(t.seeds)}</td>`+\n `<td data-col=\"peers\">${esc(t.peers)}</td>`+\n `<td data-col=\"ratio\">${esc(t.ratio)}</td>`+\n `<td data-col=\"path\" class=\"path\" title=\"${esc(t.path)}\">${esc(t.path)}</td>`+\n `<td data-col=\"label\">${labels||'<span class=\"text-muted\">-</span>'}</td>`+\n `<td data-col=\"ratio_group\">${esc(t.ratio_group||'')}</td>`+\n `<td data-col=\"down_total\">${esc(t.down_total_h||'-')}</td>`+\n `<td data-col=\"to_download\">${esc(t.to_download_h||'-')}</td>`+\n `<td data-col=\"up_total\">${esc(t.up_total_h||'-')}</td>`+\n `<td data-col=\"created\">${esc(formatDateTime(t.created))}</td>`+\n `<td data-col=\"last_activity\">${esc(formatDateTime(t.last_activity))}</td>`+\n `<td data-col=\"priority\">${esc(t.priority ?? '-')}</td>`+\n `<td data-col=\"state\">${boolCell(t.state)}</td>`+\n `<td data-col=\"active\">${boolCell(t.active)}</td>`+\n `<td data-col=\"complete\">${boolCell(t.complete)}</td>`+\n `<td data-col=\"hashing\">${esc(t.hashing ?? 0)}</td>`+\n `<td data-col=\"message\" class=\"message\" title=\"${esc(t.message||'')}\">${compactCell(t.message||'', 80)}</td>`+\n `<td data-col=\"hash\"><code>${esc(t.hash||'')}</code></td>`+\n `</tr>`;\n }\n\n\n\n\n";
@@ -0,0 +1 @@
export const torrentSelectionEventsSource = "function awaitMaybeRun(action){ runAction(action).catch?.(()=>{}); }\n function openRemoveModalForCurrentSelection(){\n // Note: Mobile remove uses the same Bootstrap modal as desktop, including the Remove with data switch.\n const modal=$('removeModal');\n if(!modal) return toast('Remove dialog is unavailable','danger');\n new bootstrap.Modal(modal).show();\n }\n document.addEventListener('click',e=>{ const ctx=$('ctxMenu'); if(!e.target.closest('#ctxMenu')) ctx.style.display='none'; const mobileFilter=e.target.closest('#mobileFilterBar .mobile-filter'); if(mobileFilter){ const key=mobileFilter.dataset.filter||'all'; if(key.startsWith('tracker:')){ activeTrackerFilter=key.slice(8); activeFilter='all'; mobileActiveFilterKey=key; } else { activeTrackerFilter=''; activeFilter=key; mobileActiveFilterKey=key; } syncFilterButtons(); saveActiveFilterPreference(); if($('tableWrap'))$('tableWrap').scrollTop=0; if($('mobileList'))$('mobileList').scrollTop=0; scheduleRender(true); return; } const mobileSort=e.target.closest('#mobileSortCycle'); if(mobileSort){ cycleMobileSort(); return; } const mobileSelectAll=e.target.closest('#mobileSelectAll'); if(mobileSelectAll){ toggleMobileVisibleSelection(); scheduleRender(true); return; } const mobileClear=e.target.closest('#mobileClearSelection'); if(mobileClear){ selected.clear(); selectedHash=null; lastSelectedHash=null; scheduleRender(true); return; } const mobileTorrentDownload=e.target.closest('#mobileBulkTorrentDownload'); if(mobileTorrentDownload){ downloadTorrentFiles(); return; } const mobileDetails=e.target.closest('.mobile-details-btn'); if(mobileDetails){ const card0=mobileDetails.closest('.mobile-card'); if(card0?.dataset.hash) openMobileDetails(card0.dataset.hash); return; } const mobileAct=e.target.closest('.mobile-card [data-action]'); if(mobileAct){ const card0=mobileAct.closest('.mobile-card'); selected.clear(); selected.add(card0.dataset.hash); selectedHash=card0.dataset.hash; lastSelectedHash=selectedHash; if(mobileAct.dataset.action==='remove') openRemoveModalForCurrentSelection(); else awaitMaybeRun(mobileAct.dataset.action); scheduleRender(true); return; } const mobileModal=e.target.closest('.mobile-card [data-mobile-modal]'); if(mobileModal){ const card0=mobileModal.closest('.mobile-card'); selected.clear(); selected.add(card0.dataset.hash); selectedHash=card0.dataset.hash; lastSelectedHash=selectedHash; scheduleRender(true); if(mobileModal.dataset.mobileModal==='label') new bootstrap.Modal($('labelModal')).show(); return; } const card=e.target.closest('.mobile-card'); const tr=e.target.closest('tr[data-hash]'); const row=tr||card; if(row){ const h=row.dataset.hash; const additive=e.ctrlKey||e.metaKey; if(e.shiftKey){ setSelectionRange(h, additive); } else if(e.target.classList.contains('row-check')){ e.target.checked?selected.add(h):selected.delete(h); lastSelectedHash=h; selectedHash=selected.size?h:null; } else { selectedHash=h; if(!additive)selected.clear(); selected.add(h); lastSelectedHash=h; loadDetails(activeTab()); } updateBulkBar(); scheduleRender(true); } const copy=e.target.closest('[data-copy]'); if(copy) copySelected(copy.dataset.copy); const torrentExport=e.target.closest('[data-download-torrent]'); if(torrentExport){ downloadTorrentFiles(); return; } const smartEx=e.target.closest('#smartExcludeCtx'); if(smartEx){ selectedHashes().forEach(h=>post('/api/smart-queue/exclusion',{hash:h,excluded:true,reason:'manual'}).catch(()=>{})); toast('Smart Queue exception saved','success'); loadSmartQueue().catch(()=>{}); } const act=e.target.closest('.torrent-action,[data-action]'); if(act&&act.dataset.action&&!act.closest('#detailTabs')&&!act.closest('.mobile-card')) runAction(act.dataset.action); });\n ";
@@ -0,0 +1 @@
export const torrentTableEventsSource = "document.addEventListener('contextmenu',e=>{ const tr=e.target.closest('tr[data-hash],.mobile-card'); if(!tr)return; e.preventDefault(); selectedHash=tr.dataset.hash; if(!selected.has(selectedHash)){selected.clear();selected.add(selectedHash);scheduleRender(true);} const m=$('ctxMenu'); m.style.left=`${e.pageX}px`; m.style.top=`${e.pageY}px`; m.style.display='block'; });\n setupDetailResizer();\n document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>th.addEventListener('click',()=>{ const key=th.dataset.sort; if(sortState.key===key) sortState.dir*=-1; else sortState={key,dir:1}; saveTorrentSortPreference(); scheduleRender(true); })); $('tableWrap')?.addEventListener('scroll',()=>scheduleRender(false),{passive:true}); $('selectAll')?.addEventListener('change',e=>{selected.clear(); if(e.target.checked)visibleRows.forEach(t=>selected.add(t.hash)); updateBulkBar(); scheduleRender(true);}); $('searchBox')?.addEventListener('input',()=>{if($('tableWrap'))$('tableWrap').scrollTop=0;scheduleRender(true);}); document.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('.filter').forEach(x=>x.classList.remove('active')); b.classList.add('active'); activeTrackerFilter=''; activeFilter=b.dataset.filter; mobileActiveFilterKey=activeFilter; saveActiveFilterPreference(); if($('tableWrap'))$('tableWrap').scrollTop=0; scheduleRender(true);})); document.querySelectorAll('#detailTabs .nav-link').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('#detailTabs .nav-link').forEach(x=>x.classList.remove('active')); b.classList.add('active'); loadDetails(b.dataset.tab);})); document.addEventListener('change',e=>{ const sel=e.target.closest('.file-priority'); if(sel){ setFilePriorities([{index:Number(sel.dataset.index),priority:Number(sel.value)}]); return; } if(e.target && e.target.id==='fileSelectAll'){ document.querySelectorAll('#detailPane .file-check').forEach(cb=>cb.checked=e.target.checked); } }); document.addEventListener('click',e=>{ const bulk=e.target.closest('.file-priority-bulk'); if(!bulk) return; const priority=Number(bulk.dataset.priority); const checked=[...document.querySelectorAll('#detailPane .file-check:checked')].map(cb=>({index:Number(cb.dataset.index),priority})); if(!checked.length) return toast('No files selected','warning'); setFilePriorities(checked); }); document.addEventListener('click',e=>{ const tree=e.target.closest('.file-tree-refresh'); if(tree){ loadFileTree(); return; } const mediaInfo=e.target.closest('.file-media-info'); if(mediaInfo){ openMediaInfo(mediaInfo.dataset.index); return; } const oneDownload=e.target.closest('.file-download-one'); if(oneDownload){ openTemporaryDownload(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${oneDownload.dataset.index}/download-link`).catch(err=>toast(err.message,'danger')); return; } const selectedDownload=e.target.closest('.file-download-selected'); if(selectedDownload){ downloadSelectedFiles(); return; } const allZip=e.target.closest('.file-download-zip'); if(allZip){ downloadZip(null); return; } const folder=e.target.closest('.folder-priority'); if(folder){ post(`/api/torrents/${encodeURIComponent(selectedHash)}/files/folder-priority`,{path:folder.dataset.path||'',priority:Number(folder.dataset.priority||0)}).then(()=>{toast('Folder priority updated','success');loadDetails('files');}).catch(err=>toast(err.message,'danger')); } }); document.addEventListener('click',e=>{ const cell=e.target.closest('.chunk-cell'); if(cell){ cell.classList.toggle('is-selected'); if(typeof updateChunkSelectionInfo==='function') updateChunkSelectionInfo(); return; } const refresh=e.target.closest('.chunk-refresh'); if(refresh){ loadDetails('chunks'); return; } const recheck=e.target.closest('.chunk-action-recheck'); if(recheck){ runChunkAction('recheck',{}); return; } const prio=e.target.closest('.chunk-action-prioritize'); if(prio){ const range=selectedChunkRange(); if(!range) return toast('No chunks selected','warning'); runChunkAction('prioritize_files',{...range,priority:2}); } }); document.addEventListener('click',e=>{ const add=e.target.closest('#trackerAddBtn'); if(add){ const url=$('trackerAddUrl')?.value||''; trackerAction('add',{url}); return; } const del=e.target.closest('.tracker-delete'); if(del && !del.disabled){ trackerAction('delete',{index:Number(del.dataset.index)}); return; } const rea=e.target.closest('#trackerReannounceBtn'); if(rea) trackerAction('reannounce',{}); }); ";
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
export const torrentTableStateSource = " function buildVisibleRows(){ visibleRows=[...torrents.values()].filter(rowVisible).sort(compareRows); $('statShown').textContent=visibleRows.length; }\n function visibleColumnKeys(){ return ['select', ...COLUMN_DEFS.map(([key])=>key)].filter(key => key === 'select' || !hiddenColumns.has(key)); }\n function applyColumnWidths(){\n // Note: Widths are applied to headers and virtualized body rows, keeping all cells aligned after live renders.\n const table = document.querySelector('.torrent-table');\n if(!table) return;\n let total = 0;\n visibleColumnKeys().forEach(key => { total += columnWidths[key] || DEFAULT_COLUMN_WIDTHS[key] || 120; });\n table.style.width = `${total}px`;\n table.style.minWidth = `${total}px`;\n document.querySelectorAll('.torrent-table [data-col]').forEach(el=>{\n const key = el.dataset.col;\n const width = columnWidths[key] || DEFAULT_COLUMN_WIDTHS[key] || 120;\n el.style.width = `${width}px`;\n el.style.minWidth = `${width}px`;\n el.style.maxWidth = `${width}px`;\n });\n }\n function applyColumnVisibility(){\n document.querySelectorAll('[data-col]').forEach(el=>el.classList.toggle('hidden-col', hiddenColumns.has(el.dataset.col)));\n applyColumnWidths();\n }\n function saveColumnWidthsPreference(){\n saveBrowserViewPrefs({columnWidths});\n savePreferencePatch({table_columns_json:columnPrefsPayload()}, 300);\n }\n function setupColumnResizers(){\n document.querySelectorAll('.torrent-table thead th[data-col]').forEach(th=>{\n const key = th.dataset.col;\n if(!key || key === 'select' || th.querySelector('.column-resize-handle')) return;\n const handle = document.createElement('span');\n handle.className = 'column-resize-handle';\n handle.title = 'Drag to resize column';\n handle.setAttribute('aria-hidden', 'true');\n th.appendChild(handle);\n let startX = 0, startWidth = 0, dragged = false;\n const onMove = (event) => {\n dragged = true;\n columnWidths[key] = clampNumber(startWidth + event.clientX - startX, COLUMN_WIDTH_MIN, COLUMN_WIDTH_MAX, startWidth);\n applyColumnWidths();\n };\n const onUp = () => {\n document.body.classList.remove('resizing-columns');\n document.removeEventListener('pointermove', onMove);\n document.removeEventListener('pointerup', onUp);\n if(dragged) saveColumnWidthsPreference();\n };\n handle.addEventListener('pointerdown', event=>{\n event.preventDefault();\n event.stopPropagation();\n dragged = false;\n startX = event.clientX;\n startWidth = columnWidths[key] || th.getBoundingClientRect().width || DEFAULT_COLUMN_WIDTHS[key] || 120;\n document.body.classList.add('resizing-columns');\n document.addEventListener('pointermove', onMove);\n document.addEventListener('pointerup', onUp);\n });\n handle.addEventListener('click', event=>event.stopPropagation());\n });\n }\n function syncActiveFilterSelection(){ syncFilterButtons(); }\n";
@@ -0,0 +1 @@
export const torrentTrackerDetailsSource = " function fmtTs(value){ const n=Number(value||0); if(!n) return '-'; try{return new Date(n*1000).toLocaleString();}catch(e){return String(n);} }\n function trackerSeedsPeers(t){ const hasScrape = t.seeds !== null || t.peers !== null; return hasScrape ? `${t.seeds ?? \"-\"} / ${t.peers ?? \"-\"}` : \"-\"; }\n function renderTrackers(trackers){\n // Note: Tracker URL editing is intentionally replaced by safe deletion; adding trackers remains unchanged.\n const pane=$('detailPane');\n const list=trackers||[];\n const canDelete=list.length>1;\n const rows=list.map(t=>{\n const idx=esc(t.index), url=esc(t.url);\n const deleteDisabled=canDelete ? '' : ' disabled title=\"At least one tracker must remain\"';\n return [`<span class=\"text-muted\">#${idx}</span>`, `<span class=\"tracker-url-text\">${url || '<span class=\"text-muted\">-</span>'}</span>`, t.enabled?'yes':'no', esc(trackerSeedsPeers(t)), esc(t.downloaded ?? '-'), fmtTs(t.last_announce), `<div class=\"tracker-actions\"><button class=\"btn btn-xs btn-outline-danger tracker-delete\" data-index=\"${idx}\"${deleteDisabled}><i class=\"fa-solid fa-trash\"></i> Delete</button></div>`];\n });\n // Note: Trackers share the responsive wrapper so long URLs do not break the details pane.\n pane.innerHTML=`<div class=\"tracker-toolbar\"><div class=\"input-group input-group-sm\"><input id=\"trackerAddUrl\" class=\"form-control tracker-add-input\" placeholder=\"https://tracker.example/announce\"><button id=\"trackerAddBtn\" class=\"btn btn-outline-primary\"><i class=\"fa-solid fa-plus\"></i> Add tracker</button></div><button id=\"trackerReannounceBtn\" class=\"btn btn-sm btn-outline-primary\"><i class=\"fa-solid fa-bullhorn\"></i> Reannounce</button></div>${responsiveTable(['#','URL','On','Seeds / Peers','Done','Last announce','Actions'], rows.length?rows:[[ '<span class=\"text-muted\">-</span>','<span class=\"text-muted\">No trackers.</span>','','','','','' ]], 'tracker-table')}`;\n }\n async function trackerAction(action,payload={}){\n if(!selectedHash) return toastMessage('toast.noTorrentSelected','warning');\n setBusy(true);\n try{\n const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/trackers/${action}`,payload);\n toast(j.message || appMessage('toast.trackerActionDone',{action}),'success');\n await loadDetails('trackers');\n }catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n\n";
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
export const trafficHistoryDataSource = " let lastTrafficHistory = null;\n let lastTrafficHistoryRange = '7d';\n let trafficHistoryAbort = null;\n const trafficHistoryCache = new Map();\n\n async function loadTrafficHistory(range=\"7d\", force=false){\n const info=$('trafficHistoryInfo');\n const volume=$('trafficHistoryChart');\n const speed=$('trafficSpeedChart');\n if(!volume||!speed) return;\n lastTrafficHistoryRange=range;\n const cached=trafficHistoryCache.get(range);\n if(cached && !force){\n lastTrafficHistory=cached;\n drawTrafficHistory(cached);\n updateTrafficHistoryInfo(cached);\n refreshTrafficHistoryInBackground(range);\n return;\n }\n if(info) info.textContent='Loading...';\n await fetchTrafficHistory(range, true);\n }\n\n async function refreshTrafficHistoryInBackground(range){\n try{ await fetchTrafficHistory(range, false); }catch(_){ }\n }\n\n async function fetchTrafficHistory(range, showErrors){\n if(trafficHistoryAbort) trafficHistoryAbort.abort();\n trafficHistoryAbort = new AbortController();\n try{\n const res=await fetch(`/api/traffic/history?range=${encodeURIComponent(range)}`,{signal:trafficHistoryAbort.signal,cache:'no-store'});\n const j=await res.json();\n if(!j.ok) throw new Error(j.error||'Failed to load history');\n const history=j.history || {rows:[],range};\n trafficHistoryCache.set(range, history);\n if(range===lastTrafficHistoryRange){\n lastTrafficHistory=history;\n drawTrafficHistory(history);\n updateTrafficHistoryInfo(history);\n }\n }catch(e){\n if(e.name==='AbortError') return;\n if(showErrors){\n const info=$('trafficHistoryInfo');\n if(info) info.textContent=e.message;\n [$('trafficHistoryChart'),$('trafficSpeedChart')].forEach(c=>{ if(c) c.getContext('2d').clearRect(0,0,c.width,c.height); });\n }\n }finally{\n trafficHistoryAbort=null;\n }\n }\n\n function updateTrafficHistoryInfo(hist){\n const info=$('trafficHistoryInfo');\n if(!info) return;\n const rows=Array.isArray(hist.rows)?hist.rows:[];\n const bucket=hist.bucket||'bucket';\n info.textContent=rows.length ? `${rows.length} ${bucket} bucket(s), retention ${hist.retention_days||90} days.` : 'No retained samples yet. Data is stored every minute while pyTorrent is running.';\n }\n\n";
+59 -19
View File
@@ -239,11 +239,11 @@ body {
display: grid; display: grid;
place-items: center; place-items: center;
padding: 1rem; padding: 1rem;
background: radial-gradient( background:
circle at 50% 35%, radial-gradient(circle at 50% 32%, rgba(var(--bs-secondary-bg-rgb), 0.92), transparent 42%),
rgba(var(--bs-secondary-bg-rgb), 0.98), var(--bs-body-bg);
var(--bs-body-bg) 68% isolation: isolate;
); overflow: auto;
color: var(--bs-body-color); color: var(--bs-body-color);
transition: transition:
opacity 0.22s ease, opacity 0.22s ease,
@@ -256,10 +256,12 @@ body {
} }
.initial-loader-card { .initial-loader-card {
width: min(92vw, 430px); width: min(92vw, 430px);
max-height: calc(100vh - 2rem);
overflow: auto;
padding: 2rem; padding: 2rem;
border: 1px solid var(--bs-border-color); border: 1px solid var(--bs-border-color);
border-radius: 18px; border-radius: 18px;
background: rgba(var(--bs-secondary-bg-rgb), 0.88); background: var(--bs-secondary-bg);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.48); box-shadow: 0 24px 70px rgba(0, 0, 0, 0.48);
text-align: center; text-align: center;
} }
@@ -269,14 +271,25 @@ body {
letter-spacing: 0.2px; letter-spacing: 0.2px;
} }
.initial-loader-spinner { .initial-loader-spinner {
margin: 1.4rem 0 1rem; display: flex;
align-items: center;
justify-content: center;
min-height: 132px;
margin: 1.25rem 0 1rem;
} }
.initial-loader-easter-egg-image,
.initial-loader-prank img { .initial-loader-prank img {
display: block;
width: auto;
max-width: min(100%, 320px); max-width: min(100%, 320px);
max-height: 220px; height: auto;
max-height: 150px;
object-fit: contain; object-fit: contain;
border-radius: 14px; border-radius: 14px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35); box-shadow: 0 10px 28px rgba(0, 0, 0, 0.26);
}
.initial-loader-easter-egg-image {
contain: layout paint;
} }
.prank-click-image { .prank-click-image {
position: fixed; position: fixed;
@@ -302,6 +315,21 @@ body {
margin-top: 0.35rem; margin-top: 0.35rem;
color: var(--bs-secondary-color); color: var(--bs-secondary-color);
} }
@media (max-height: 620px) {
.initial-loader-card {
padding: 1.5rem;
}
.initial-loader-spinner {
min-height: 96px;
margin: 1rem 0 0.75rem;
}
.initial-loader-easter-egg-image {
max-height: 110px;
}
}
.main-grid { .main-grid {
min-height: 0; min-height: 0;
display: grid; display: grid;
@@ -2466,11 +2494,11 @@ body.mobile-mode .mobile-filter-bar {
min-height: 100vh; min-height: 100vh;
place-items: center; place-items: center;
padding: 1rem; padding: 1rem;
background: radial-gradient( background:
circle at 50% 35%, radial-gradient(circle at 50% 32%, rgba(var(--bs-secondary-bg-rgb), 0.92), transparent 42%),
rgba(var(--bs-secondary-bg-rgb), 0.98), var(--bs-body-bg);
var(--bs-body-bg) 68% isolation: isolate;
); overflow: auto;
color: var(--bs-body-color); color: var(--bs-body-color);
} }
@@ -2761,11 +2789,11 @@ body.mobile-mode .mobile-filter-bar {
min-height: 100vh; min-height: 100vh;
place-items: center; place-items: center;
padding: 1rem; padding: 1rem;
background: radial-gradient( background:
circle at 50% 35%, radial-gradient(circle at 50% 32%, rgba(var(--bs-secondary-bg-rgb), 0.92), transparent 42%),
rgba(var(--bs-secondary-bg-rgb), 0.98), var(--bs-body-bg);
var(--bs-body-bg) 68% isolation: isolate;
); overflow: auto;
color: var(--bs-body-color); color: var(--bs-body-color);
} }
@@ -2967,6 +2995,17 @@ body.mobile-mode .mobile-filter-bar {
display: grid; display: grid;
gap: 0.3rem; gap: 0.3rem;
} }
.smart-surge-cooldown-card {
background: rgba(var(--bs-info-rgb), 0.08);
}
.smart-surge-refill-controls {
display: grid;
grid-template-columns: repeat(2, minmax(110px, 1fr));
gap: 0.55rem;
width: min(330px, 100%);
}
.disk-monitor-shell { .disk-monitor-shell {
display: grid; display: grid;
grid-template-columns: minmax(240px, 0.9fr) minmax(280px, 1.1fr); grid-template-columns: minmax(240px, 0.9fr) minmax(280px, 1.1fr);
@@ -3041,6 +3080,7 @@ body.mobile-mode .mobile-filter-bar {
flex-direction: column; flex-direction: column;
} }
.smart-surge-refill-controls,
.disk-monitor-shell { .disk-monitor-shell {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
File diff suppressed because one or more lines are too long
-62
View File
@@ -1,62 +0,0 @@
import assert from 'node:assert/strict';
global.window = {PYTORRENT_DISABLE_AUTOSTART: true};
const app = await import('../pytorrent/static/js/app.js');
const source = app.buildRuntimeSource();
assert.equal(app.moduleSources.length, 18, 'all frontend module chunks are loaded');
assert.doesNotThrow(() => Function('io', source), 'assembled frontend runtime compiles');
for (const marker of [
'function renderRow',
'function renderTable',
'function scheduleRender',
'async function post',
'async function loadRss',
'async function loadSmartQueue',
'function ensurePlannerToolsUI',
'function loadPlannerPreview',
'function pollerPayload',
'function pollerDiagnostics',
'function renderHealthDashboard',
'function recordNotification',
'function drawTrafficHistory',
"socket.on('connect'",
'/api/download-planner/preview',
'plannerProfileName',
'pollerTorrentList',
]) {
assert.ok(source.includes(marker), `runtime contains ${marker}`);
}
function extractFunction(src, name){
const start = src.indexOf(`function ${name}`);
assert.ok(start >= 0, `found function ${name}`);
const open = src.indexOf('{', start);
let depth = 0;
for(let i=open; i<src.length; i++){
const ch = src[i];
if(ch === '{') depth++;
if(ch === '}') depth--;
if(depth === 0) return src.slice(start, i + 1);
}
throw new Error(`unterminated function ${name}`);
}
const escLine = source.match(/const esc = .*?;\n/)?.[0];
assert.ok(escLine, 'found esc helper');
const renderHarness = new Function(`${escLine}
${extractFunction(source, 'progressBar')}
${extractFunction(source, 'compactCell')}
${extractFunction(source, 'table')}
${extractFunction(source, 'smartQueueToastMessage')}
return {esc, progressBar, compactCell, table, smartQueueToastMessage};`)();
assert.equal(renderHarness.esc('<tag>&"'), '&lt;tag&gt;&amp;&quot;', 'esc escapes HTML');
assert.ok(renderHarness.progressBar(42).includes('42%'), 'progressBar renders percentage');
assert.ok(renderHarness.compactCell('x'.repeat(200)).includes('title='), 'compactCell renders title for long text');
assert.ok(renderHarness.table(['A'], [['B']]).includes('<table'), 'table renders HTML table');
assert.ok(renderHarness.smartQueueToastMessage({stopped:[1,2], started:[3], max_active_downloads:5}).includes('Smart Queue:'), 'smartQueue toast renders');
console.log('frontend module tests passed');
-106
View File
@@ -1,106 +0,0 @@
from datetime import datetime
from pathlib import Path
import sys
import time
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from pytorrent.services import download_planner, poller_control
def test_planner_evaluate_network_caps():
download_planner._override_until = lambda profile_id: ""
settings = download_planner.normalize({
"enabled": True,
"profile_name": "low power mode",
"network_protection_enabled": True,
"network_max_down": 100,
"network_max_up": 50,
"weekday_down": 1000,
"weekday_up": 500,
})
decision = download_planner.evaluate({"id": 1}, settings, datetime(2026, 5, 13, 12, 0))
assert decision["profile_name"] == "low power mode"
assert decision["down"] == 100
assert decision["up"] == 50
assert "network_limit_down" in decision["reasons"]
def test_poller_metrics_and_fallback():
settings = poller_control.normalize_settings({
"active_interval_seconds": -1,
"safe_fallback_enabled": True,
"slow_response_threshold_ms": 200,
})
assert settings["active_interval_seconds"] > 0
state = poller_control.ProfilePollState(profile_id=1)
runtime = poller_control.mark_tick(
state,
time.monotonic() - 0.5,
active=True,
ok=True,
emitted_payload_size=1234,
rtorrent_call_count=2,
skipped_emissions=1,
settings=settings,
)
assert runtime["emitted_payload_size"] == 1234
assert runtime["rtorrent_call_count"] == 2
assert runtime["adaptive_mode"] in {"normal", "idle", "slowdown", "recovery"}
fixed_state = poller_control.ProfilePollState(profile_id=2, adaptive_mode="slowdown", slow_count=5)
fixed_runtime = poller_control.mark_tick(
fixed_state,
time.monotonic() - 1.0,
active=True,
ok=True,
settings={**settings, "adaptive_enabled": False},
)
assert fixed_runtime["adaptive_enabled"] is False
assert fixed_runtime["adaptive_mode"] == "fixed"
assert fixed_runtime["slow_count"] == 0
def test_poller_background_slow_task_state():
state = poller_control.ProfilePollState(profile_id=3)
assert state.slow_task_running is False
state.slow_task_running = True
runtime = poller_control.mark_tick(
state,
time.monotonic() - 0.05,
active=True,
ok=True,
settings={"adaptive_enabled": False, "slow_response_threshold_ms": 200},
skipped_emissions=1,
)
assert runtime["adaptive_mode"] == "fixed"
assert runtime["skipped_emissions"] >= 1
assert state.slow_task_running is True
def test_poller_requested_fast_defaults():
settings = poller_control.normalize_settings({})
assert settings["active_interval_seconds"] == 0.5
assert settings["torrent_list_interval_seconds"] == 0.5
assert settings["idle_interval_seconds"] == 3.0
assert settings["error_interval_seconds"] == 2.0
assert settings["system_stats_interval_seconds"] == 1.0
assert settings["tracker_stats_interval_seconds"] == 30.0
assert settings["disk_stats_interval_seconds"] == 30.0
assert settings["queue_stats_interval_seconds"] == 5.0
assert settings["heartbeat_interval_seconds"] == 5.0
assert settings["slow_response_threshold_ms"] == 10000.0
assert settings["slowdown_multiplier"] == 1.0
state = poller_control.ProfilePollState(profile_id=4)
runtime = poller_control.mark_tick(state, time.monotonic() - 0.01, active=True, ok=True, settings=settings)
assert runtime["effective_interval_seconds"] == 0.5
assert runtime["configured_min_interval_seconds"] == 0.5
assert "last_tick_gap_ms" in runtime
if __name__ == "__main__":
test_planner_evaluate_network_caps()
test_poller_metrics_and_fallback()
test_poller_background_slow_task_state()
test_poller_requested_fast_defaults()
print("planner/poller service smoke tests passed")