Fix multiproile settings #29

Merged
gru merged 3 commits from fix_multiproile_settings into master 2026-06-16 19:36:53 +02:00
16 changed files with 118 additions and 25 deletions
+9
View File
@@ -372,6 +372,15 @@ CREATE TABLE IF NOT EXISTS traffic_history (
CREATE INDEX IF NOT EXISTS idx_traffic_history_profile_created ON traffic_history(profile_id, created_at);
CREATE TABLE IF NOT EXISTS profile_speed_limits (
profile_id INTEGER PRIMARY KEY,
down_limit INTEGER DEFAULT 0,
up_limit INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS transfer_speed_peaks (
profile_id INTEGER PRIMARY KEY,
session_started_at TEXT NOT NULL,
+16
View File
@@ -133,10 +133,26 @@ def migrate_operation_log_split_retention(conn: sqlite3.Connection) -> bool:
return changed
def migrate_profile_speed_limits_table(conn: sqlite3.Connection) -> bool:
existing = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='profile_speed_limits'").fetchone()
conn.execute("""
CREATE TABLE IF NOT EXISTS profile_speed_limits (
profile_id INTEGER PRIMARY KEY,
down_limit INTEGER DEFAULT 0,
up_limit INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) ON DELETE CASCADE
)
""")
return existing is None
MIGRATIONS: tuple[Migration, ...] = (
migrate_disk_monitor_preferences_to_profile_scope,
migrate_profile_preferences_sidebar_columns,
migrate_operation_log_split_retention,
migrate_profile_speed_limits_table,
)
+5 -2
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from ._shared import *
from ..services import profile_speed_limits
from ..services import pdf_preview_links, torrent_creator
from ..services.reverse_dns import attach_reverse_dns
@@ -667,8 +668,10 @@ def speed_limits():
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {}
job_id = enqueue("set_limits", profile["id"], {"down": data.get("down"), "up": data.get("up")})
return ok({"job_id": job_id})
limits = profile_speed_limits.save_limits(profile["id"], data.get("down"), data.get("up"))
# Note: Manual speed limits are stored once per rTorrent profile, so every user opening this profile sees and applies the same values.
job_id = enqueue("set_limits", profile["id"], {"down": limits["down"], "up": limits["up"]})
return ok({"job_id": job_id, "limits": limits})
def _user_disk_status(profile: dict) -> dict:
@@ -0,0 +1,48 @@
from __future__ import annotations
from ..db import connect, utcnow
def normalize_limit(value: object) -> int:
try:
limit = int(float(value or 0))
except (TypeError, ValueError):
return 0
return max(0, limit)
def get_limits(profile_id: int | None) -> dict:
profile_id = int(profile_id or 0)
if not profile_id:
return {"down": 0, "up": 0, "configured": False}
with connect() as conn:
row = conn.execute("SELECT down_limit, up_limit FROM profile_speed_limits WHERE profile_id=?", (profile_id,)).fetchone()
if not row:
return {"down": 0, "up": 0, "configured": False}
return {"down": int(row.get("down_limit") or 0), "up": int(row.get("up_limit") or 0), "configured": True}
def save_limits(profile_id: int, down: object, up: object) -> dict:
profile_id = int(profile_id or 0)
if not profile_id:
raise ValueError("Missing profile id")
clean = {"down": normalize_limit(down), "up": normalize_limit(up), "configured": True}
now = utcnow()
with connect() as conn:
conn.execute(
"""
INSERT INTO profile_speed_limits(profile_id, down_limit, up_limit, created_at, updated_at)
VALUES(?,?,?,?,?)
ON CONFLICT(profile_id) DO UPDATE SET
down_limit=excluded.down_limit,
up_limit=excluded.up_limit,
updated_at=excluded.updated_at
""",
(profile_id, clean["down"], clean["up"], now, now),
)
return clean
def delete_limits(profile_id: int) -> None:
with connect() as conn:
conn.execute("DELETE FROM profile_speed_limits WHERE profile_id=?", (int(profile_id or 0),))
+2 -2
View File
@@ -61,7 +61,7 @@ def _apply_profile(socketio, profile: dict) -> None:
return
_applied_profiles.add(profile_id)
_log_status(profile, "applied", "Saved rTorrent startup config overrides applied", result=result)
socketio.emit("rtorrent_config_applied", {"profile_id": profile_id, "result": result})
socketio.emit("rtorrent_config_applied", {"profile_id": profile_id, "result": result}, to=f"profile:{int(profile_id)}")
def schedule_startup_config_apply(socketio, delay_seconds: int = 60, retry_seconds: int = 30, max_wait_seconds: int = 3600) -> None:
@@ -97,7 +97,7 @@ def schedule_startup_config_apply(socketio, delay_seconds: int = 60, retry_secon
action="rtorrent_config",
details={"error": str(exc)},
)
socketio.emit("rtorrent_config_applied", {"ok": False, "error": str(exc)})
socketio.emit("rtorrent_config_applied", {"ok": False, "profile_id": int(profile_id or 0), "error": str(exc)}, to=f"profile:{int(profile_id or 0)}" if profile_id else None)
socketio.sleep(max(5, int(retry_seconds)))
socketio.start_background_task(runner)
+25 -8
View File
@@ -9,7 +9,7 @@ from .preferences import active_profile, get_profile
from ..db import default_user_id
from .torrent_cache import torrent_cache
from .torrent_summary import cached_summary
from . import rtorrent, smart_queue, traffic_history, automation_rules, torrent_stats, auth, speed_peaks, poller_control, download_planner
from . import rtorrent, smart_queue, traffic_history, automation_rules, torrent_stats, auth, speed_peaks, poller_control, download_planner, profile_speed_limits
def _profile_room(profile_id: int) -> str:
@@ -27,8 +27,9 @@ def _poller_profiles() -> list[dict]:
def emit_profile_event(socketio, event: str, payload: dict, profile_id: int) -> None:
target = _profile_room(profile_id) if auth.enabled() else None
socketio.emit(event, payload, to=target) if target else socketio.emit(event, payload)
# Note: Profile-scoped events always go to the selected profile room, even when authentication is disabled.
scoped_payload = {**(payload or {}), "profile_id": int(profile_id)}
socketio.emit(event, scoped_payload, to=_profile_room(profile_id))
def _emit_profile(socketio, event: str, payload: dict, profile_id: int) -> None:
@@ -37,13 +38,21 @@ def _emit_profile(socketio, event: str, payload: dict, profile_id: int) -> None:
def _apply_configured_speed_limits(profile: dict) -> None:
limits = profile_speed_limits.get_limits(int(profile.get("id") or 0))
if not limits.get("configured"):
return
# Note: Profile-level speed limits are re-applied when the profile is opened so they are not tied to a specific user session.
rtorrent.set_limits(profile, limits.get("down"), limits.get("up"))
def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None:
state = poller_control.state_for(profile_id)
# Note: Background checks keep the profile owner so bypass/admin profiles do not enqueue jobs as the fallback user.
profile_user_id = int(profile.get("user_id") or default_user_id())
try:
try:
torrent_stats.queue_refresh(socketio, profile, force=False, room=_profile_room(profile_id) if auth.enabled() else None)
torrent_stats.queue_refresh(socketio, profile, force=False, room=_profile_room(profile_id))
except Exception as exc:
_emit_profile(socketio, "torrent_stats_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
try:
@@ -282,10 +291,14 @@ def register_socketio_handlers(socketio):
if not profile:
emit("profile_required", {"ok": True, "profiles": []})
return
try:
_apply_configured_speed_limits(profile)
except Exception as exc:
emit("rtorrent_error", {"profile_id": profile["id"], "error": str(exc)})
rows = torrent_cache.snapshot(profile["id"])
emit("torrent_snapshot", {"profile_id": profile["id"], "torrents": rows, "summary": cached_summary(profile["id"], rows), "speed_status": _speed_status_from_rows(profile["id"], rows)})
emit("poller_settings", {"settings": poller_control.get_settings(int(profile["id"])), "runtime": poller_control.snapshot(int(profile["id"]))})
emit("download_plan_update", {"settings": download_planner.get_settings(int(profile["id"]))})
emit("poller_settings", {"profile_id": int(profile["id"]), "settings": poller_control.get_settings(int(profile["id"])), "runtime": poller_control.snapshot(int(profile["id"]))})
emit("download_plan_update", {"profile_id": int(profile["id"]), "settings": download_planner.get_settings(int(profile["id"]))})
@socketio.on("select_profile")
def handle_select_profile(data):
@@ -304,8 +317,12 @@ def register_socketio_handlers(socketio):
emit("rtorrent_error", {"error": "Profile access denied or profile does not exist"})
return
join_room(_profile_room(profile_id))
try:
_apply_configured_speed_limits(profile)
except Exception as exc:
emit("rtorrent_error", {"profile_id": profile_id, "error": str(exc)})
diff = torrent_cache.refresh(profile)
rows = torrent_cache.snapshot(profile_id)
emit("torrent_snapshot", {"profile_id": profile_id, "torrents": rows, "summary": cached_summary(profile_id, rows, force=True), "speed_status": _speed_status_from_rows(profile_id, rows), "error": diff.get("error", "")})
emit("poller_settings", {"settings": poller_control.get_settings(profile_id), "runtime": poller_control.snapshot(profile_id)})
emit("download_plan_update", {"settings": download_planner.get_settings(profile_id)})
emit("poller_settings", {"profile_id": profile_id, "settings": poller_control.get_settings(profile_id), "runtime": poller_control.snapshot(profile_id)})
emit("download_plan_update", {"profile_id": profile_id, "settings": download_planner.get_settings(profile_id)})
+4 -4
View File
@@ -42,8 +42,8 @@ def _emit(name: str, payload: dict):
if not _socketio:
return
profile_id = payload.get("profile_id")
if auth.enabled() and profile_id:
# Note: Job/socket events are sent only to clients joined to the affected profile room.
if profile_id:
# Note: Job/socket events are profile-room scoped so modals and toasts do not leak between rTorrent profiles.
_socketio.emit(name, payload, to=f"profile:{int(profile_id)}")
else:
_socketio.emit(name, payload)
@@ -359,9 +359,9 @@ def _emit_torrent_refresh(profile: dict, action_name: str) -> None:
profile_id = int(profile["id"])
if diff.get("ok"):
rows = torrent_cache.snapshot(profile_id)
_emit("torrent_patch", {**diff, "summary": cached_summary(profile_id, rows, force=True)})
_emit("torrent_patch", {**diff, "profile_id": profile_id, "summary": cached_summary(profile_id, rows, force=True)})
else:
_emit("rtorrent_error", diff)
_emit("rtorrent_error", {**diff, "profile_id": profile_id})
except Exception as exc:
# Note: A failed live refresh must not change the already completed job result.
_emit("rtorrent_error", {"profile_id": int(profile.get("id") or 0), "error": str(exc)})
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
export const preferencesToolsSource = " async function loadPreferences(){\n try{\n const j=await (await fetch(`/api/preferences?_=${Date.now()}`, {cache:'no-store'})).json();\n const prefs=j.preferences||{};\n portCheckEnabled=!!Number(prefs.port_check_enabled ?? portCheckEnabled);\n reverseDnsEnabled=!!Number(prefs.reverse_dns_enabled ?? (reverseDnsEnabled?1:0));\n if($('reverseDnsEnabled')) $('reverseDnsEnabled').checked=reverseDnsEnabled;\n automationToastsEnabled=Number(prefs.automation_toasts_enabled ?? (automationToastsEnabled?1:0))!==0;\n smartQueueToastsEnabled=Number(prefs.smart_queue_toasts_enabled ?? (smartQueueToastsEnabled?1:0))!==0;\n easterEggEnabled=Number(prefs.easter_egg_enabled ?? (easterEggEnabled?1:0))!==0;\n easterEggLoadingImageUrl=String(prefs.easter_egg_loading_image_url ?? easterEggLoadingImageUrl ?? '').trim();\n easterEggClickImageUrl=String(prefs.easter_egg_click_image_url ?? easterEggClickImageUrl ?? '').trim();\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||'';\n diskMonitorOwnerLabel=String(prefs.disk_monitor_owner_label||'').trim();\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ diskMonitorPaths=[]; }\n bootstrapTheme=prefs.bootstrap_theme||bootstrapTheme;\n fontFamily=prefs.font_family||fontFamily;\n interfaceScale=Number(prefs.interface_scale||interfaceScale||100);\n torrentListFontSize=clampTorrentListFontSize(prefs.torrent_list_font_size||torrentListFontSize||13);\n compactTorrentListEnabled=Number(prefs.compact_torrent_list_enabled ?? (compactTorrentListEnabled?1:0))!==0;\n try{ footerItems={...DEFAULT_FOOTER_ITEMS,...JSON.parse(prefs.footer_items_json||'{}')}; }catch(_){ footerItems={...DEFAULT_FOOTER_ITEMS}; }\n }catch(e){ console.warn('Preference load failed', e); }\n if($('portCheckEnabled')) $('portCheckEnabled').checked=portCheckEnabled; if($('automationToastsEnabled')) $('automationToastsEnabled').checked=automationToastsEnabled; if($('smartQueueToastsEnabled')) $('smartQueueToastsEnabled').checked=smartQueueToastsEnabled; if($('easterEggEnabled')) $('easterEggEnabled').checked=easterEggEnabled; if($('easterEggLoadingImageUrl')) $('easterEggLoadingImageUrl').value=easterEggLoadingImageUrl; if($('easterEggClickImageUrl')) $('easterEggClickImageUrl').value=easterEggClickImageUrl; if($('diskMonitorMode')) $('diskMonitorMode').value=diskMonitorMode; if($('diskMonitorSelectedPath')) $('diskMonitorSelectedPath').value=diskMonitorSelectedPath; renderDiskMonitorPaths(); applyInitialLoaderEasterEgg(); scheduleRender(true); applyBootstrapTheme(bootstrapTheme); applyFontFamily(fontFamily); applyInterfaceScale(interfaceScale); applyTorrentListFontSize(torrentListFontSize); applyCompactTorrentList(compactTorrentListEnabled); renderFooterPreferences(); applyFooterPreferences(); await loadPortCheck(false); }";
export const preferencesToolsSource = " async function loadPreferences(){\n try{\n const j=await (await fetch(`/api/preferences?_=${Date.now()}`, {cache:'no-store'})).json();\n const prefs=j.preferences||{};\n portCheckEnabled=!!Number(prefs.port_check_enabled ?? portCheckEnabled);\n reverseDnsEnabled=!!Number(prefs.reverse_dns_enabled ?? (reverseDnsEnabled?1:0));\n if($('reverseDnsEnabled')) $('reverseDnsEnabled').checked=reverseDnsEnabled;\n automationToastsEnabled=Number(prefs.automation_toasts_enabled ?? (automationToastsEnabled?1:0))!==0;\n smartQueueToastsEnabled=Number(prefs.smart_queue_toasts_enabled ?? (smartQueueToastsEnabled?1:0))!==0;\n easterEggEnabled=Number(prefs.easter_egg_enabled ?? (easterEggEnabled?1:0))!==0;\n easterEggLoadingImageUrl=String(prefs.easter_egg_loading_image_url ?? easterEggLoadingImageUrl ?? '').trim();\n easterEggClickImageUrl=String(prefs.easter_egg_click_image_url ?? easterEggClickImageUrl ?? '').trim();\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||'';\n diskMonitorOwnerLabel=String(prefs.disk_monitor_owner_label||'').trim();\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ diskMonitorPaths=[]; }\n bootstrapTheme=prefs.bootstrap_theme||bootstrapTheme;\n fontFamily=prefs.font_family||fontFamily;\n interfaceScale=Number(prefs.interface_scale||interfaceScale||100);\n torrentListFontSize=clampTorrentListFontSize(prefs.torrent_list_font_size||torrentListFontSize||13);\n compactTorrentListEnabled=Number(prefs.compact_torrent_list_enabled ?? (compactTorrentListEnabled?1:0))!==0;\n const nextFilter = String(prefs.active_filter || 'all');\n // Note: Profile switches refresh the in-memory filter/sort immediately, matching the state that a full page reload would load.\n activeTrackerFilter = nextFilter.startsWith('tracker:') ? nextFilter.slice(8) : '';\n activeFilter = nextFilter.startsWith('tracker:') ? 'all' : nextFilter;\n mobileActiveFilterKey = nextFilter || 'all';\n try{\n const nextSort = typeof prefs.torrent_sort_json === 'string' ? JSON.parse(prefs.torrent_sort_json || '{}') : (prefs.torrent_sort_json || {});\n if(SORT_KEYS.has(nextSort.key)) sortState = {key: nextSort.key, dir: Number(nextSort.dir) < 0 ? -1 : 1};\n }catch(_){}\n try{ footerItems={...DEFAULT_FOOTER_ITEMS,...JSON.parse(prefs.footer_items_json||'{}')}; }catch(_){ footerItems={...DEFAULT_FOOTER_ITEMS}; }\n }catch(e){ console.warn('Preference load failed', e); }\n if($('portCheckEnabled')) $('portCheckEnabled').checked=portCheckEnabled; if($('automationToastsEnabled')) $('automationToastsEnabled').checked=automationToastsEnabled; if($('smartQueueToastsEnabled')) $('smartQueueToastsEnabled').checked=smartQueueToastsEnabled; if($('easterEggEnabled')) $('easterEggEnabled').checked=easterEggEnabled; if($('easterEggLoadingImageUrl')) $('easterEggLoadingImageUrl').value=easterEggLoadingImageUrl; if($('easterEggClickImageUrl')) $('easterEggClickImageUrl').value=easterEggClickImageUrl; if($('diskMonitorMode')) $('diskMonitorMode').value=diskMonitorMode; if($('diskMonitorSelectedPath')) $('diskMonitorSelectedPath').value=diskMonitorSelectedPath; renderDiskMonitorPaths(); applyInitialLoaderEasterEgg(); scheduleRender(true); applyBootstrapTheme(bootstrapTheme); applyFontFamily(fontFamily); applyInterfaceScale(interfaceScale); applyTorrentListFontSize(torrentListFontSize); applyCompactTorrentList(compactTorrentListEnabled); renderFooterPreferences(); applyFooterPreferences(); await loadPortCheck(false); }";
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
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