Fix multiproile settings #29
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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),))
|
||||
@@ -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)
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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)})
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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
Reference in New Issue
Block a user