move to anther profile
This commit is contained in:
@@ -113,6 +113,25 @@ CREATE TABLE IF NOT EXISTS rtorrent_profiles (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rtorrent_profiles_user_default_name ON rtorrent_profiles(user_id, is_default, name COLLATE NOCASE);
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profile_runtime_stats (
|
||||
profile_id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
torrent_count INTEGER DEFAULT 0,
|
||||
total_size_bytes INTEGER DEFAULT 0,
|
||||
completed_bytes INTEGER DEFAULT 0,
|
||||
downloaded_bytes INTEGER DEFAULT 0,
|
||||
uploaded_bytes INTEGER DEFAULT 0,
|
||||
active_count INTEGER DEFAULT 0,
|
||||
seeding_count INTEGER DEFAULT 0,
|
||||
downloading_count INTEGER DEFAULT 0,
|
||||
stopped_count INTEGER DEFAULT 0,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id),
|
||||
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_profile_runtime_stats_user ON profile_runtime_stats(user_id, profile_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
|
||||
@@ -146,11 +146,36 @@ def migrate_profile_speed_limits_table(conn: sqlite3.Connection) -> bool:
|
||||
return existing is None
|
||||
|
||||
|
||||
def migrate_profile_runtime_stats_table(conn: sqlite3.Connection) -> bool:
|
||||
existing = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='profile_runtime_stats'").fetchone()
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS profile_runtime_stats (
|
||||
profile_id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
torrent_count INTEGER DEFAULT 0,
|
||||
total_size_bytes INTEGER DEFAULT 0,
|
||||
completed_bytes INTEGER DEFAULT 0,
|
||||
downloaded_bytes INTEGER DEFAULT 0,
|
||||
uploaded_bytes INTEGER DEFAULT 0,
|
||||
active_count INTEGER DEFAULT 0,
|
||||
seeding_count INTEGER DEFAULT 0,
|
||||
downloading_count INTEGER DEFAULT 0,
|
||||
stopped_count INTEGER DEFAULT 0,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id),
|
||||
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_profile_runtime_stats_user ON profile_runtime_stats(user_id, profile_id)")
|
||||
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,
|
||||
migrate_profile_runtime_stats_table,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
from ._shared import *
|
||||
from ..services.rtorrent.diagnostics import profile_diagnostics
|
||||
from ..services import auth
|
||||
from ..utils import human_size
|
||||
|
||||
@bp.get("/profiles")
|
||||
def profiles_list():
|
||||
@@ -10,6 +11,13 @@ def profiles_list():
|
||||
item = dict(row)
|
||||
# Note: Frontend actions can hide write-only operations without trusting this flag; backend still enforces permissions.
|
||||
item["can_write"] = auth.can_write_profile(int(item.get("id") or 0), auth.current_user_id() or default_user_id())
|
||||
stats = preferences.get_profile_runtime_stats(int(item.get("id") or 0))
|
||||
if stats:
|
||||
stats["total_size_h"] = human_size(stats.get("total_size_bytes"))
|
||||
stats["completed_h"] = human_size(stats.get("completed_bytes"))
|
||||
stats["downloaded_h"] = human_size(stats.get("downloaded_bytes"))
|
||||
stats["uploaded_h"] = human_size(stats.get("uploaded_bytes"))
|
||||
item["runtime_stats"] = stats
|
||||
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")
|
||||
@@ -46,7 +54,17 @@ def profiles_delete(profile_id: int):
|
||||
@bp.post("/profiles/<int:profile_id>/activate")
|
||||
def profiles_activate(profile_id: int):
|
||||
try:
|
||||
return ok({"profile": preferences.activate_profile(profile_id)})
|
||||
profile = preferences.activate_profile(profile_id)
|
||||
stats_error = ""
|
||||
try:
|
||||
# Note: Profile overview metrics are cached only on user-initiated profile switch, not on every profile list render.
|
||||
preferences.save_profile_runtime_stats(profile, rtorrent.list_torrents(profile), user_id=auth.current_user_id() or default_user_id())
|
||||
except Exception as exc:
|
||||
stats_error = str(exc)
|
||||
response = {"profile": profile}
|
||||
if stats_error:
|
||||
response["stats_error"] = stats_error
|
||||
return ok(response)
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 404
|
||||
|
||||
|
||||
@@ -578,7 +578,9 @@ def _profile_transfer_payload(source_profile: dict, data: dict, *, require_hashe
|
||||
target_path = _clean_remote_transfer_path(requested_target_path or default_target_path)
|
||||
inside_allowed_root = bool(roots and any(_path_inside_root(target_path, root) for root in roots))
|
||||
if not inside_allowed_root:
|
||||
# Note: A metadata-only profile transfer does not require source-user write access, but it still uses a safe target default.
|
||||
# Note: A chosen target path must stay inside the target profile roots even for metadata-only transfers.
|
||||
if requested_target_path:
|
||||
raise ValueError("Target path is outside the target profile download roots")
|
||||
target_path = default_target_path
|
||||
inside_allowed_root = bool(roots and any(_path_inside_root(target_path, root) for root in roots))
|
||||
|
||||
|
||||
@@ -408,7 +408,8 @@ def _automation_profile_transfer_payload(profile: dict[str, Any], eff: dict[str,
|
||||
if not target_profile:
|
||||
raise ValueError('Automation target profile does not exist')
|
||||
default_path = _safe_remote_path(rtorrent.default_download_path(target_profile))
|
||||
target_path = _safe_remote_path(str(eff.get('target_path') or eff.get('path') or default_path))
|
||||
requested_target_path = _safe_remote_path(str(eff.get('target_path') or eff.get('path') or ''))
|
||||
target_path = requested_target_path or default_path
|
||||
roots = [default_path]
|
||||
try:
|
||||
prefs = get_disk_monitor_preferences(target_id, user_id=user_id)
|
||||
@@ -423,6 +424,8 @@ def _automation_profile_transfer_payload(profile: dict[str, Any], eff: dict[str,
|
||||
pass
|
||||
target_roots = [r for r in roots if r]
|
||||
if not any(_path_inside_root(target_path, root) for root in target_roots):
|
||||
if requested_target_path:
|
||||
raise ValueError('Automation target path is outside the target profile download roots')
|
||||
target_path = default_path
|
||||
requested_move_data = bool(eff.get('move_data'))
|
||||
move_data = False
|
||||
|
||||
@@ -577,3 +577,77 @@ def save_preferences(data: dict, user_id: int | None = None, profile_id: int | N
|
||||
if disk_payload is not None:
|
||||
save_disk_monitor_preferences(profile_id, disk_payload, user_id)
|
||||
return get_preferences(user_id, profile_id)
|
||||
|
||||
|
||||
def _row_int(row: dict, key: str) -> int:
|
||||
try:
|
||||
return int(float(row.get(key) or 0))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def profile_runtime_stats_from_rows(profile: dict, rows: list[dict], user_id: int | None = None) -> dict:
|
||||
# Note: Stored profile stats are intentionally approximate and updated only when the user switches to that profile.
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
total_size = completed = downloaded = uploaded = active = seeding = downloading = stopped = 0
|
||||
for row in rows or []:
|
||||
size = _row_int(row, 'size')
|
||||
total_size += size
|
||||
completed += min(size, _row_int(row, 'completed_bytes')) if size else _row_int(row, 'completed_bytes')
|
||||
downloaded += _row_int(row, 'down_total')
|
||||
uploaded += _row_int(row, 'up_total')
|
||||
status = str(row.get('status') or '').strip().lower()
|
||||
state = bool(row.get('state'))
|
||||
complete = bool(row.get('complete'))
|
||||
if state:
|
||||
active += 1
|
||||
if complete and state:
|
||||
seeding += 1
|
||||
if not complete and state and status != 'queued':
|
||||
downloading += 1
|
||||
if not state:
|
||||
stopped += 1
|
||||
return {
|
||||
'profile_id': int(profile.get('id') or 0),
|
||||
'user_id': int(user_id),
|
||||
'torrent_count': len(rows or []),
|
||||
'total_size_bytes': total_size,
|
||||
'completed_bytes': completed,
|
||||
'downloaded_bytes': downloaded,
|
||||
'uploaded_bytes': uploaded,
|
||||
'active_count': active,
|
||||
'seeding_count': seeding,
|
||||
'downloading_count': downloading,
|
||||
'stopped_count': stopped,
|
||||
'updated_at': utcnow(),
|
||||
}
|
||||
|
||||
|
||||
def save_profile_runtime_stats(profile: dict, rows: list[dict], user_id: int | None = None) -> dict:
|
||||
stats = profile_runtime_stats_from_rows(profile, rows, user_id=user_id)
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO profile_runtime_stats(
|
||||
profile_id,user_id,torrent_count,total_size_bytes,completed_bytes,downloaded_bytes,uploaded_bytes,
|
||||
active_count,seeding_count,downloading_count,stopped_count,updated_at
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(profile_id) DO UPDATE SET
|
||||
user_id=excluded.user_id, torrent_count=excluded.torrent_count, total_size_bytes=excluded.total_size_bytes,
|
||||
completed_bytes=excluded.completed_bytes, downloaded_bytes=excluded.downloaded_bytes, uploaded_bytes=excluded.uploaded_bytes,
|
||||
active_count=excluded.active_count, seeding_count=excluded.seeding_count, downloading_count=excluded.downloading_count,
|
||||
stopped_count=excluded.stopped_count, updated_at=excluded.updated_at
|
||||
""",
|
||||
(
|
||||
stats['profile_id'], stats['user_id'], stats['torrent_count'], stats['total_size_bytes'], stats['completed_bytes'],
|
||||
stats['downloaded_bytes'], stats['uploaded_bytes'], stats['active_count'], stats['seeding_count'],
|
||||
stats['downloading_count'], stats['stopped_count'], stats['updated_at'],
|
||||
),
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
def get_profile_runtime_stats(profile_id: int) -> dict | None:
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT * FROM profile_runtime_stats WHERE profile_id=?", (int(profile_id),)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
@@ -289,6 +289,12 @@ def _emit_disk_refresh_requested(profile_id: int, action_name: str, payload: dic
|
||||
_schedule_profile_disk_refresh(int(profile_id), len((payload or {}).get("hashes") or []))
|
||||
|
||||
def _execute(profile: dict, action_name: str, payload: dict, user_id: int | None = None):
|
||||
def checkpoint(next_state: dict, current: int, total: int):
|
||||
# Note: Checkpoint is defined before every action branch so profile-transfer jobs can resume safely.
|
||||
job_id = payload.get("__job_id")
|
||||
if job_id:
|
||||
_checkpoint_job(str(job_id), next_state, current, total)
|
||||
|
||||
if action_name == "smart_queue_check":
|
||||
from . import smart_queue
|
||||
return smart_queue.check(profile, user_id=user_id or default_user_id(), force=True)
|
||||
@@ -315,11 +321,6 @@ def _execute(profile: dict, action_name: str, payload: dict, user_id: int | None
|
||||
disk_guard.assert_can_start_download(profile)
|
||||
state = payload.get("__resume_state") or {}
|
||||
|
||||
def checkpoint(next_state: dict, current: int, total: int):
|
||||
job_id = payload.get("__job_id")
|
||||
if job_id:
|
||||
_checkpoint_job(str(job_id), next_state, current, total)
|
||||
|
||||
return rtorrent.action(profile, hashes, action_name, payload, checkpoint=checkpoint, resume_state=state)
|
||||
|
||||
|
||||
|
||||
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 profileSelectionSource = " function renderProfileSelectionState(count=0){\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},queued:{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 function renderProfilePickerChoices(profiles=[], active=null){\n const list=$('profileChoiceList');\n if(!list) return;\n const activeId=Number(active?.id || window.PYTORRENT?.activeProfile || activeProfileId || 0);\n list.innerHTML=(profiles||[]).map(p=>{\n const id=Number(p.id||0);\n const activeClass=id===activeId?' active':'';\n return `<button class=\"profile-choice-card${activeClass}\" type=\"button\" data-profile-id=\"${esc(id)}\"><span><i class=\"fa-solid fa-server\"></i><b>${esc(p.name||('rTorrent '+id))}</b></span><small>#${esc(id)}${id===activeId?' \u00b7 active':''}</small></button>`;\n }).join('') || '<div class=\"text-muted small\">No profiles configured.</div>';\n }\n\n async function openProfilePicker(){\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n renderProfilePickerChoices(j.profiles||[], j.active||null);\n }catch(e){ renderProfilePickerChoices([], null); }\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\n $('profileChoiceList')?.addEventListener('click',async e=>{\n const btn=e.target.closest('.profile-choice-card');\n if(!btn) return;\n const id=btn.dataset.profileId;\n if(!id) return;\n await activateProfileAndRefresh(id, btn.querySelector(\"b\")?.textContent || \"rTorrent\");\n bootstrap.Modal.getInstance($('profilePickerModal'))?.hide();\n });\n";
|
||||
export const profileSelectionSource = " function renderProfileSelectionState(count=0){\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},queued:{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 function profileRuntimeStatsHtml(stats){\n if(!stats) return '';\n const parts=[];\n if(stats.torrent_count!==undefined) parts.push(`${stats.torrent_count} torrents`);\n if(stats.total_size_h) parts.push(`total ${stats.total_size_h}`);\n if(stats.seeding_count!==undefined || stats.downloading_count!==undefined) parts.push(`${stats.seeding_count||0} seeding / ${stats.downloading_count||0} downloading`);\n if(stats.updated_at) parts.push(`cached ${formatDateTime(stats.updated_at)}`);\n return parts.length?`<div class=\"profile-choice-stats\">${parts.map(x=>`<span>${esc(x)}</span>`).join('')}</div>`:'';\n }\n\n function renderProfilePickerChoices(profiles=[], active=null){\n const list=$('profileChoiceList');\n if(!list) return;\n const activeId=Number(active?.id || window.PYTORRENT?.activeProfile || activeProfileId || 0);\n list.innerHTML=(profiles||[]).map(p=>{\n const id=Number(p.id||0);\n const activeClass=id===activeId?' active':'';\n return `<button class=\"profile-choice-card${activeClass}\" type=\"button\" data-profile-id=\"${esc(id)}\"><div class=\"profile-choice-main\"><span><i class=\"fa-solid fa-server\"></i><b>${esc(p.name||('rTorrent '+id))}</b></span><small>#${esc(id)}${id===activeId?' \u00b7 active':''}</small></div>${profileRuntimeStatsHtml(p.runtime_stats)}</button>`;\n }).join('') || '<div class=\"text-muted small\">No profiles configured.</div>';\n }\n\n async function openProfilePicker(){\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n renderProfilePickerChoices(j.profiles||[], j.active||null);\n }catch(e){ renderProfilePickerChoices([], null); }\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\n $('profileChoiceList')?.addEventListener('click',async e=>{\n const btn=e.target.closest('.profile-choice-card');\n if(!btn) return;\n const id=btn.dataset.profileId;\n if(!id) return;\n await activateProfileAndRefresh(id, btn.querySelector(\"b\")?.textContent || \"rTorrent\");\n bootstrap.Modal.getInstance($('profilePickerModal'))?.hide();\n });\n";
|
||||
|
||||
@@ -6010,6 +6010,49 @@ body.compact-torrent-list .mobile-progress .torrent-progress {
|
||||
min-height: 1.2rem;
|
||||
}
|
||||
|
||||
|
||||
.profile-choice-main {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-choice-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
justify-content: flex-end;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.profile-choice-stats span {
|
||||
background: var(--bs-tertiary-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 999px;
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.75rem;
|
||||
padding: 0.15rem 0.45rem;
|
||||
}
|
||||
|
||||
.profile-transfer-path-hints {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.profile-choice-card {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.profile-choice-stats {
|
||||
justify-content: flex-start;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.profile-transfer-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user