profile id support in api requests

This commit is contained in:
Mateusz Gruszczyński
2026-06-16 19:46:16 +02:00
parent c796a740d1
commit a73aeb5544
18 changed files with 1823 additions and 185 deletions
File diff suppressed because it is too large Load Diff
+73 -1
View File
@@ -23,7 +23,7 @@ from flask import Blueprint, jsonify, request, abort, send_file, redirect, Respo
from ..config import DB_PATH, JOBS_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, LOG_RETENTION_DAYS, WORKERS, PYTORRENT_TMP_DIR
from ..db import connect, utcnow
from ..services.auth import current_user_id as default_user_id, current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, require_profile_write, require_admin, is_admin
from ..services import preferences, rtorrent, torrent_stats, speed_peaks, tracker_cache, rss as rss_service, ratio_rules, backup as backup_service, download_planner, operation_logs, poller_control, database_maintenance
from ..services import auth, preferences, rtorrent, torrent_stats, speed_peaks, tracker_cache, rss as rss_service, ratio_rules, backup as backup_service, download_planner, operation_logs, poller_control, database_maintenance
from ..services.torrent_cache import torrent_cache
from ..services.torrent_summary import cached_summary
from ..services.workers import enqueue, list_jobs, cancel_job, retry_job, force_job, clear_jobs, emergency_clear_jobs
@@ -39,6 +39,78 @@ from .auth_api import register_auth_routes
register_auth_routes(bp)
def _request_profile_selector() -> tuple[int | None, str]:
"""Return the optional profile selector supplied by external API clients."""
payload = {}
if request.method in {"POST", "PUT", "PATCH", "DELETE"}:
try:
payload = request.get_json(silent=True) or {}
except Exception:
payload = {}
profile_id = request.args.get("profile_id") or request.form.get("profile_id") or payload.get("profile_id") or request.headers.get("X-PyTorrent-Profile-Id")
profile_name = request.args.get("profile_name") or request.form.get("profile_name") or payload.get("profile_name") or request.headers.get("X-PyTorrent-Profile-Name") or ""
try:
return (int(profile_id), "") if profile_id not in (None, "") else (None, str(profile_name or "").strip())
except (TypeError, ValueError):
raise ValueError("profile_id must be an integer")
def _profile_by_name(profile_name: str, user_id: int | None = None):
name = str(profile_name or "").strip()
if not name:
return None
user_id = user_id or default_user_id()
visible = auth.visible_profile_ids(user_id)
with connect() as conn:
if visible is None:
return conn.execute(
"SELECT * FROM rtorrent_profiles WHERE lower(name)=lower(?) ORDER BY is_default DESC, id LIMIT 1",
(name,),
).fetchone()
if not visible:
return None
placeholders = ",".join("?" for _ in visible)
return conn.execute(
f"SELECT * FROM rtorrent_profiles WHERE id IN ({placeholders}) AND lower(name)=lower(?) ORDER BY is_default DESC, id LIMIT 1",
(*tuple(visible), name),
).fetchone()
def request_profile(require_write: bool = False):
"""Resolve API profile context from profile_id/profile_name, then active profile for compatibility."""
try:
profile_id, profile_name = _request_profile_selector()
except ValueError:
raise
user_id = default_user_id()
profile = None
if profile_id:
profile = preferences.get_profile(int(profile_id), user_id)
elif profile_name:
profile = _profile_by_name(profile_name, user_id)
else:
profile = preferences.active_profile(user_id)
if not profile and auth.can_access_profile(1, user_id):
profile = preferences.get_profile(1, user_id)
if not profile and (profile_id or profile_name):
abort(404)
if not profile:
return None
pid = int(profile["id"])
if require_write and not auth.can_write_profile(pid, user_id):
abort(403)
if not require_write and not auth.can_access_profile(pid, user_id):
abort(403)
return profile
def request_profile_id(require_write: bool = False) -> int | None:
profile = request_profile(require_write=require_write)
return int(profile["id"]) if profile else None
def _job_profile_id(job_id: str) -> int | None:
with connect() as conn:
row = conn.execute("SELECT profile_id FROM jobs WHERE id=?", (job_id,)).fetchone()
+8 -8
View File
@@ -10,7 +10,7 @@ def _automation_user_id() -> int:
@bp.get('/automations')
def automations_get():
from ..services import automation_rules
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return ok({'rules': [], 'history': [], 'error': 'No profile'})
try:
@@ -26,7 +26,7 @@ def automations_get():
@bp.get('/automations/export')
def automations_export():
from ..services import automation_rules
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({'ok': False, 'error': 'No profile'}), 400
try:
@@ -39,7 +39,7 @@ def automations_export():
@bp.post('/automations/import')
def automations_import():
from ..services import automation_rules
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({'ok': False, 'error': 'No profile'}), 400
try:
@@ -55,7 +55,7 @@ def automations_import():
@bp.post('/automations')
def automations_save():
from ..services import automation_rules
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({'ok': False, 'error': 'No profile'}), 400
try:
@@ -69,7 +69,7 @@ def automations_save():
@bp.delete('/automations/<int:rule_id>')
def automations_delete(rule_id: int):
from ..services import automation_rules
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({'ok': False, 'error': 'No profile'}), 400
try:
@@ -83,7 +83,7 @@ def automations_delete(rule_id: int):
@bp.post('/automations/<int:rule_id>/run')
def automations_run_rule(rule_id: int):
from ..services import automation_rules
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({'ok': False, 'error': 'No profile'}), 400
try:
@@ -100,7 +100,7 @@ def automations_run_rule(rule_id: int):
@bp.post('/automations/check')
def automations_check():
from ..services import automation_rules
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({'ok': False, 'error': 'No profile'}), 400
try:
@@ -117,7 +117,7 @@ def automations_check():
@bp.delete('/automations/history')
def automations_history_clear():
from ..services import automation_rules
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({'ok': False, 'error': 'No profile'}), 400
try:
+5 -5
View File
@@ -4,8 +4,8 @@ from ._shared import *
from ..services import auth
def _active_profile_id() -> int | None:
profile = preferences.active_profile()
def _active_profile_id(require_write: bool = False) -> int | None:
profile = request_profile(require_write=require_write)
return int(profile["id"]) if profile else None
@@ -27,7 +27,7 @@ def backup_list():
@bp.post("/backup/profile")
def backup_create_profile():
data = request.get_json(silent=True) or {}
pid = _active_profile_id()
pid = _active_profile_id(require_write=True)
if not pid:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
@@ -84,7 +84,7 @@ def profile_backup_settings_get():
@bp.post("/backup/profile/settings")
def profile_backup_settings_save():
data = request.get_json(silent=True) or {}
pid = _active_profile_id()
pid = _active_profile_id(require_write=True)
if not pid:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
@@ -104,7 +104,7 @@ def backup_preview(backup_id: int):
@bp.post("/backup/<int:backup_id>/restore")
def backup_restore(backup_id: int):
try:
pid = _active_profile_id()
pid = _active_profile_id(require_write=True)
return ok({"result": backup_service.restore_backup(backup_id, default_user_id(), profile_id=pid)})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400
+1 -1
View File
@@ -5,7 +5,7 @@ from ..services import operation_logs
def _active_profile_or_400():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return None
return profile
+1 -1
View File
@@ -16,7 +16,7 @@ def ok(payload=None):
def _profile_or_error():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return None, (jsonify({"ok": False, "error": "No profile"}), 400)
return profile, None
+18 -10
View File
@@ -6,7 +6,15 @@ from ..services import auth
@bp.get("/profiles")
def profiles_list():
return ok({"profiles": preferences.list_profiles(), "active": preferences.active_profile()})
profiles = []
for row in preferences.list_profiles():
item = dict(row)
settings = backup_service.get_auto_backup_settings(default_user_id(), "profile", int(item.get("id") or 0))
item["profile_backup_enabled"] = bool(settings.get("enabled"))
item["profile_backup_interval_hours"] = settings.get("interval_hours")
item["profile_backup_retention_days"] = settings.get("retention_days")
profiles.append(item)
return ok({"profiles": profiles, "active": preferences.active_profile()})
@@ -89,25 +97,25 @@ def profiles_import():
@bp.get("/preferences")
def prefs_get():
return ok({"preferences": preferences.get_preferences()})
return ok({"preferences": preferences.get_preferences(profile_id=request_profile_id())})
@bp.post("/preferences")
def prefs_save():
return ok({"preferences": preferences.save_preferences(request.json or {})})
return ok({"preferences": preferences.save_preferences(request.json or {}, profile_id=request_profile_id(require_write=True))})
@bp.post("/preferences/table-columns/recommended")
def prefs_table_columns_recommended():
# Note: Applies the backend-owned recommended desktop and mobile column layout.
return ok({"preferences": preferences.apply_recommended_table_columns()})
return ok({"preferences": preferences.apply_recommended_table_columns(profile_id=request_profile_id(require_write=True))})
@bp.get("/labels")
def labels_list():
profile = preferences.active_profile()
profile = request_profile()
pid = profile["id"] if profile else None
if not pid:
return ok({"labels": []})
@@ -128,7 +136,7 @@ def labels_list():
@bp.post("/labels")
def labels_save():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {}
@@ -150,7 +158,7 @@ def labels_save():
@bp.delete("/labels/<int:label_id>")
def labels_delete(label_id: int):
profile = preferences.active_profile()
profile = request_profile()
pid = profile["id"] if profile else None
if not pid or not auth.can_write_profile(int(pid), default_user_id()):
return jsonify({"ok": False, "error": "No write access to profile"}), 403
@@ -162,7 +170,7 @@ def labels_delete(label_id: int):
@bp.get("/ratio-groups")
def ratio_groups_list():
profile = preferences.active_profile()
profile = request_profile()
pid = profile["id"] if profile else None
with connect() as conn:
rows = conn.execute(
@@ -182,7 +190,7 @@ def ratio_groups_list():
@bp.post("/ratio-groups")
def ratio_groups_save():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {}
@@ -212,7 +220,7 @@ def ratio_groups_save():
@bp.post("/ratio-groups/check")
def ratio_groups_check():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
return ok({"result": ratio_rules.check(profile, default_user_id())})
+2 -2
View File
@@ -4,7 +4,7 @@ from ._shared import *
def _active_profile_or_400():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return None
return profile
@@ -117,7 +117,7 @@ def rss_rule_test():
@bp.post("/rss/check")
def rss_check():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
return ok(rss_service.check(profile, only_due=False))
+5 -5
View File
@@ -5,7 +5,7 @@ from ._shared import *
@bp.get('/smart-queue')
def smart_queue_get():
from ..services import smart_queue
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return ok({'settings': {}, 'exclusions': [], 'error': 'No profile'})
try:
@@ -23,7 +23,7 @@ def smart_queue_get():
@bp.post('/smart-queue')
def smart_queue_save():
from ..services import smart_queue
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return ok({'settings': {}, 'error': 'No profile'})
try:
@@ -37,7 +37,7 @@ def smart_queue_save():
@bp.post('/smart-queue/check')
def smart_queue_check():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return ok({'result': {'ok': False, 'error': 'No profile'}})
if str(request.args.get('sync') or '').lower() in {'1', 'true', 'yes'}:
@@ -66,7 +66,7 @@ def smart_queue_check():
@bp.post('/smart-queue/exclusion')
def smart_queue_exclusion():
from ..services import smart_queue
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({'ok': False, 'error': 'No profile'}), 400
data = request.get_json(silent=True) or {}
@@ -79,7 +79,7 @@ def smart_queue_exclusion():
@bp.delete('/smart-queue/history')
def smart_queue_history_clear():
from ..services import smart_queue
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({'ok': False, 'error': 'No profile'}), 400
try:
+19 -19
View File
@@ -7,7 +7,7 @@ from ..services.frontend_assets import static_hash
@bp.get("/system/disk")
def system_disk():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"})
try:
@@ -19,7 +19,7 @@ def system_disk():
@bp.get("/system/status")
def system_status():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"})
try:
@@ -80,7 +80,7 @@ def health_check_nagios():
@bp.get("/app/status")
def app_status():
started = time.perf_counter()
profile = preferences.active_profile()
profile = request_profile()
proc = psutil.Process(os.getpid())
try:
jobs = list_jobs(10, 0)
@@ -178,7 +178,7 @@ def cleanup_status():
@bp.post("/cleanup/cache")
def cleanup_profile_cache():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
profile_id = int(profile["id"])
@@ -225,7 +225,7 @@ def cleanup_database_vacuum():
@bp.post("/cleanup/smart-queue")
def cleanup_smart_queue():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
profile_id = int(profile["id"])
@@ -243,7 +243,7 @@ def cleanup_smart_queue():
@bp.post("/cleanup/operation-logs")
def cleanup_operation_logs():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
# Note: Operation log cleanup removes only profile-scoped log entries; torrents, jobs and settings stay intact.
@@ -254,7 +254,7 @@ def cleanup_operation_logs():
@bp.post("/cleanup/planner")
def cleanup_planner():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
# Note: Planner cleanup removes only the active profile action history, not saved Planner settings.
@@ -264,7 +264,7 @@ def cleanup_planner():
@bp.post("/cleanup/automations")
def cleanup_automations():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
profile_id = int(profile["id"])
@@ -284,7 +284,7 @@ def cleanup_automations():
@bp.post("/cleanup/poller-diagnostics")
def cleanup_poller_diagnostics():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
profile_id = int(profile["id"])
@@ -295,7 +295,7 @@ def cleanup_poller_diagnostics():
@bp.post("/cleanup/all")
def cleanup_all():
deleted_jobs = clear_jobs()
active_profile = preferences.active_profile()
active_profile = request_profile()
active_profile_id = int(active_profile["id"]) if active_profile else 0
deleted_logs = operation_logs.clear(active_profile_id) if active_profile_id else 0
deleted_planner = download_planner.clear_history(active_profile_id) if active_profile_id else 0
@@ -371,7 +371,7 @@ def _annotate_path_directories(profile: dict, payload: dict) -> dict:
@bp.get("/path/default")
def path_default():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
@@ -383,7 +383,7 @@ def path_default():
@bp.get("/path/browse")
def path_browse():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
base = request.args.get("path") or ""
@@ -395,7 +395,7 @@ def path_browse():
@bp.post("/path/directories")
def path_directory_create():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
require_profile_write(profile.get("id"))
@@ -410,7 +410,7 @@ def path_directory_create():
@bp.post("/path/directories/rename")
def path_directory_rename():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
require_profile_write(profile.get("id"))
@@ -429,7 +429,7 @@ def path_directory_rename():
@bp.get('/rtorrent-config')
def rtorrent_config_get():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({'ok': False, 'error': 'No profile'}), 400
try:
@@ -440,7 +440,7 @@ def rtorrent_config_get():
@bp.post('/rtorrent-config')
def rtorrent_config_save():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({'ok': False, 'error': 'No profile'}), 400
try:
@@ -457,7 +457,7 @@ def rtorrent_config_save():
@bp.post('/rtorrent-config/reset')
def rtorrent_config_reset():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({'ok': False, 'error': 'No profile'}), 400
try:
@@ -468,7 +468,7 @@ def rtorrent_config_reset():
@bp.post('/rtorrent-config/generate')
def rtorrent_config_generate():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({'ok': False, 'error': 'No profile'}), 400
try:
@@ -481,7 +481,7 @@ def rtorrent_config_generate():
@bp.get('/traffic/history')
def traffic_history_get():
from ..services import traffic_history
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return ok({'history': {'range': request.args.get('range') or '7d', 'bucket': 'day', 'rows': []}})
range_name = request.args.get('range') or '7d'
+26 -26
View File
@@ -7,7 +7,7 @@ from ..services.reverse_dns import attach_reverse_dns
@bp.get("/torrents")
def torrents():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return ok({"torrents": [], "summary": cached_summary(0, []), "error": "No rTorrent profile"})
rows = torrent_cache.snapshot(profile["id"])
@@ -23,7 +23,7 @@ def torrents():
@bp.get("/trackers/summary")
def trackers_summary():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return ok({"summary": {"hashes": {}, "trackers": [], "errors": [], "scanned": 0, "pending": 0}, "error": "No profile"})
try:
@@ -78,7 +78,7 @@ def tracker_favicon_query():
@bp.get("/torrent-stats")
def torrent_stats_get():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return ok({"stats": {}, "error": "No profile"})
force = str(request.args.get("force") or "").lower() in {"1", "true", "yes"}
@@ -92,7 +92,7 @@ def torrent_stats_get():
@bp.get("/torrents/<torrent_hash>/files")
def torrent_files(torrent_hash: str):
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
return ok({"files": rtorrent.torrent_files(profile, torrent_hash)})
@@ -101,7 +101,7 @@ def torrent_files(torrent_hash: str):
@bp.get("/torrents/<torrent_hash>/files/<int:file_index>/mediainfo")
def torrent_file_media_info(torrent_hash: str, file_index: int):
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
@@ -124,7 +124,7 @@ def torrent_file_media_info(torrent_hash: str, file_index: int):
@bp.post("/torrents/<torrent_hash>/files/priority")
def torrent_file_priority(torrent_hash: str):
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {}
@@ -139,7 +139,7 @@ def torrent_file_priority(torrent_hash: str):
@bp.get("/torrents/<torrent_hash>/files/tree")
def torrent_file_tree(torrent_hash: str):
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
return ok({"tree": rtorrent.torrent_file_tree(profile, torrent_hash)})
@@ -148,7 +148,7 @@ def torrent_file_tree(torrent_hash: str):
@bp.post("/torrents/<torrent_hash>/files/folder-priority")
def torrent_folder_priority(torrent_hash: str):
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {}
@@ -214,7 +214,7 @@ def _send_staged_file(profile: dict, path: str, download_name: str, local: bool
@bp.post("/torrents/<torrent_hash>/files/<int:file_index>/download-link")
def torrent_file_download_link(torrent_hash: str, file_index: int):
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
@@ -238,7 +238,7 @@ def torrent_file_download_link_from_body(torrent_hash: str):
@bp.post("/torrents/<torrent_hash>/files/download.zip/link")
def torrent_files_download_zip_link(torrent_hash: str):
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {}
@@ -254,7 +254,7 @@ def torrent_files_download_zip_link(torrent_hash: str):
@bp.get("/torrents/<torrent_hash>/torrent-file/link")
def torrent_file_export_link(torrent_hash: str):
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
@@ -267,7 +267,7 @@ def torrent_file_export_link(torrent_hash: str):
@bp.post("/torrents/torrent-files.zip/link")
def torrent_files_export_zip_link():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {}
@@ -284,7 +284,7 @@ def torrent_files_export_zip_link():
@bp.get("/torrents/<torrent_hash>/files/<int:file_index>/download")
def torrent_file_download(torrent_hash: str, file_index: int):
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
@@ -377,7 +377,7 @@ def _stream_torrent_files_zip(profile: dict, items: list[dict]):
@bp.post("/torrents/<torrent_hash>/files/download.zip")
def torrent_files_download_zip(torrent_hash: str):
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {}
@@ -393,7 +393,7 @@ def torrent_files_download_zip(torrent_hash: str):
@bp.get("/torrents/<torrent_hash>/torrent-file")
def torrent_file_export(torrent_hash: str):
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
@@ -406,7 +406,7 @@ def torrent_file_export(torrent_hash: str):
@bp.post("/torrents/torrent-files.zip")
def torrent_files_export_zip():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {}
@@ -455,7 +455,7 @@ def torrent_files_export_zip():
@bp.get("/torrents/<torrent_hash>/chunks")
def torrent_chunks(torrent_hash: str):
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
@@ -467,7 +467,7 @@ def torrent_chunks(torrent_hash: str):
@bp.post("/torrents/<torrent_hash>/chunks/<action_name>")
def torrent_chunk_action(torrent_hash: str, action_name: str):
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
@@ -480,7 +480,7 @@ def torrent_chunk_action(torrent_hash: str, action_name: str):
@bp.get("/torrents/<torrent_hash>/peers")
def torrent_peers(torrent_hash: str):
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
peers = rtorrent.torrent_peers(profile, torrent_hash)
@@ -496,7 +496,7 @@ def torrent_peers(torrent_hash: str):
@bp.get("/torrents/<torrent_hash>/trackers")
def torrent_trackers(torrent_hash: str):
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
return ok({"trackers": rtorrent.torrent_trackers(profile, torrent_hash)})
@@ -505,7 +505,7 @@ def torrent_trackers(torrent_hash: str):
@bp.post("/torrents/<torrent_hash>/trackers/<action_name>")
def torrent_tracker_action(torrent_hash: str, action_name: str):
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
@@ -518,7 +518,7 @@ def torrent_tracker_action(torrent_hash: str, action_name: str):
@bp.post("/torrents/<action_name>")
def torrent_action(action_name: str):
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {}
@@ -547,7 +547,7 @@ def torrent_action(action_name: str):
@bp.post("/torrents/create")
def torrent_create():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
form = request.form if request.content_type and request.content_type.startswith("multipart/form-data") else (request.get_json(silent=True) or {})
@@ -577,7 +577,7 @@ def torrent_create():
@bp.post("/torrents/add")
def torrent_add():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
job_ids = []
@@ -634,7 +634,7 @@ def torrent_add():
@bp.post("/torrents/preview")
def torrent_preview():
profile = preferences.active_profile()
profile = request_profile()
existing_hashes = set()
if profile:
try:
@@ -664,7 +664,7 @@ def torrent_preview():
@bp.post("/speed/limits")
def speed_limits():
profile = preferences.active_profile()
profile = request_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {}
+37 -4
View File
@@ -728,12 +728,45 @@ def install_guards(app) -> None:
def _request_profile_id() -> int | None:
if request.view_args and request.view_args.get("profile_id"):
return int(request.view_args["profile_id"])
payload = {}
try:
payload = request.get_json(silent=True) or {}
if payload.get("profile_id"):
return int(payload.get("profile_id"))
except Exception:
pass
payload = {}
raw_id = (
request.args.get("profile_id")
or request.form.get("profile_id")
or payload.get("profile_id")
or request.headers.get("X-PyTorrent-Profile-Id")
)
if raw_id not in (None, ""):
try:
return int(raw_id)
except (TypeError, ValueError):
return None
raw_name = (
request.args.get("profile_name")
or request.form.get("profile_name")
or payload.get("profile_name")
or request.headers.get("X-PyTorrent-Profile-Name")
)
if raw_name:
from . import preferences
visible = visible_profile_ids(current_user_id())
with connect() as conn:
if visible is None:
row = conn.execute("SELECT id FROM rtorrent_profiles WHERE lower(name)=lower(?) ORDER BY is_default DESC, id LIMIT 1", (str(raw_name).strip(),)).fetchone()
elif visible:
placeholders = ",".join("?" for _ in visible)
row = conn.execute(
f"SELECT id FROM rtorrent_profiles WHERE id IN ({placeholders}) AND lower(name)=lower(?) ORDER BY is_default DESC, id LIMIT 1",
(*tuple(visible), str(raw_name).strip()),
).fetchone()
else:
row = None
return int(row["id"]) if row else None
from . import preferences
profile = preferences.active_profile()
return int(profile["id"]) if profile else None
if profile:
return int(profile["id"])
return 1 if can_access_profile(1) else None
+2 -2
View File
@@ -175,8 +175,8 @@ def create_app_backup(name: str, user_id: int | None = None, automatic: bool = F
def create_profile_backup(name: str, profile_id: int, user_id: int | None = None, automatic: bool = False) -> dict:
user_id = user_id or auth.current_user_id() or default_user_id()
if not auth.can_access_profile(profile_id, user_id):
raise PermissionError("No access to profile")
if not auth.can_write_profile(profile_id, user_id):
raise PermissionError("No write access to profile")
payload = {"version": 2, "backup_type": "profile", "source_profile_id": int(profile_id), "created_at": utcnow(), "automatic": bool(automatic), "tables": {}}
with connect() as conn:
for table in PROFILE_BACKUP_TABLES:
+2 -2
View File
@@ -491,9 +491,9 @@ def get_preferences(user_id: int | None = None, profile_id: int | None = None):
merged.update(get_disk_monitor_preferences(profile_id, user_id))
return merged
def save_preferences(data: dict, user_id: int | None = None):
def save_preferences(data: dict, user_id: int | None = None, profile_id: int | None = None):
user_id = user_id or auth.current_user_id() or default_user_id()
profile_id = _active_profile_id_for_user(user_id)
profile_id = profile_id or _active_profile_id_for_user(user_id)
allowed_theme = data.get("theme") if data.get("theme") in {"light", "dark"} else None
bootstrap_theme = data.get("bootstrap_theme") if data.get("bootstrap_theme") in BOOTSTRAP_THEMES else None
font_family = data.get("font_family") if data.get("font_family") in FONT_FAMILIES else None
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +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";
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; const backupBadge=p.profile_backup_enabled?` <span class=\"badge text-bg-info ms-1\">profile backup on</span>`:''; return `<div class=\"profile-row ${isActive?'active':''}\" data-profile-id=\"${esc(p.id)}\" aria-current=\"${isActive?'true':'false'}\"><b><span class=\"profile-id-badge\">#${esc(p.id)}</span> ${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>\":''}${backupBadge} <span class=\"badge text-bg-${cls}\">${esc(st)}</span></b><span>ID ${esc(p.id)} · ${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";
+12
View File
@@ -5884,3 +5884,15 @@ body.compact-torrent-list .mobile-progress .torrent-progress {
font-size: 0.82rem;
gap: 0.45rem 0.85rem;
}
.profile-id-badge {
display: inline-flex;
align-items: center;
padding: 0.1rem 0.35rem;
border: 1px solid var(--bs-border-color);
border-radius: 999px;
color: var(--bs-secondary-color);
font-family: var(--bs-font-monospace);
font-size: 0.72rem;
font-weight: 600;
}
+1 -1
View File
@@ -168,7 +168,7 @@
<div class="modal-body">
<label class="form-label" for="profileSelect">Configured rTorrents</label>
<select id="profileSelect" class="form-select profile-select">
{% for p in profiles %}<option value="{{ p.id }}" {% if active_profile and active_profile.id == p.id %}selected{% endif %}>{{ p.name }}</option>{% endfor %}
{% for p in profiles %}<option value="{{ p.id }}" {% if active_profile and active_profile.id == p.id %}selected{% endif %}>#{{ p.id }} — {{ p.name }}</option>{% endfor %}
</select>
<div class="form-text">Changing rTorrent reloads the live torrent snapshot.</div>
</div>