diff --git a/pytorrent/routes/system.py b/pytorrent/routes/system.py
index 387eeaf..55e4d5c 100644
--- a/pytorrent/routes/system.py
+++ b/pytorrent/routes/system.py
@@ -372,9 +372,21 @@ def _annotate_path_directories(profile: dict, payload: dict) -> dict:
return payload
+def _path_profile_from_request(*, require_write_access: bool = False):
+ profile_id = 0
+ try:
+ profile_id = int((request.args.get("profile_id") if request.method == "GET" else (request.get_json(silent=True) or {}).get("profile_id")) or 0)
+ except Exception:
+ profile_id = 0
+ profile = preferences.get_profile(profile_id, auth.current_user_id() or default_user_id()) if profile_id else request_profile()
+ if profile and require_write_access:
+ require_profile_write(profile.get("id"))
+ return profile
+
+
@bp.get("/path/default")
def path_default():
- profile = request_profile()
+ profile = _path_profile_from_request()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
@@ -386,7 +398,7 @@ def path_default():
@bp.get("/path/browse")
def path_browse():
- profile = request_profile()
+ profile = _path_profile_from_request()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
base = request.args.get("path") or ""
@@ -398,10 +410,9 @@ def path_browse():
@bp.post("/path/directories")
def path_directory_create():
- profile = request_profile()
+ profile = _path_profile_from_request(require_write_access=True)
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
- require_profile_write(profile.get("id"))
data = request.get_json(silent=True) or {}
try:
# Note: This endpoint only creates an empty directory and does not alter any torrent state.
@@ -413,10 +424,9 @@ def path_directory_create():
@bp.post("/path/directories/rename")
def path_directory_rename():
- profile = request_profile()
+ profile = _path_profile_from_request(require_write_access=True)
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
- require_profile_write(profile.get("id"))
data = request.get_json(silent=True) or {}
path = str(data.get("path") or "").strip()
if _path_has_cached_torrents(int(profile.get("id") or 0), path):
diff --git a/pytorrent/routes/torrents.py b/pytorrent/routes/torrents.py
index f72447f..64fe12d 100644
--- a/pytorrent/routes/torrents.py
+++ b/pytorrent/routes/torrents.py
@@ -613,7 +613,7 @@ def _profile_transfer_payload(source_profile: dict, data: dict, *, require_hashe
"target_write_check": write_check,
"label_mode": str(data.get("label_mode") or "none").strip(),
"label_value": str(data.get("label_value") or "").strip(),
- "post_action": str(data.get("post_action") or "none").strip(),
+ "post_action": str(data.get("post_action") or "current").strip(),
}
diff --git a/pytorrent/services/automation_rules.py b/pytorrent/services/automation_rules.py
index 08ff3bc..2e904e5 100644
--- a/pytorrent/services/automation_rules.py
+++ b/pytorrent/services/automation_rules.py
@@ -370,7 +370,7 @@ def _enqueue_automation_job(profile: dict[str, Any], rule: dict[str, Any], actio
if action_name == 'move':
extra.update({'target_path': str(part_payload.get('path') or ''), 'move_data': bool(part_payload.get('move_data'))})
if action_name == 'profile_transfer':
- extra.update({'target_profile_id': int(part_payload.get('target_profile_id') or 0), 'target_path': str(part_payload.get('target_path') or ''), 'move_data': bool(part_payload.get('move_data')), 'post_action': str(part_payload.get('post_action') or 'none')})
+ extra.update({'target_profile_id': int(part_payload.get('target_profile_id') or 0), 'target_path': str(part_payload.get('target_path') or ''), 'move_data': bool(part_payload.get('move_data')), 'post_action': str(part_payload.get('post_action') or 'current')})
if action_name == 'remove':
extra.update({'remove_data': bool(part_payload.get('remove_data'))})
effect_type = str(context_extra.get('effect_type') if context_extra else action_name)
@@ -435,9 +435,9 @@ def _automation_profile_transfer_payload(profile: dict[str, Any], eff: dict[str,
move_data = bool(check.get('ok'))
if not move_data:
downgrade_reason = str(check.get('message') or check.get('error') or 'target path is not writable by source rTorrent user')
- post_action = str(eff.get('post_action') or 'none').strip().lower()
- if post_action not in {'none', 'start', 'stop', 'pause', 'check', 'recheck'}:
- post_action = 'none'
+ post_action = str(eff.get('post_action') or 'current').strip().lower()
+ if post_action not in {'none', 'current', 'start', 'stop', 'pause', 'check', 'recheck'}:
+ post_action = 'current'
label_mode = str(eff.get('label_mode') or 'none').strip().lower()
if label_mode not in {'none', 'custom', 'moved_from', 'moved_to'}:
label_mode = 'none'
diff --git a/pytorrent/services/download_planner.py b/pytorrent/services/download_planner.py
index 176064b..aa08c20 100644
--- a/pytorrent/services/download_planner.py
+++ b/pytorrent/services/download_planner.py
@@ -583,16 +583,10 @@ def start_scheduler(socketio=None) -> None:
def loop():
while True:
try:
- from .preferences import active_profile
from .websocket import emit_profile_event
- from . import auth
profiles: list[dict]
- if auth.enabled():
- with connect() as conn:
- profiles = conn.execute("SELECT * FROM rtorrent_profiles ORDER BY id").fetchall()
- else:
- profile = active_profile()
- profiles = [profile] if profile else []
+ with connect() as conn:
+ profiles = [dict(row) for row in conn.execute("SELECT * FROM rtorrent_profiles ORDER BY id").fetchall()]
for profile in profiles:
try:
result = enforce(profile, force=False)
diff --git a/pytorrent/services/rss.py b/pytorrent/services/rss.py
index b6297ab..9bb01b5 100644
--- a/pytorrent/services/rss.py
+++ b/pytorrent/services/rss.py
@@ -200,7 +200,10 @@ def start_scheduler(socketio=None) -> None:
with connect() as conn:
profiles = conn.execute("SELECT DISTINCT profile_id FROM rss_feeds WHERE enabled=1 AND profile_id IS NOT NULL").fetchall()
for row in profiles:
- profile = get_profile(int(row["profile_id"]))
+ profile_id = int(row["profile_id"])
+ with connect() as conn:
+ owner = conn.execute("SELECT user_id FROM rtorrent_profiles WHERE id=?", (profile_id,)).fetchone()
+ profile = get_profile(profile_id, int(owner["user_id"] if owner and owner.get("user_id") else default_user_id()))
if profile:
result = check(profile, only_due=True)
if socketio and result.get("queued"):
diff --git a/pytorrent/services/rtorrent/torrents.py b/pytorrent/services/rtorrent/torrents.py
index dd16795..ca73e76 100644
--- a/pytorrent/services/rtorrent/torrents.py
+++ b/pytorrent/services/rtorrent/torrents.py
@@ -842,7 +842,7 @@ def transfer_profile(source_profile: dict, target_profile: dict, torrent_hashes:
target_path = _remote_clean_path(payload.get("target_path") or payload.get("path") or "")
move_data = bool(payload.get("move_data"))
post_action = str(payload.get("post_action") or "none").strip().lower()
- if post_action not in {"none", "start", "stop", "pause", "check", "recheck"}:
+ if post_action not in {"none", "current", "start", "stop", "pause", "check", "recheck"}:
raise ValueError("Unsupported post-transfer action")
label_mode = str(payload.get("label_mode") or "none").strip().lower()
label_value = str(payload.get("label_value") or "").strip()
@@ -902,8 +902,8 @@ def transfer_profile(source_profile: dict, target_profile: dict, torrent_hashes:
move_result = _move_profile_transfer_data(source_client, h, target_path)
item.update(move_result)
moved_to = str(move_result.get("moved_to") or "")
- # Note: Explicit post-transfer actions override state restoration and keep command effects predictable.
- start_on_target = bool(move_data and (was_state or was_active)) if post_action == "none" else post_action == "start"
+ # Note: The default keeps the torrent status from the source profile; explicit actions override it.
+ start_on_target = bool(was_state or was_active) if post_action in {"none", "current"} else post_action == "start"
try:
added = add_torrent_raw(target_profile, data, start_on_target, target_path, target_label)
if not added.get("ok"):
diff --git a/pytorrent/static/js/api.js b/pytorrent/static/js/api.js
index 56db230..ec5afd8 100644
--- a/pytorrent/static/js/api.js
+++ b/pytorrent/static/js/api.js
@@ -1 +1 @@
-export const apiSource = " async function post(url,data,method='POST'){\n const res=await fetch(url,{method,headers:{'Content-Type':'application/json','Accept':'application/json'},body:JSON.stringify(data||{})});\n const text=await res.text();\n let json;\n try{ json=JSON.parse(text); }\n catch(e){\n const clean=(text||'').replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim().slice(0,180);\n throw new Error(clean?`Invalid server response (${res.status}): ${clean}`:`Invalid server response (${res.status})`);\n }\n if(!res.ok || !json.ok) throw new Error(json.error||`Operation failed (${res.status})`);\n return json;\n }\n\n async function runAction(action, extra={}){ const hashes=selectedHashes(); if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning'); let payload={hashes,...extra}; if(action==='move'){ openPathPicker('move'); return; } if(action==='profile_transfer'){ openProfileTransferModal(); return; } setBusy(true); try{ const j=await post(`/api/torrents/${action}`,payload); markQueuedJobs(j, hashes, action); if(action==='recheck'){ hashes.forEach(h=>{ const t=torrents.get(h); if(t) torrents.set(h,{...t,status:'Checking',hashing:1,message:'Force recheck queued'}); }); scheduleRender(true); } const parts=Number(j.bulk_parts||1); toastMessage('toast.actionQueued','success',{action,parts}); if(action==='set_label') await loadLabels(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }\n function profileTransferTargetRow(profile){\n const name=profile.name||('rTorrent '+profile.id);\n return `${esc(name)} #${esc(profile.id)} `;\n }\n function selectedTorrentSummaryRows(hashes){\n const rows=hashes.slice(0,6).map(h=>{\n const t=torrents.get(h)||{};\n return `
${esc(t.name||h)} ${esc(t.size_h||'')}
`;\n }).join('');\n const more=hashes.length>6?`+${hashes.length-6} more `:'';\n return rows+more;\n }\n function selectedTorrentBytes(hashes){\n return hashes.reduce((sum,h)=>sum+Number((torrents.get(h)||{}).size||0),0);\n }\n function humanBytes(bytes){\n let value=Number(bytes||0); const units=['B','KB','MB','GB','TB','PB']; let idx=0;\n while(value>=1024 && idx=10||idx===0?0:1)} ${units[idx]}`;\n }\n function setProfileTransferPermission(message, tone='muted'){\n const el=$('profileTransferPermissionNote');\n if(!el) return;\n el.className=`profile-transfer-permission form-text mt-2 text-${tone}`;\n el.textContent=message;\n }\n function setProfileTransferDiskInfo(html){\n const el=$('profileTransferDiskInfo');\n if(el) el.innerHTML=html;\n }\n function renderProfileTransferPathHints(paths=[]){\n const box=$('profileTransferPathHints');\n if(!box) return;\n const clean=[...new Set((paths||[]).map(p=>String(p||'').trim()).filter(Boolean))];\n box.innerHTML=clean.length?clean.map(p=>` ${esc(p)} `).join(' '):'No cached target roots. ';\n }\n function setProfileTransferTargetPath(path, overwrite=false){\n const input=$('profileTransferTargetPath');\n if(input && (overwrite || !input.value.trim())) input.value=path||'';\n }\n function profileTransferPayloadBase(){\n return {\n target_profile_id:Number($('profileTransferTargetId')?.value||0),\n move_data:!!($('profileTransferMoveData')?.checked),\n label_mode:$('profileTransferLabelMode')?.value||'none',\n label_value:$('profileTransferLabelValue')?.value||'',\n post_action:$('profileTransferPostAction')?.value||'none',\n target_path:($('profileTransferTargetPath')?.value||'').trim()\n };\n }\n async function validateProfileTransferSelection(){\n const payload=profileTransferPayloadBase();\n const selectedSize=selectedTorrentBytes(selectedHashes());\n if(!payload.target_profile_id){\n setProfileTransferPermission('Choose a target profile. Torrent metadata can be moved without data-file write permission.');\n setProfileTransferDiskInfo('Select a target profile to see destination disk space.');\n renderProfileTransferPathHints([]);\n return null;\n }\n setProfileTransferPermission(payload.move_data?'Checking data-move permissions...':'Only torrent metadata will be moved. Data files stay in the current location.', 'muted');\n try{\n const j=await post('/api/torrents/profile_transfer/validate',payload);\n const disk=j.disk||{};\n const free=Number(disk.free||0);\n const enough=!selectedSize || !free || free>=selectedSize;\n const diskTone=disk.ok?(enough?'success':'warning'):'warning';\n setProfileTransferTargetPath(j.target_path||'', false);\n renderProfileTransferPathHints(j.target_allowed_roots||[]);\n setProfileTransferDiskInfo(`Destination: ${esc(j.target_path||'-')}
Free: ${esc(disk.free_h||'-')} / ${esc(disk.total_h||'-')} \u00b7 Selected: ${esc(humanBytes(selectedSize))}
${disk.warning?`${esc(disk.warning)}
`:''}`);\n if(payload.move_data && j.move_data_allowed){\n setProfileTransferPermission(enough?'Data move is allowed for the destination path.':'Data move is allowed, but free space may be lower than selected torrent size.', enough?'success':'warning');\n } else if(payload.move_data){\n setProfileTransferPermission(`Data move is not allowed, so only torrent metadata will be moved. ${j.move_data_downgrade_reason||''}`.trim(), 'warning');\n }\n return j;\n }catch(e){\n setProfileTransferPermission(`Cannot validate data move. Only torrent metadata will be moved. ${e.message}`, 'warning');\n setProfileTransferDiskInfo('Destination disk space unavailable. ');\n return {move_data_allowed:false,error:e.message};\n }\n }\n async function openProfileTransferModal(){\n const hashes=selectedHashes();\n if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning');\n const list=$('profileTransferList');\n const count=$('profileTransferCount');\n const torrentList=$('profileTransferTorrentList');\n if(count) count.textContent=String(hashes.length);\n if(torrentList) torrentList.innerHTML=selectedTorrentSummaryRows(hashes);\n if(list) list.innerHTML=' Loading profiles...';\n setProfileTransferPermission('Choose a target profile. Torrent metadata can be moved without data-file write permission.');\n setProfileTransferDiskInfo('Select a target profile to see destination disk space.');\n renderProfileTransferPathHints([]);\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n const activeId=Number(j.active?.id || window.PYTORRENT?.activeProfile || activeProfileId || 0);\n const targets=(j.profiles||[]).filter(p=>Number(p.id)!==activeId && p.can_write!==false);\n if(list) list.innerHTML=targets.map(profileTransferTargetRow).join('') || 'No other writable profile is available.
';\n if($('profileTransferTargetId')) $('profileTransferTargetId').value='';\n if($('profileTransferTargetPath')) $('profileTransferTargetPath').value='';\n if($('profileTransferMoveData')) $('profileTransferMoveData').checked=false;\n if($('profileTransferLabelMode')) $('profileTransferLabelMode').value='none';\n if($('profileTransferLabelValue')) { $('profileTransferLabelValue').value=''; $('profileTransferLabelValue').classList.add('d-none'); }\n if($('profileTransferPostAction')) $('profileTransferPostAction').value='none';\n new bootstrap.Modal($('profileTransferModal')).show();\n }catch(e){\n if(list) list.innerHTML=`${esc(e.message)}
`;\n new bootstrap.Modal($('profileTransferModal')).show();\n }\n }\n async function submitProfileTransfer(){\n const hashes=selectedHashes();\n const payload={hashes,...profileTransferPayloadBase()};\n if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning');\n if(!payload.target_profile_id) return toast('Choose target profile.', 'warning');\n const btn=$('profileTransferBtn');\n buttonBusy(btn,true);\n try{\n const j=await post('/api/torrents/profile_transfer',payload);\n markQueuedJobs(j, hashes, 'profile_transfer');\n const parts=Number(j.bulk_parts||1);\n const downgraded=j.transfer_move_data_downgraded?' Data move was not permitted; only torrent metadata will be moved.':'';\n toast(`Move to profile queued (${parts} part${parts===1?'':'s'}).${downgraded}`,'success');\n bootstrap.Modal.getInstance($('profileTransferModal'))?.hide();\n }catch(e){ toast(e.message,'danger'); }\n finally{ buttonBusy(btn,false); }\n }\n $('profileTransferList')?.addEventListener('click',e=>{\n const btn=e.target.closest('.profile-transfer-card');\n if(!btn) return;\n document.querySelectorAll('.profile-transfer-card').forEach(x=>x.classList.remove('active'));\n btn.classList.add('active');\n if($('profileTransferTargetId')) $('profileTransferTargetId').value=btn.dataset.profileId||'';\n validateProfileTransferSelection();\n });\n $('profileTransferMoveData')?.addEventListener('change',validateProfileTransferSelection);\n $('profileTransferTargetPath')?.addEventListener('input',()=>{ clearTimeout(window.__profileTransferPathTimer); window.__profileTransferPathTimer=setTimeout(validateProfileTransferSelection, 350); });\n $('profileTransferPathHints')?.addEventListener('click',e=>{ const btn=e.target.closest('.profile-transfer-root'); if(!btn) return; setProfileTransferTargetPath(btn.dataset.path||'', true); validateProfileTransferSelection(); });\n $('profileTransferPostAction')?.addEventListener('change',validateProfileTransferSelection);\n $('profileTransferLabelMode')?.addEventListener('change',()=>{\n const custom=$('profileTransferLabelMode')?.value==='custom';\n $('profileTransferLabelValue')?.classList.toggle('d-none', !custom);\n });\n $('profileTransferBtn')?.addEventListener('click',submitProfileTransfer);\n\n function flag(iso){ const code=String(iso||'').toLowerCase(); return code?` ${esc(code.toUpperCase())} `:'-'; }\n function table(headers,rows,extraClass=''){ const cls=extraClass?` ${extraClass}`:''; return `${headers.map(h=>`${esc(h)} `).join('')} ${rows.map(r=>`${r.map(c=>`${c} `).join('')} `).join('')}
`; }\n function responsiveTable(headers,rows,extraClass=''){ return `${table(headers,rows,extraClass)}
`; }\n function downloadJson(filename, data){ const blob=new Blob([JSON.stringify(data,null,2)],{type:'application/json'}); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(url),500); }\n function filenameFromResponse(res, fallback){ const cd=res.headers.get('Content-Disposition')||''; const m=cd.match(/filename\\*=UTF-8''([^;]+)|filename=\"?([^\";]+)\"?/i); try{ return decodeURIComponent(m?.[1]||m?.[2]||fallback); }catch(e){ return m?.[1]||m?.[2]||fallback; } }\n async function openTemporaryDownload(url, data=null, method='POST', label='Preparing download...'){\n // Note: Link creation is intentionally light; real file work starts when the browser opens the temporary /download URL.\n setBusy(true, label);\n try{\n const options = {method, headers:{'Accept':'application/json'}};\n if(data !== null){\n options.headers['Content-Type']='application/json';\n options.body=JSON.stringify(data || {});\n }\n const res = await fetch(url, options);\n const json = await res.json().catch(()=>({}));\n if(!res.ok || !json.ok) throw new Error(json.error || `Download link failed (${res.status})`);\n if(!json.url) throw new Error('Download link response did not include a URL');\n const loader=$('globalLoader');\n const span=loader?.querySelector('span:last-child');\n if(span) span.textContent='Starting browser download...';\n // Note: Do not call setBusy(true) again here; this updates the active loader without increasing the busy counter.\n window.location.href = json.url;\n toastMessage('toast.downloadStarted','success');\n setTimeout(()=>setBusy(false), 1200);\n return json;\n } catch(e) {\n setBusy(false);\n throw e;\n }\n }\n async function downloadResponse(url, options={}, fallback='download.bin', label='Preparing download...'){\n setBusy(true,label);\n try{\n const res=await fetch(url,options);\n if(!res.ok){ const j=await res.json().catch(()=>({})); throw new Error(j.error||`Download failed: HTTP ${res.status}`); }\n const total=Number(res.headers.get('Content-Length')||0);\n const name=filenameFromResponse(res,fallback);\n let blob;\n if(res.body){\n const reader=res.body.getReader();\n const chunks=[]; let received=0;\n while(true){\n const {done,value}=await reader.read();\n if(done) break;\n chunks.push(value); received += value.length;\n const loader=$('globalLoader');\n const span=loader?.querySelector('span:last-child');\n if(span){\n if(total){\n const pct=Math.max(0,Math.min(100,Math.round((received/total)*100)));\n span.textContent=`Downloading ${pct}%`;\n } else {\n span.textContent=`Downloading ${(received/1024/1024).toFixed(1)} MB`;\n }\n }\n }\n blob=new Blob(chunks);\n } else {\n blob=await res.blob();\n }\n const obj=URL.createObjectURL(blob);\n const a=document.createElement('a'); a.href=obj; a.download=name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(obj),1000);\n toastMessage('toast.downloadStarted','success');\n } finally { setBusy(false); }\n }\n async function downloadTorrentFiles(hashes=null){\n const list=hashes||selectedHashes();\n if(!list.length) return toastMessage('toast.noTorrentsSelected','warning');\n if(list.length===1){\n return openTemporaryDownload(\n `/api/torrents/${encodeURIComponent(list[0])}/torrent-file/link`,\n null,\n 'GET',\n 'Preparing .torrent file...'\n ).catch(e=>toast(e.message,'danger'));\n }\n return openTemporaryDownload(\n '/api/torrents/torrent-files.zip/link',\n {hashes:list},\n 'POST',\n `Preparing torrent ZIP (${list.length})...`\n ).catch(e=>toast(e.message,'danger'));\n }\n";
+export const apiSource = " async function post(url,data,method='POST'){\n const res=await fetch(url,{method,headers:{'Content-Type':'application/json','Accept':'application/json'},body:JSON.stringify(data||{})});\n const text=await res.text();\n let json;\n try{ json=JSON.parse(text); }\n catch(e){\n const clean=(text||'').replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim().slice(0,180);\n throw new Error(clean?`Invalid server response (${res.status}): ${clean}`:`Invalid server response (${res.status})`);\n }\n if(!res.ok || !json.ok) throw new Error(json.error||`Operation failed (${res.status})`);\n return json;\n }\n\n async function runAction(action, extra={}){ const hashes=selectedHashes(); if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning'); let payload={hashes,...extra}; if(action==='move'){ openPathPicker('move'); return; } if(action==='profile_transfer'){ openProfileTransferModal(); return; } setBusy(true); try{ const j=await post(`/api/torrents/${action}`,payload); markQueuedJobs(j, hashes, action); if(action==='recheck'){ hashes.forEach(h=>{ const t=torrents.get(h); if(t) torrents.set(h,{...t,status:'Checking',hashing:1,message:'Force recheck queued'}); }); scheduleRender(true); } const parts=Number(j.bulk_parts||1); toastMessage('toast.actionQueued','success',{action,parts}); if(action==='set_label') await loadLabels(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }\n function profileTransferTargetRow(profile){\n const name=profile.name||('rTorrent '+profile.id);\n const stats=profile.runtime_stats||null;\n const meta=[];\n if(stats){\n if(stats.torrent_count!==undefined) meta.push(`${stats.torrent_count} torrents`);\n if(stats.total_size_h) meta.push(stats.total_size_h);\n if(stats.seeding_count!==undefined || stats.downloading_count!==undefined) meta.push(`${stats.seeding_count||0} seeding / ${stats.downloading_count||0} downloading`);\n }\n return `${esc(name)} #${esc(profile.id)}${meta.length?' \u00b7 '+esc(meta.join(' \u00b7 ')):''} `;\n }\n function selectedTorrentSummaryRows(hashes){\n const rows=hashes.slice(0,6).map(h=>{\n const t=torrents.get(h)||{};\n return `${esc(t.name||h)} ${esc(t.size_h||'')}
`;\n }).join('');\n const more=hashes.length>6?`+${hashes.length-6} more `:'';\n return rows+more;\n }\n function selectedTorrentBytes(hashes){\n return hashes.reduce((sum,h)=>sum+Number((torrents.get(h)||{}).size||0),0);\n }\n function humanBytes(bytes){\n let value=Number(bytes||0); const units=['B','KB','MB','GB','TB','PB']; let idx=0;\n while(value>=1024 && idx=10||idx===0?0:1)} ${units[idx]}`;\n }\n function setProfileTransferPermission(message, tone='muted'){\n const el=$('profileTransferPermissionNote');\n if(!el) return;\n el.className=`profile-transfer-permission form-text mt-2 text-${tone}`;\n el.textContent=message;\n }\n function setProfileTransferDiskInfo(html){\n const el=$('profileTransferDiskInfo');\n if(el) el.innerHTML=html;\n }\n function renderProfileTransferPathHints(paths=[]){\n const box=$('profileTransferPathHints');\n if(!box) return;\n const clean=[...new Set((paths||[]).map(p=>String(p||'').trim()).filter(Boolean))];\n box.innerHTML=clean.length?clean.map(p=>` ${esc(p)} `).join(' '):'No cached target roots. ';\n }\n function setProfileTransferTargetPath(path, overwrite=false){\n const input=$('profileTransferTargetPath');\n if(input && (overwrite || !input.value.trim())) input.value=path||'';\n }\n function profileTransferPayloadBase(){\n return {\n target_profile_id:Number($('profileTransferTargetId')?.value||0),\n move_data:!!($('profileTransferMoveData')?.checked),\n label_mode:$('profileTransferLabelMode')?.value||'none',\n label_value:$('profileTransferLabelValue')?.value||'',\n post_action:$('profileTransferPostAction')?.value||'current',\n target_path:($('profileTransferTargetPath')?.value||'').trim()\n };\n }\n async function validateProfileTransferSelection(){\n const payload=profileTransferPayloadBase();\n const selectedSize=selectedTorrentBytes(selectedHashes());\n if(!payload.target_profile_id){\n setProfileTransferPermission('Choose a target profile. Torrent metadata can be moved without data-file write permission.');\n setProfileTransferDiskInfo('Select a target profile to see destination disk space.');\n renderProfileTransferPathHints([]);\n return null;\n }\n setProfileTransferPermission(payload.move_data?'Checking data-move permissions...':'Only torrent metadata will be moved. Data files stay in the current location.', 'muted');\n try{\n const j=await post('/api/torrents/profile_transfer/validate',payload);\n const disk=j.disk||{};\n const free=Number(disk.free||0);\n const enough=!selectedSize || !free || free>=selectedSize;\n const diskTone=disk.ok?(enough?'success':'warning'):'warning';\n setProfileTransferTargetPath(j.target_path||'', false);\n renderProfileTransferPathHints(j.target_allowed_roots||[]);\n setProfileTransferDiskInfo(`Destination: ${esc(j.target_path||'-')}
Free: ${esc(disk.free_h||'-')} / ${esc(disk.total_h||'-')} \u00b7 Selected: ${esc(humanBytes(selectedSize))}
${disk.warning?`${esc(disk.warning)}
`:''}`);\n if(payload.move_data && j.move_data_allowed){\n setProfileTransferPermission(enough?'Data move is allowed for the destination path.':'Data move is allowed, but free space may be lower than selected torrent size.', enough?'success':'warning');\n } else if(payload.move_data){\n setProfileTransferPermission(`Data move is not allowed, so only torrent metadata will be moved. ${j.move_data_downgrade_reason||''}`.trim(), 'warning');\n }\n return j;\n }catch(e){\n setProfileTransferPermission(`Cannot validate data move. Only torrent metadata will be moved. ${e.message}`, 'warning');\n setProfileTransferDiskInfo('Destination disk space unavailable. ');\n return {move_data_allowed:false,error:e.message};\n }\n }\n function selectedTransferDefaultAction(hashes){\n const states=hashes.map(h=>String((torrents.get(h)||{}).status||'').toLowerCase());\n const active=states.filter(Boolean);\n if(!active.length) return 'current';\n if(active.every(s=>s.includes('stop'))) return 'stop';\n if(active.every(s=>s.includes('pause'))) return 'pause';\n if(active.every(s=>s.includes('check'))) return 'check';\n if(active.every(s=>s.includes('seed') || s.includes('download') || s.includes('queue'))) return 'start';\n return 'current';\n }\n\n async function openProfileTransferModal(){\n const hashes=selectedHashes();\n if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning');\n const list=$('profileTransferList');\n const count=$('profileTransferCount');\n const torrentList=$('profileTransferTorrentList');\n if(count) count.textContent=String(hashes.length);\n if(torrentList) torrentList.innerHTML=selectedTorrentSummaryRows(hashes);\n if(list) list.innerHTML=' Loading profiles...';\n setProfileTransferPermission('Choose a target profile. Torrent metadata can be moved without data-file write permission.');\n setProfileTransferDiskInfo('Select a target profile to see destination disk space.');\n renderProfileTransferPathHints([]);\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n const activeId=Number(j.active?.id || window.PYTORRENT?.activeProfile || activeProfileId || 0);\n const targets=(j.profiles||[]).filter(p=>Number(p.id)!==activeId && p.can_write!==false);\n if(list) list.innerHTML=targets.map(profileTransferTargetRow).join('') || 'No other writable profile is available.
';\n if($('profileTransferTargetId')) $('profileTransferTargetId').value='';\n if($('profileTransferTargetPath')) $('profileTransferTargetPath').value='';\n if($('profileTransferMoveData')) $('profileTransferMoveData').checked=false;\n if($('profileTransferLabelMode')) $('profileTransferLabelMode').value='none';\n if($('profileTransferLabelValue')) { $('profileTransferLabelValue').value=''; $('profileTransferLabelValue').classList.add('d-none'); }\n if($('profileTransferPostAction')) $('profileTransferPostAction').value=selectedTransferDefaultAction(hashes);\n new bootstrap.Modal($('profileTransferModal')).show();\n }catch(e){\n if(list) list.innerHTML=`${esc(e.message)}
`;\n new bootstrap.Modal($('profileTransferModal')).show();\n }\n }\n async function submitProfileTransfer(){\n const hashes=selectedHashes();\n const payload={hashes,...profileTransferPayloadBase()};\n if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning');\n if(!payload.target_profile_id) return toast('Choose target profile.', 'warning');\n const btn=$('profileTransferBtn');\n buttonBusy(btn,true);\n try{\n const j=await post('/api/torrents/profile_transfer',payload);\n markQueuedJobs(j, hashes, 'profile_transfer');\n const parts=Number(j.bulk_parts||1);\n const downgraded=j.transfer_move_data_downgraded?' Data files cannot be moved with the selected destination, so only torrent metadata was queued.':'';\n const targetName=document.querySelector('.profile-transfer-card.active b')?.textContent || 'selected profile';\n toast(`Profile transfer queued for ${hashes.length} torrent${hashes.length===1?'':'s'} to ${targetName} (${parts} job${parts===1?'':'s'}).${downgraded}`,'success');\n bootstrap.Modal.getInstance($('profileTransferModal'))?.hide();\n }catch(e){ toast(e.message,'danger'); }\n finally{ buttonBusy(btn,false); }\n }\n $('profileTransferList')?.addEventListener('click',e=>{\n const btn=e.target.closest('.profile-transfer-card');\n if(!btn) return;\n document.querySelectorAll('.profile-transfer-card').forEach(x=>x.classList.remove('active'));\n btn.classList.add('active');\n if($('profileTransferTargetId')) $('profileTransferTargetId').value=btn.dataset.profileId||'';\n if($('profileTransferTargetPath')) $('profileTransferTargetPath').value='';\n validateProfileTransferSelection();\n });\n $('profileTransferMoveData')?.addEventListener('change',validateProfileTransferSelection);\n $('profileTransferTargetPath')?.addEventListener('input',()=>{ clearTimeout(window.__profileTransferPathTimer); window.__profileTransferPathTimer=setTimeout(validateProfileTransferSelection, 350); });\n $('profileTransferPathHints')?.addEventListener('click',e=>{ const btn=e.target.closest('.profile-transfer-root'); if(!btn) return; setProfileTransferTargetPath(btn.dataset.path||'', true); validateProfileTransferSelection(); });\n $('profileTransferPostAction')?.addEventListener('change',validateProfileTransferSelection);\n $('profileTransferLabelMode')?.addEventListener('change',()=>{\n const custom=$('profileTransferLabelMode')?.value==='custom';\n $('profileTransferLabelValue')?.classList.toggle('d-none', !custom);\n });\n $('profileTransferBtn')?.addEventListener('click',submitProfileTransfer);\n\n function flag(iso){ const code=String(iso||'').toLowerCase(); return code?` ${esc(code.toUpperCase())} `:'-'; }\n function table(headers,rows,extraClass=''){ const cls=extraClass?` ${extraClass}`:''; return `${headers.map(h=>`${esc(h)} `).join('')} ${rows.map(r=>`${r.map(c=>`${c} `).join('')} `).join('')}
`; }\n function responsiveTable(headers,rows,extraClass=''){ return `${table(headers,rows,extraClass)}
`; }\n function downloadJson(filename, data){ const blob=new Blob([JSON.stringify(data,null,2)],{type:'application/json'}); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(url),500); }\n function filenameFromResponse(res, fallback){ const cd=res.headers.get('Content-Disposition')||''; const m=cd.match(/filename\\*=UTF-8''([^;]+)|filename=\"?([^\";]+)\"?/i); try{ return decodeURIComponent(m?.[1]||m?.[2]||fallback); }catch(e){ return m?.[1]||m?.[2]||fallback; } }\n async function openTemporaryDownload(url, data=null, method='POST', label='Preparing download...'){\n // Note: Link creation is intentionally light; real file work starts when the browser opens the temporary /download URL.\n setBusy(true, label);\n try{\n const options = {method, headers:{'Accept':'application/json'}};\n if(data !== null){\n options.headers['Content-Type']='application/json';\n options.body=JSON.stringify(data || {});\n }\n const res = await fetch(url, options);\n const json = await res.json().catch(()=>({}));\n if(!res.ok || !json.ok) throw new Error(json.error || `Download link failed (${res.status})`);\n if(!json.url) throw new Error('Download link response did not include a URL');\n const loader=$('globalLoader');\n const span=loader?.querySelector('span:last-child');\n if(span) span.textContent='Starting browser download...';\n // Note: Do not call setBusy(true) again here; this updates the active loader without increasing the busy counter.\n window.location.href = json.url;\n toastMessage('toast.downloadStarted','success');\n setTimeout(()=>setBusy(false), 1200);\n return json;\n } catch(e) {\n setBusy(false);\n throw e;\n }\n }\n async function downloadResponse(url, options={}, fallback='download.bin', label='Preparing download...'){\n setBusy(true,label);\n try{\n const res=await fetch(url,options);\n if(!res.ok){ const j=await res.json().catch(()=>({})); throw new Error(j.error||`Download failed: HTTP ${res.status}`); }\n const total=Number(res.headers.get('Content-Length')||0);\n const name=filenameFromResponse(res,fallback);\n let blob;\n if(res.body){\n const reader=res.body.getReader();\n const chunks=[]; let received=0;\n while(true){\n const {done,value}=await reader.read();\n if(done) break;\n chunks.push(value); received += value.length;\n const loader=$('globalLoader');\n const span=loader?.querySelector('span:last-child');\n if(span){\n if(total){\n const pct=Math.max(0,Math.min(100,Math.round((received/total)*100)));\n span.textContent=`Downloading ${pct}%`;\n } else {\n span.textContent=`Downloading ${(received/1024/1024).toFixed(1)} MB`;\n }\n }\n }\n blob=new Blob(chunks);\n } else {\n blob=await res.blob();\n }\n const obj=URL.createObjectURL(blob);\n const a=document.createElement('a'); a.href=obj; a.download=name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(obj),1000);\n toastMessage('toast.downloadStarted','success');\n } finally { setBusy(false); }\n }\n async function downloadTorrentFiles(hashes=null){\n const list=hashes||selectedHashes();\n if(!list.length) return toastMessage('toast.noTorrentsSelected','warning');\n if(list.length===1){\n return openTemporaryDownload(\n `/api/torrents/${encodeURIComponent(list[0])}/torrent-file/link`,\n null,\n 'GET',\n 'Preparing .torrent file...'\n ).catch(e=>toast(e.message,'danger'));\n }\n return openTemporaryDownload(\n '/api/torrents/torrent-files.zip/link',\n {hashes:list},\n 'POST',\n `Preparing torrent ZIP (${list.length})...`\n ).catch(e=>toast(e.message,'danger'));\n }\n";
diff --git a/pytorrent/static/js/automationRules.js b/pytorrent/static/js/automationRules.js
index 799dfda..d02f108 100644
--- a/pytorrent/static/js/automationRules.js
+++ b/pytorrent/static/js/automationRules.js
@@ -1 +1 @@
-export const automationRulesSource = " function automationCondition(){\n const type=$('autoConditionType')?.value||'completed';\n const cond={type, negate:!!$('autoCondNegate')?.checked};\n if(type==='no_seeds'){ cond.seeds=Number($('autoCondSeeds')?.value||0); cond.minutes=Number($('autoCondMinutes')?.value||0); }\n if(type==='ratio_gte') cond.ratio=Number($('autoCondRatio')?.value||1);\n // Note: Progress conditions compare the torrent completion percentage stored in the live torrent row.\n if(type==='progress_gte'||type==='progress_lte') cond.progress=Number($('autoCondProgress')?.value||0);\n if(type==='label_missing'||type==='label_has') cond.label=$('autoCondLabel')?.value||'';\n if(type==='status') cond.status=$('autoCondStatus')?.value||'Seeding';\n if(type==='path_contains') cond.text=$('autoCondText')?.value||'';\n return cond;\n }\n\n function automationEffect(){\n const type=$('autoEffectType')?.value||'add_label';\n const eff={type};\n if(type==='move'){\n eff.path=$('autoEffectPath')?.value||'';\n eff.move_data=!!$('autoMoveData')?.checked;\n eff.recheck=!!$('autoMoveRecheck')?.checked;\n eff.keep_seeding=!!$('autoMoveKeepSeeding')?.checked;\n }\n if(type==='profile_transfer'){\n eff.target_profile_id=Number($('autoProfileTransferTargetId')?.value||0);\n eff.target_path=($('autoProfileTransferTargetPath')?.value||'').trim();\n eff.move_data=!!$('autoProfileTransferMoveData')?.checked;\n eff.post_action=$('autoProfileTransferPostAction')?.value||'none';\n eff.label_mode=$('autoProfileTransferLabelMode')?.value||'none';\n eff.label_value=$('autoProfileTransferLabelValue')?.value||'';\n }\n if(type==='add_label'||type==='remove_label') eff.label=$('autoEffectLabel')?.value||'';\n if(type==='set_labels') eff.labels=$('autoEffectLabels')?.value||'';\n return eff;\n }\n\n function updateAutomationForm(){\n const ct=$('autoConditionType')?.value||'';\n document.querySelectorAll('[data-auto-cond]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoCond.split(',').includes(ct)));\n const et=$('autoEffectType')?.value||'';\n document.querySelectorAll('[data-auto-effect]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoEffect.split(',').includes(et)));\n }\n\n function conditionText(c={}){\n const base=c.type==='no_seeds'?`seeds <= ${c.seeds||0} for ${c.minutes||0} min`:c.type==='ratio_gte'?`ratio >= ${c.ratio}`:c.type==='progress_gte'?`progress >= ${c.progress||0}%`:c.type==='progress_lte'?`progress <= ${c.progress||0}%`:c.type==='label_missing'?`missing label ${c.label||''}`:c.type==='label_has'?`has label ${c.label||''}`:c.type==='status'?`status = ${c.status||''}`:c.type==='path_contains'?`path contains ${c.text||''}`:'completed';\n return c.negate?`NOT (${base})`:base;\n }\n function effectText(e={}){\n if(e.type==='move'){\n const flags=[];\n if(e.move_data) flags.push('move data');\n if(e.recheck) flags.push('recheck');\n if(e.keep_seeding) flags.push('keep seeding');\n return `move to ${e.path||'default path'}${flags.length?` (${flags.join(', ')})`:''}`;\n }\n if(e.type==='profile_transfer'){\n const flags=[];\n if(e.move_data) flags.push('move data if allowed');\n if(e.post_action && e.post_action!=='none') flags.push(e.post_action);\n if(e.label_mode && e.label_mode!=='none') flags.push(`label ${e.label_mode}`);\n const path=e.target_path?` \u00b7 ${e.target_path}`:'';\n return `move to profile #${e.target_profile_id||'?'}${path}${flags.length?` (${flags.join(', ')})`:''}`;\n }\n return e.type==='add_label'?`add label ${e.label||''}`:e.type==='remove_label'?`remove label ${e.label||''}`:e.type==='set_labels'?`set labels ${e.labels||''}`:e.type;\n }\n function ruleSummary(r){\n const cs=(r.conditions||[]).map(conditionText).join(' + ')||'no conditions';\n const es=(r.effects||[]).map(effectText).join(' \u2192 ')||'no actions';\n return `${cs} \u2192 ${es}`;\n }\n\n function renderAutomationBuilder(){\n const cBox=$('automationConditionList');\n if(cBox) cBox.innerHTML=automationConditions.length?automationConditions.map((c,i)=>`IF ${esc(conditionText(c))} `).join(''):'No conditions added yet. ';\n const eBox=$('automationEffectList');\n if(eBox) eBox.innerHTML=automationEffects.length?automationEffects.map((e,i)=>`${i+1} ${esc(effectText(e))} `).join(''):'No actions added yet. ';\n }\n function resetAutomationForm(){\n if($('autoEditId')) $('autoEditId').value='';\n if($('autoName')) $('autoName').value='';\n if($('autoEnabled')) $('autoEnabled').checked=true;\n if($('autoCooldown')) $('autoCooldown').value='60';\n automationConditions=[]; automationEffects=[];\n $('automationCancelEditBtn')?.classList.add('d-none');\n if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Save rule';\n renderAutomationBuilder(); updateAutomationForm();\n }\n function editAutomationRule(rule){\n if(!rule) return;\n if($('autoEditId')) $('autoEditId').value=rule.id||'';\n if($('autoName')) $('autoName').value=rule.name||'';\n if($('autoEnabled')) $('autoEnabled').checked=!!rule.enabled;\n if($('autoCooldown')) $('autoCooldown').value=rule.cooldown_minutes ?? 60;\n automationConditions=Array.isArray(rule.conditions)?JSON.parse(JSON.stringify(rule.conditions)):[];\n automationEffects=Array.isArray(rule.effects)?JSON.parse(JSON.stringify(rule.effects)):[];\n $('automationCancelEditBtn')?.classList.remove('d-none');\n if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Update rule';\n renderAutomationBuilder();\n }\n\n function summarizeActionObject(a={}){\n if(a.error) return `${esc(a.error)} `;\n const count=a.count || a.result?.count || a.result?.results?.length || '';\n const parts=[];\n if(a.type) parts.push(a.type);\n if(count) parts.push(`${count} torrent(s)`);\n if(a.path) parts.push(a.path);\n if(a.label) parts.push(`label ${a.label}`);\n if(a.labels) parts.push(`labels ${a.labels}`);\n if(a.move_data) parts.push('move data');\n if(a.recheck) parts.push('recheck');\n if(a.keep_seeding) parts.push('keep seeding');\n return `${esc(parts.join(' \u00b7 ')||'action')} `;\n }\n function automationHistoryActions(raw){\n let actions=[];\n try{ actions=JSON.parse(raw||'[]'); }catch(e){ return `${esc(raw||'')}
`; }\n if(!Array.isArray(actions)) actions=[actions];\n const summary=actions.map(summarizeActionObject).join(' ');\n const details=esc(JSON.stringify(actions,null,2));\n // Note: Large automation payloads are collapsed so JSON never stretches the modal width.\n return `${summary||'No actions'} ${details} `;\n }\n\n function renderAutomationHistory(hist=[]){\n if(!$('automationHistory')) return;\n const toolbar=' Clear history
';\n const rows=hist.map(h=>[humanDateCell(h.created_at),esc(h.rule_name||''),esc(h.torrent_name||h.torrent_hash||''),automationHistoryActions(h.actions_json||'')]);\n // Note: Automation history uses the shared responsive table wrapper so it stays inside narrow mobile modals.\n const body=hist.length?responsiveTable(['Time','Rule','Torrent / batch','Actions'],rows,'automation-history-table'):'No automation history yet.
';\n $('automationHistory').innerHTML=toolbar+body;\n }\n\n async function clearAutomationHistory(){\n if(!confirm('Clear automation history?')) return;\n setBusy(true);\n try{ const j=await fetch('/api/automations/history',{method:'DELETE'}).then(r=>r.json()); if(!j.ok) throw new Error(j.error||'Clear automation history failed'); toastMessage('toast.automationLogsDeleted','success',{deleted:j.deleted}); renderAutomationHistory(j.history||[]); }\n catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n async function exportAutomations(){\n try{ const j=await (await fetch('/api/automations/export')).json(); if(!j.ok) throw new Error(j.error||'Automation export failed'); downloadJson(`pytorrent-automation-rules-${new Date().toISOString().slice(0,10)}.json`, j.export||j); toast(`Exported ${j.count||0} automation rule(s)`,'success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n\n async function importAutomations(file){\n if(!file) return;\n try{ const payload=JSON.parse(await file.text()); const j=await post('/api/automations/import',payload); toast(`Imported ${j.imported||0} automation rule(s)`,'success'); await loadAutomations(); }\n catch(e){ toast(e.message||'Automation import failed','danger'); }\n finally{ if($('automationImportFile')) $('automationImportFile').value=''; }\n }\n\n async function loadAutomations(){\n const j=await fetch('/api/automations').then(r=>r.json());\n const rules=j.rules||[], hist=j.history||[];\n automationRulesCache=rules;\n if($('automationManager')) $('automationManager').innerHTML=rules.length?rules.map(r=>{\n const enabled=!!r.enabled;\n const toggleTitle=enabled?'Disable automation':'Enable automation';\n const toggleIcon=enabled?'fa-toggle-on':'fa-toggle-off';\n const toggleClass=enabled?'btn-outline-warning':'btn-outline-success';\n const owner=r.owner_label?` ${esc(r.owner_label)} `:'';\n return `${esc(r.name)} ${enabled?'on ':'off '} ${owner}
${esc(ruleSummary(r))} \u00b7 cooldown ${esc(r.cooldown_minutes||0)} min
Remove
`;\n }).join(''):'No automation rules.
';\n renderAutomationHistory(hist);\n }\n\n async function toggleAutomationRule(rule){\n if(!rule) return;\n const payload={...rule, enabled:!rule.enabled};\n // Note: Toggle keeps the rule definition unchanged and only switches automatic execution on or off.\n setBusy(true);\n try{ await post('/api/automations',payload); toast(payload.enabled?'Automation enabled':'Automation disabled','success'); await loadAutomations(); }\n catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n async function saveAutomation(){\n const currentCond=automationCondition();\n const currentEff=automationEffect();\n const conditions=automationConditions.length?automationConditions:[currentCond];\n const effects=automationEffects.length?automationEffects:[currentEff];\n const payload={id:Number($('autoEditId')?.value||0)||undefined,name:$('autoName')?.value||'Automation rule',enabled:!!$('autoEnabled')?.checked,cooldown_minutes:Number($('autoCooldown')?.value||60),conditions,effects};\n setBusy(true);\n try{ await post('/api/automations',payload); toast(payload.id?'Automation rule updated':'Automation rule saved','success'); resetAutomationForm(); await loadAutomations(); }\n catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n\n\n";
+export const automationRulesSource = " function automationCondition(){\n const type=$('autoConditionType')?.value||'completed';\n const cond={type, negate:!!$('autoCondNegate')?.checked};\n if(type==='no_seeds'){ cond.seeds=Number($('autoCondSeeds')?.value||0); cond.minutes=Number($('autoCondMinutes')?.value||0); }\n if(type==='ratio_gte') cond.ratio=Number($('autoCondRatio')?.value||1);\n // Note: Progress conditions compare the torrent completion percentage stored in the live torrent row.\n if(type==='progress_gte'||type==='progress_lte') cond.progress=Number($('autoCondProgress')?.value||0);\n if(type==='label_missing'||type==='label_has') cond.label=$('autoCondLabel')?.value||'';\n if(type==='status') cond.status=$('autoCondStatus')?.value||'Seeding';\n if(type==='path_contains') cond.text=$('autoCondText')?.value||'';\n return cond;\n }\n\n function automationEffect(){\n const type=$('autoEffectType')?.value||'add_label';\n const eff={type};\n if(type==='move'){\n eff.path=$('autoEffectPath')?.value||'';\n eff.move_data=!!$('autoMoveData')?.checked;\n eff.recheck=!!$('autoMoveRecheck')?.checked;\n eff.keep_seeding=!!$('autoMoveKeepSeeding')?.checked;\n }\n if(type==='profile_transfer'){\n eff.target_profile_id=Number($('autoProfileTransferTargetId')?.value||0);\n eff.target_path=($('autoProfileTransferTargetPath')?.value||'').trim();\n eff.move_data=!!$('autoProfileTransferMoveData')?.checked;\n eff.post_action=$('autoProfileTransferPostAction')?.value||'current';\n eff.label_mode=$('autoProfileTransferLabelMode')?.value||'none';\n eff.label_value=$('autoProfileTransferLabelValue')?.value||'';\n }\n if(type==='add_label'||type==='remove_label') eff.label=$('autoEffectLabel')?.value||'';\n if(type==='set_labels') eff.labels=$('autoEffectLabels')?.value||'';\n return eff;\n }\n\n function updateAutomationForm(){\n const ct=$('autoConditionType')?.value||'';\n document.querySelectorAll('[data-auto-cond]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoCond.split(',').includes(ct)));\n const et=$('autoEffectType')?.value||'';\n document.querySelectorAll('[data-auto-effect]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoEffect.split(',').includes(et)));\n }\n\n function conditionText(c={}){\n const base=c.type==='no_seeds'?`seeds <= ${c.seeds||0} for ${c.minutes||0} min`:c.type==='ratio_gte'?`ratio >= ${c.ratio}`:c.type==='progress_gte'?`progress >= ${c.progress||0}%`:c.type==='progress_lte'?`progress <= ${c.progress||0}%`:c.type==='label_missing'?`missing label ${c.label||''}`:c.type==='label_has'?`has label ${c.label||''}`:c.type==='status'?`status = ${c.status||''}`:c.type==='path_contains'?`path contains ${c.text||''}`:'completed';\n return c.negate?`NOT (${base})`:base;\n }\n function effectText(e={}){\n if(e.type==='move'){\n const flags=[];\n if(e.move_data) flags.push('move data');\n if(e.recheck) flags.push('recheck');\n if(e.keep_seeding) flags.push('keep seeding');\n return `move to ${e.path||'default path'}${flags.length?` (${flags.join(', ')})`:''}`;\n }\n if(e.type==='profile_transfer'){\n const flags=[];\n if(e.move_data) flags.push('move data if allowed');\n if(e.post_action && e.post_action!=='none') flags.push(e.post_action);\n if(e.label_mode && e.label_mode!=='none') flags.push(`label ${e.label_mode}`);\n const path=e.target_path?` \u00b7 ${e.target_path}`:'';\n return `move to profile #${e.target_profile_id||'?'}${path}${flags.length?` (${flags.join(', ')})`:''}`;\n }\n return e.type==='add_label'?`add label ${e.label||''}`:e.type==='remove_label'?`remove label ${e.label||''}`:e.type==='set_labels'?`set labels ${e.labels||''}`:e.type;\n }\n function ruleSummary(r){\n const cs=(r.conditions||[]).map(conditionText).join(' + ')||'no conditions';\n const es=(r.effects||[]).map(effectText).join(' \u2192 ')||'no actions';\n return `${cs} \u2192 ${es}`;\n }\n\n function renderAutomationBuilder(){\n const cBox=$('automationConditionList');\n if(cBox) cBox.innerHTML=automationConditions.length?automationConditions.map((c,i)=>`IF ${esc(conditionText(c))} `).join(''):'No conditions added yet. ';\n const eBox=$('automationEffectList');\n if(eBox) eBox.innerHTML=automationEffects.length?automationEffects.map((e,i)=>`${i+1} ${esc(effectText(e))} `).join(''):'No actions added yet. ';\n }\n function resetAutomationForm(){\n if($('autoEditId')) $('autoEditId').value='';\n if($('autoName')) $('autoName').value='';\n if($('autoEnabled')) $('autoEnabled').checked=true;\n if($('autoCooldown')) $('autoCooldown').value='60';\n automationConditions=[]; automationEffects=[];\n $('automationCancelEditBtn')?.classList.add('d-none');\n if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Save rule';\n renderAutomationBuilder(); updateAutomationForm();\n }\n function editAutomationRule(rule){\n if(!rule) return;\n if($('autoEditId')) $('autoEditId').value=rule.id||'';\n if($('autoName')) $('autoName').value=rule.name||'';\n if($('autoEnabled')) $('autoEnabled').checked=!!rule.enabled;\n if($('autoCooldown')) $('autoCooldown').value=rule.cooldown_minutes ?? 60;\n automationConditions=Array.isArray(rule.conditions)?JSON.parse(JSON.stringify(rule.conditions)):[];\n automationEffects=Array.isArray(rule.effects)?JSON.parse(JSON.stringify(rule.effects)):[];\n $('automationCancelEditBtn')?.classList.remove('d-none');\n if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Update rule';\n renderAutomationBuilder();\n }\n\n function summarizeActionObject(a={}){\n if(a.error) return `${esc(a.error)} `;\n const count=a.count || a.result?.count || a.result?.results?.length || '';\n const parts=[];\n if(a.type) parts.push(a.type);\n if(count) parts.push(`${count} torrent(s)`);\n if(a.path) parts.push(a.path);\n if(a.label) parts.push(`label ${a.label}`);\n if(a.labels) parts.push(`labels ${a.labels}`);\n if(a.move_data) parts.push('move data');\n if(a.recheck) parts.push('recheck');\n if(a.keep_seeding) parts.push('keep seeding');\n return `${esc(parts.join(' \u00b7 ')||'action')} `;\n }\n function automationHistoryActions(raw){\n let actions=[];\n try{ actions=JSON.parse(raw||'[]'); }catch(e){ return `${esc(raw||'')}
`; }\n if(!Array.isArray(actions)) actions=[actions];\n const summary=actions.map(summarizeActionObject).join(' ');\n const details=esc(JSON.stringify(actions,null,2));\n // Note: Large automation payloads are collapsed so JSON never stretches the modal width.\n return `${summary||'No actions'} ${details} `;\n }\n\n function renderAutomationHistory(hist=[]){\n if(!$('automationHistory')) return;\n const toolbar=' Clear history
';\n const rows=hist.map(h=>[humanDateCell(h.created_at),esc(h.rule_name||''),esc(h.torrent_name||h.torrent_hash||''),automationHistoryActions(h.actions_json||'')]);\n // Note: Automation history uses the shared responsive table wrapper so it stays inside narrow mobile modals.\n const body=hist.length?responsiveTable(['Time','Rule','Torrent / batch','Actions'],rows,'automation-history-table'):'No automation history yet.
';\n $('automationHistory').innerHTML=toolbar+body;\n }\n\n async function clearAutomationHistory(){\n if(!confirm('Clear automation history?')) return;\n setBusy(true);\n try{ const j=await fetch('/api/automations/history',{method:'DELETE'}).then(r=>r.json()); if(!j.ok) throw new Error(j.error||'Clear automation history failed'); toastMessage('toast.automationLogsDeleted','success',{deleted:j.deleted}); renderAutomationHistory(j.history||[]); }\n catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n async function exportAutomations(){\n try{ const j=await (await fetch('/api/automations/export')).json(); if(!j.ok) throw new Error(j.error||'Automation export failed'); downloadJson(`pytorrent-automation-rules-${new Date().toISOString().slice(0,10)}.json`, j.export||j); toast(`Exported ${j.count||0} automation rule(s)`,'success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n\n async function importAutomations(file){\n if(!file) return;\n try{ const payload=JSON.parse(await file.text()); const j=await post('/api/automations/import',payload); toast(`Imported ${j.imported||0} automation rule(s)`,'success'); await loadAutomations(); }\n catch(e){ toast(e.message||'Automation import failed','danger'); }\n finally{ if($('automationImportFile')) $('automationImportFile').value=''; }\n }\n\n async function loadAutomations(){\n const j=await fetch('/api/automations').then(r=>r.json());\n const rules=j.rules||[], hist=j.history||[];\n automationRulesCache=rules;\n if($('automationManager')) $('automationManager').innerHTML=rules.length?rules.map(r=>{\n const enabled=!!r.enabled;\n const toggleTitle=enabled?'Disable automation':'Enable automation';\n const toggleIcon=enabled?'fa-toggle-on':'fa-toggle-off';\n const toggleClass=enabled?'btn-outline-warning':'btn-outline-success';\n const owner=r.owner_label?` ${esc(r.owner_label)} `:'';\n return `${esc(r.name)} ${enabled?'on ':'off '} ${owner}
${esc(ruleSummary(r))} \u00b7 cooldown ${esc(r.cooldown_minutes||0)} min
Remove
`;\n }).join(''):'No automation rules.
';\n renderAutomationHistory(hist);\n }\n\n async function toggleAutomationRule(rule){\n if(!rule) return;\n const payload={...rule, enabled:!rule.enabled};\n // Note: Toggle keeps the rule definition unchanged and only switches automatic execution on or off.\n setBusy(true);\n try{ await post('/api/automations',payload); toast(payload.enabled?'Automation enabled':'Automation disabled','success'); await loadAutomations(); }\n catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n async function saveAutomation(){\n const currentCond=automationCondition();\n const currentEff=automationEffect();\n const conditions=automationConditions.length?automationConditions:[currentCond];\n const effects=automationEffects.length?automationEffects:[currentEff];\n const payload={id:Number($('autoEditId')?.value||0)||undefined,name:$('autoName')?.value||'Automation rule',enabled:!!$('autoEnabled')?.checked,cooldown_minutes:Number($('autoCooldown')?.value||60),conditions,effects};\n setBusy(true);\n try{ await post('/api/automations',payload); toast(payload.id?'Automation rule updated':'Automation rule saved','success'); resetAutomationForm(); await loadAutomations(); }\n catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n\n\n";
diff --git a/pytorrent/static/js/messages.js b/pytorrent/static/js/messages.js
index f2230b5..9f70306 100644
--- a/pytorrent/static/js/messages.js
+++ b/pytorrent/static/js/messages.js
@@ -1 +1 @@
-export const messagesSource = "\n\n const APP_MESSAGES = {\n actions: {\n raw_torrent: 'Add torrent',\n add_torrent_raw: 'Add torrent file',\n add_magnet: 'Add magnet link',\n add: 'Add torrent',\n // Note: Start is intentionally noun-free so queued/running UI stays consistent with the other short action labels.\n start: 'Start',\n pause: 'Pause',\n stop: 'Stop',\n resume: 'Resume',\n remove: 'Remove',\n erase: 'Delete',\n delete: 'Delete',\n move: 'Move',\n recheck: 'Force recheck',\n reannounce: 'Reannounce',\n set_label: 'Update label',\n label: 'Update label'\n },\n\n toast: {\n operationStarted: ({ action }) => `${actionLabel(action)} started`,\n\n operationDone: ({ action }) => `${actionLabel(action)} done`,\n\n operationFailed: ({ action, error }) =>\n `${actionLabel(action)} failed: ${error || 'unknown error'}`,\n\n actionQueued: ({ action, parts }) =>\n Number(parts || 1) > 1\n ? `${actionLabel(action)} queued in ${parts} parts`\n : `${actionLabel(action)} queued`,\n\n moveQueued: ({ parts, physical }) =>\n Number(parts || 1) > 1\n ? `Move queued in ${parts} parts`\n : physical\n ? 'Physical move queued'\n : 'Move queued',\n\n addQueued: () => 'Torrent add queued',\n\n addQueuedSkipped: ({ count }) =>\n `Torrent add queued, skipped ${count} duplicate torrent(s)`,\n\n addTooLarge: () =>\n 'One or more .torrent files exceed the current rTorrent XML-RPC upload limit. Open rTorrent config and set network.xmlrpc.size_limit to e.g. 16M.',\n\n dropOnlyTorrents: () => 'Drop .torrent files only',\n\n droppedAddedSkipped: ({ queued, skipped }) =>\n `Added ${queued} torrent(s), skipped ${skipped} duplicate(s)`,\n\n droppedAdded: ({ queued }) => `Added ${queued} torrent(s)`,\n\n droppedSkipped: ({ skipped }) =>\n `Skipped ${skipped} duplicate torrent(s)`,\n\n droppedNone: () => 'No torrents were added',\n\n noTorrentsSelected: () => 'No torrents selected',\n\n noTorrentSelected: () => 'No torrent selected',\n\n noFilesSelected: () => 'No files selected',\n\n downloadStarted: () => 'Download started',\n\n chunkActionDone: ({ action }) => `${actionLabel(action)} done`,\n\n trackerActionDone: ({ action }) => `${actionLabel(action)} done`,\n\n pathPickerUnavailable: () => 'Path picker is unavailable',\n\n pathEmpty: () => 'Path is empty',\n\n columnsSaved: () => 'Columns saved',\n\n recommendedColumnsApplied: () => 'Recommended columns applied',\n\n jobLogsCleared: ({ deleted }) =>\n `Cleared ${deleted || 0} finished job log(s)`,\n\n emergencyJobLogsCleared: ({ deleted }) =>\n `Emergency cleanup removed ${deleted || 0} job log(s)`,\n\n rtorrentConfigSaved: ({ updated }) =>\n `rTorrent config saved (${updated || 0})`,\n\n rtorrentConfigReset: ({ removed }) =>\n `rTorrent config reset (${removed || 0} override(s) removed)`,\n\n automationLogsDeleted: ({ deleted }) =>\n `Automation logs deleted: ${deleted || 0}`,\n\n cleanupDone: ({ deleted }) => `Cleanup done (${deleted})`,\n\n plannerApplied: ({ dryRun, paused, resumed, limitsChanged }) =>\n `${dryRun ? 'Planner dry-run' : 'Planner applied'}: paused ${paused || 0}, resumed ${resumed || 0}, limits ${limitsChanged ? 'changed' : 'unchanged'}`,\n\n rssQueued: ({ queued }) => `RSS queued ${queued || 0} item(s)`,\n\n smartQueueCheckQueued: () =>\n 'Smart Queue check queued. It will continue in the background.',\n\n automationForceRunDone: ({ count }) =>\n `Automation force run done (${count || 0} torrent item(s))`,\n\n automationsApplied: ({ count, batches }) =>\n batches\n ? `Automations applied ${count || 0} torrent(s) in ${batches || 0} batch(es)`\n : `Automations applied ${count || 0} item(s)`,\n\n torrentStatsError: ({ error }) => `Torrent stats: ${error}`,\n\n startupConfigApplied: ({ count }) =>\n `Startup rTorrent config applied (${count || 0})`,\n\n startupConfigFailed: ({ error }) =>\n `Startup rTorrent config: ${error}`,\n\n plannerSocketResult: ({ paused, resumed, dryRun }) =>\n `Planner: paused ${paused || 0}, resumed ${resumed || 0}${dryRun ? ' dry-run' : ''}`\n }\n };\n\n function actionLabel(action) {\n const key = String(action || '').trim();\n\n if (APP_MESSAGES.actions[key]) {\n return APP_MESSAGES.actions[key];\n }\n\n return key\n ? key.replace(/[_-]+/g, ' ').replace(/\\\\b\\\\w/g, (c) => c.toUpperCase())\n : 'Operation';\n }\n\n function appMessage(key, params = {}) {\n const fn = key\n .split('.')\n .reduce((acc, part) => acc && acc[part], APP_MESSAGES);\n\n return typeof fn === 'function' ? fn(params) : String(fn || key);\n }\n\n function toastMessage(key, type = 'secondary', params = {}) {\n toast(appMessage(key, params), type);\n }\n";
+export const messagesSource = "\n\n const APP_MESSAGES = {\n actions: {\n raw_torrent: 'Add torrent',\n add_torrent_raw: 'Add torrent file',\n add_magnet: 'Add magnet link',\n add: 'Add torrent',\n // Note: Start is intentionally noun-free so queued/running UI stays consistent with the other short action labels.\n start: 'Start',\n pause: 'Pause',\n stop: 'Stop',\n resume: 'Resume',\n remove: 'Remove',\n erase: 'Delete',\n delete: 'Delete',\n move: 'Move',\n profile_transfer: 'Move to another profile',\n recheck: 'Force recheck',\n reannounce: 'Reannounce',\n set_label: 'Update label',\n label: 'Update label'\n },\n\n toast: {\n operationStarted: ({ action }) => `${actionLabel(action)} started`,\n\n operationDone: ({ action }) => `${actionLabel(action)} done`,\n\n operationFailed: ({ action, error }) =>\n `${actionLabel(action)} failed: ${error || 'unknown error'}`,\n\n actionQueued: ({ action, parts }) =>\n Number(parts || 1) > 1\n ? `${actionLabel(action)} queued in ${parts} parts`\n : `${actionLabel(action)} queued`,\n\n moveQueued: ({ parts, physical }) =>\n Number(parts || 1) > 1\n ? `Move queued in ${parts} parts`\n : physical\n ? 'Physical move queued'\n : 'Move queued',\n\n addQueued: () => 'Torrent add queued',\n\n addQueuedSkipped: ({ count }) =>\n `Torrent add queued, skipped ${count} duplicate torrent(s)`,\n\n addTooLarge: () =>\n 'One or more .torrent files exceed the current rTorrent XML-RPC upload limit. Open rTorrent config and set network.xmlrpc.size_limit to e.g. 16M.',\n\n dropOnlyTorrents: () => 'Drop .torrent files only',\n\n droppedAddedSkipped: ({ queued, skipped }) =>\n `Added ${queued} torrent(s), skipped ${skipped} duplicate(s)`,\n\n droppedAdded: ({ queued }) => `Added ${queued} torrent(s)`,\n\n droppedSkipped: ({ skipped }) =>\n `Skipped ${skipped} duplicate torrent(s)`,\n\n droppedNone: () => 'No torrents were added',\n\n noTorrentsSelected: () => 'No torrents selected',\n\n noTorrentSelected: () => 'No torrent selected',\n\n noFilesSelected: () => 'No files selected',\n\n downloadStarted: () => 'Download started',\n\n chunkActionDone: ({ action }) => `${actionLabel(action)} done`,\n\n trackerActionDone: ({ action }) => `${actionLabel(action)} done`,\n\n pathPickerUnavailable: () => 'Path picker is unavailable',\n\n pathEmpty: () => 'Path is empty',\n\n columnsSaved: () => 'Columns saved',\n\n recommendedColumnsApplied: () => 'Recommended columns applied',\n\n jobLogsCleared: ({ deleted }) =>\n `Cleared ${deleted || 0} finished job log(s)`,\n\n emergencyJobLogsCleared: ({ deleted }) =>\n `Emergency cleanup removed ${deleted || 0} job log(s)`,\n\n rtorrentConfigSaved: ({ updated }) =>\n `rTorrent config saved (${updated || 0})`,\n\n rtorrentConfigReset: ({ removed }) =>\n `rTorrent config reset (${removed || 0} override(s) removed)`,\n\n automationLogsDeleted: ({ deleted }) =>\n `Automation logs deleted: ${deleted || 0}`,\n\n cleanupDone: ({ deleted }) => `Cleanup done (${deleted})`,\n\n plannerApplied: ({ dryRun, paused, resumed, limitsChanged }) =>\n `${dryRun ? 'Planner dry-run' : 'Planner applied'}: paused ${paused || 0}, resumed ${resumed || 0}, limits ${limitsChanged ? 'changed' : 'unchanged'}`,\n\n rssQueued: ({ queued }) => `RSS queued ${queued || 0} item(s)`,\n\n smartQueueCheckQueued: () =>\n 'Smart Queue check queued. It will continue in the background.',\n\n automationForceRunDone: ({ count }) =>\n `Automation force run done (${count || 0} torrent item(s))`,\n\n automationsApplied: ({ count, batches }) =>\n batches\n ? `Automations applied ${count || 0} torrent(s) in ${batches || 0} batch(es)`\n : `Automations applied ${count || 0} item(s)`,\n\n torrentStatsError: ({ error }) => `Torrent stats: ${error}`,\n\n startupConfigApplied: ({ count }) =>\n `Startup rTorrent config applied (${count || 0})`,\n\n startupConfigFailed: ({ error }) =>\n `Startup rTorrent config: ${error}`,\n\n plannerSocketResult: ({ paused, resumed, dryRun }) =>\n `Planner: paused ${paused || 0}, resumed ${resumed || 0}${dryRun ? ' dry-run' : ''}`\n }\n };\n\n function actionLabel(action) {\n const key = String(action || '').trim();\n\n if (APP_MESSAGES.actions[key]) {\n return APP_MESSAGES.actions[key];\n }\n\n return key\n ? key.replace(/[_-]+/g, ' ').replace(/\\\\b\\\\w/g, (c) => c.toUpperCase())\n : 'Operation';\n }\n\n function appMessage(key, params = {}) {\n const fn = key\n .split('.')\n .reduce((acc, part) => acc && acc[part], APP_MESSAGES);\n\n return typeof fn === 'function' ? fn(params) : String(fn || key);\n }\n\n function toastMessage(key, type = 'secondary', params = {}) {\n toast(appMessage(key, params), type);\n }\n";
diff --git a/pytorrent/static/js/pathPickerTools.js b/pytorrent/static/js/pathPickerTools.js
index 8932216..5386773 100644
--- a/pytorrent/static/js/pathPickerTools.js
+++ b/pytorrent/static/js/pathPickerTools.js
@@ -1 +1 @@
-export const pathPickerToolsSource = " function copyText(text){\n text=String(text ?? '');\n if(navigator.clipboard && window.isSecureContext){\n return navigator.clipboard.writeText(text);\n }\n return new Promise((resolve,reject)=>{\n const ta=document.createElement('textarea');\n ta.value=text; ta.setAttribute('readonly','');\n ta.style.position='fixed'; ta.style.left='-9999px'; ta.style.top='0';\n document.body.appendChild(ta); ta.focus(); ta.select();\n try{ document.execCommand('copy') ? resolve() : reject(new Error('copy command failed')); }\n catch(e){ reject(e); }\n finally{ ta.remove(); }\n });\n }\n function copySelected(field){\n const t=torrents.get(selectedHash);\n if(!t) return toast('No torrent selected','warning');\n const value=String(t[field] ?? '');\n if(!value) return toast(`No ${field} to copy`,'warning');\n copyText(value).then(()=>toast(`Copied ${field}`,'success')).catch(()=>toast('Copy failed','danger'));\n }\n\n async function getDefaultDownloadPath(){ if(defaultDownloadPath) return defaultDownloadPath; try{ const j=await (await fetch('/api/path/default')).json(); if(j.ok && j.path) defaultDownloadPath=j.path; }catch(e){} return defaultDownloadPath || '/'; }\n async function applyDefaultDownloadPath(force=false){ const p=await getDefaultDownloadPath(); ['addPath','rssPath','autoEffectPath'].forEach(id=>{ const el=$(id); if(el && (force || !el.value)) el.value=p; }); return p; }\n async function openPathPicker(target){\n pathTarget=target;\n const modal=$('pathModal');\n if(!modal) return toastMessage('toast.pathPickerUnavailable','danger');\n const def=await getDefaultDownloadPath();\n const initial=def || ($(target)?.value||'/');\n // Note: The same modal is used for Move and simple path selection; only Move shows extra options.\n $('moveOptions')?.classList.toggle('d-none', target!=='move');\n if($('moveDataPhysical')) $('moveDataPhysical').checked=true;\n if($('moveRecheck')) $('moveRecheck').checked=true;\n resetInlineDirectoryCreate();\n // Note: The path picker can be opened from Add/Create modals, so it must sit above the parent modal.\n modal.classList.toggle('path-picker-stacked', document.querySelectorAll('.modal.show').length > 0);\n new bootstrap.Modal(modal).show();\n browsePath(initial);\n }\n function pathInfoHtml(j){\n // Note: Move modal shows remote-side capacity and entry counts before queuing a move.\n const meta=[];\n if(j.free_h) meta.push(` Free ${esc(j.free_h)} `);\n if(j.used_percent!==undefined) meta.push(`${esc(j.used_percent)}% used `);\n if(j.dir_count!==undefined) meta.push(`${esc(j.dir_count)} dirs `);\n if(j.file_count!==undefined) meta.push(`${esc(j.file_count)} files `);\n return meta.length ? `${meta.join('')}
` : '';\n }\n function resetInlineDirectoryCreate(){\n const input=$('pathCreateName');\n if(input) input.value='';\n }\n function pathDirectoryRow(d){\n const disabled=!d.can_rename;\n const reason=d.rename_reason || (d.has_torrents?'Folder contains a known torrent path':(!d.empty?'Only empty folders can be renamed':'Rename folder'));\n // Note: Rename state comes from the backend so Add and Move share the same safety rules.\n return `${esc(d.name)}
`;\n }\n async function browsePath(path){\n const list=$('pathList');\n const current=$('pathCurrent');\n if(!list || !current) return;\n list.innerHTML=' Loading...';\n try{\n const res=await fetch(`/api/path/browse?path=${encodeURIComponent(path||'/')}`);\n const j=await res.json();\n if(!j.ok) throw new Error(j.error);\n current.value=j.path;\n lastPathParent=j.parent;\n const rows=j.dirs.map(pathDirectoryRow).join('')||'No directories.
';\n list.innerHTML=pathInfoHtml(j)+rows;\n }catch(e){\n list.innerHTML=`${esc(e.message)}
`;\n }\n }\n function inlineDirectoryName(value){\n value=String(value||'').trim();\n if(!value || value==='.' || value==='..' || value.includes('/')) throw new Error('Enter a valid folder name');\n return value;\n }\n async function createInlineDirectory(){\n try{\n const parent=($('pathCurrent')?.value||'').trim();\n const name=inlineDirectoryName($('pathCreateName')?.value||'');\n // Note: Inline create avoids an extra modal and leaves the current Add/Move context untouched.\n await post('/api/path/directories',{parent,name});\n resetInlineDirectoryCreate();\n toast('Directory created','success');\n await browsePath(parent);\n }catch(e){ toast(e.message,'danger'); }\n }\n function startInlineRename(row){\n if(!row || row.dataset.canRename!=='1') return;\n const oldName=row.dataset.name||'';\n row.classList.add('is-renaming');\n row.innerHTML=``;\n const input=row.querySelector('.path-rename-input');\n input?.focus();\n input?.select();\n }\n async function submitInlineRename(row){\n try{\n const input=row?.querySelector('.path-rename-input');\n const newName=inlineDirectoryName(input?.value||'');\n const path=row?.dataset.path||'';\n if(newName===(row?.dataset.name||'')) return browsePath($('pathCurrent')?.value);\n // Note: Rename is limited by the backend to empty folders with no cached torrent path inside.\n await post('/api/path/directories/rename',{path,new_name:newName});\n toast('Directory renamed','success');\n await browsePath($('pathCurrent')?.value);\n }catch(e){ toast(e.message,'danger'); }\n }\n $('pathList')?.addEventListener('click',e=>{\n const rename=e.target.closest('.path-rename-btn');\n if(rename){ e.preventDefault(); e.stopPropagation(); startInlineRename(rename.closest('.path-row')); return; }\n const cancel=e.target.closest('.path-rename-cancel');\n if(cancel){ e.preventDefault(); browsePath($('pathCurrent')?.value); return; }\n const open=e.target.closest('.path-row-open');\n if(open){ const r=open.closest('.path-row'); if(r) browsePath(r.dataset.path); }\n });\n $('pathList')?.addEventListener('submit',e=>{ const form=e.target.closest('.path-rename-form'); if(form){ e.preventDefault(); submitInlineRename(form.closest('.path-row')); } });\n $('pathCreateBtn')?.addEventListener('click',createInlineDirectory);\n $('pathCreateName')?.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); createInlineDirectory(); } });\n $('pathGoBtn')?.addEventListener('click',()=>browsePath($('pathCurrent')?.value));\n $('pathUpBtn')?.addEventListener('click',()=>browsePath(lastPathParent));\n $('pathReloadBtn')?.addEventListener('click',()=>browsePath($('pathCurrent')?.value));\n $('pathSelectBtn')?.addEventListener('click',async()=>{\n const p=($('pathCurrent')?.value||'').trim();\n if(!p) return toastMessage('toast.pathEmpty','warning');\n if(pathTarget==='move'){\n const hashes=selectedHashes();\n if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning');\n const j=await post('/api/torrents/move',{hashes,path:p,move_data:!!($('moveDataPhysical')?.checked),recheck:!!($('moveRecheck')?.checked)});\n markQueuedJobs(j,hashes,'move');\n const parts=Number(j.bulk_parts||1);\n toastMessage('toast.moveQueued','success',{parts,physical:$('moveDataPhysical')?.checked});\n } else if($(pathTarget)) {\n $(pathTarget).value=p;\n }\n bootstrap.Modal.getInstance($('pathModal'))?.hide();\n });\n document.querySelectorAll('.browse-path').forEach(b=>b.addEventListener('click',()=>openPathPicker(b.dataset.target)));\n";
+export const pathPickerToolsSource = " function copyText(text){\n text=String(text ?? '');\n if(navigator.clipboard && window.isSecureContext){\n return navigator.clipboard.writeText(text);\n }\n return new Promise((resolve,reject)=>{\n const ta=document.createElement('textarea');\n ta.value=text; ta.setAttribute('readonly','');\n ta.style.position='fixed'; ta.style.left='-9999px'; ta.style.top='0';\n document.body.appendChild(ta); ta.focus(); ta.select();\n try{ document.execCommand('copy') ? resolve() : reject(new Error('copy command failed')); }\n catch(e){ reject(e); }\n finally{ ta.remove(); }\n });\n }\n function copySelected(field){\n const t=torrents.get(selectedHash);\n if(!t) return toast('No torrent selected','warning');\n const value=String(t[field] ?? '');\n if(!value) return toast(`No ${field} to copy`,'warning');\n copyText(value).then(()=>toast(`Copied ${field}`,'success')).catch(()=>toast('Copy failed','danger'));\n }\n\n async function getDefaultDownloadPath(profileId=null){\n if(!profileId && defaultDownloadPath) return defaultDownloadPath;\n try{\n const qs=profileId?`?profile_id=${encodeURIComponent(profileId)}`:'';\n const j=await (await fetch(`/api/path/default${qs}`)).json();\n if(j.ok && j.path){ if(!profileId) defaultDownloadPath=j.path; return j.path; }\n }catch(e){}\n return defaultDownloadPath || '/';\n }\n async function applyDefaultDownloadPath(force=false){ const p=await getDefaultDownloadPath(); ['addPath','rssPath','autoEffectPath'].forEach(id=>{ const el=$(id); if(el && (force || !el.value)) el.value=p; }); return p; }\n async function openPathPicker(target, options={}){\n pathTarget=target;\n window.__pathPickerOptions = options || {};\n const modal=$('pathModal');\n if(!modal) return toastMessage('toast.pathPickerUnavailable','danger');\n const profileId=options.profileId || null;\n const def=await getDefaultDownloadPath(profileId);\n const initial=options.initialPath || ($(target)?.value||'') || def || '/';\n // Note: The same modal is used for Move and simple path selection; only Move shows extra options.\n $('moveOptions')?.classList.toggle('d-none', target!=='move');\n if($('moveDataPhysical')) $('moveDataPhysical').checked=true;\n if($('moveRecheck')) $('moveRecheck').checked=true;\n resetInlineDirectoryCreate();\n // Note: The path picker can be opened from Add/Create modals, so it must sit above the parent modal.\n modal.classList.toggle('path-picker-stacked', document.querySelectorAll('.modal.show').length > 0);\n new bootstrap.Modal(modal).show();\n browsePath(initial);\n }\n function pathInfoHtml(j){\n // Note: Move modal shows remote-side capacity and entry counts before queuing a move.\n const meta=[];\n if(j.free_h) meta.push(` Free ${esc(j.free_h)} `);\n if(j.used_percent!==undefined) meta.push(`${esc(j.used_percent)}% used `);\n if(j.dir_count!==undefined) meta.push(`${esc(j.dir_count)} dirs `);\n if(j.file_count!==undefined) meta.push(`${esc(j.file_count)} files `);\n return meta.length ? `${meta.join('')}
` : '';\n }\n function resetInlineDirectoryCreate(){\n const input=$('pathCreateName');\n if(input) input.value='';\n }\n function pathDirectoryRow(d){\n const disabled=!d.can_rename;\n const reason=d.rename_reason || (d.has_torrents?'Folder contains a known torrent path':(!d.empty?'Only empty folders can be renamed':'Rename folder'));\n // Note: Rename state comes from the backend so Add and Move share the same safety rules.\n return `${esc(d.name)}
`;\n }\n async function browsePath(path){\n const list=$('pathList');\n const current=$('pathCurrent');\n if(!list || !current) return;\n list.innerHTML=' Loading...';\n try{\n const profileId=window.__pathPickerOptions?.profileId || '';\n const qs=`path=${encodeURIComponent(path||'/')}${profileId?`&profile_id=${encodeURIComponent(profileId)}`:''}`;\n const res=await fetch(`/api/path/browse?${qs}`);\n const j=await res.json();\n if(!j.ok) throw new Error(j.error);\n current.value=j.path;\n lastPathParent=j.parent;\n const rows=j.dirs.map(pathDirectoryRow).join('')||'No directories.
';\n list.innerHTML=pathInfoHtml(j)+rows;\n }catch(e){\n list.innerHTML=`${esc(e.message)}
`;\n }\n }\n function inlineDirectoryName(value){\n value=String(value||'').trim();\n if(!value || value==='.' || value==='..' || value.includes('/')) throw new Error('Enter a valid folder name');\n return value;\n }\n async function createInlineDirectory(){\n try{\n const parent=($('pathCurrent')?.value||'').trim();\n const name=inlineDirectoryName($('pathCreateName')?.value||'');\n // Note: Inline create avoids an extra modal and leaves the current Add/Move context untouched.\n await post('/api/path/directories',{parent,name,profile_id:window.__pathPickerOptions?.profileId||null});\n resetInlineDirectoryCreate();\n toast('Directory created','success');\n await browsePath(parent);\n }catch(e){ toast(e.message,'danger'); }\n }\n function startInlineRename(row){\n if(!row || row.dataset.canRename!=='1') return;\n const oldName=row.dataset.name||'';\n row.classList.add('is-renaming');\n row.innerHTML=``;\n const input=row.querySelector('.path-rename-input');\n input?.focus();\n input?.select();\n }\n async function submitInlineRename(row){\n try{\n const input=row?.querySelector('.path-rename-input');\n const newName=inlineDirectoryName(input?.value||'');\n const path=row?.dataset.path||'';\n if(newName===(row?.dataset.name||'')) return browsePath($('pathCurrent')?.value);\n // Note: Rename is limited by the backend to empty folders with no cached torrent path inside.\n await post('/api/path/directories/rename',{path,new_name:newName,profile_id:window.__pathPickerOptions?.profileId||null});\n toast('Directory renamed','success');\n await browsePath($('pathCurrent')?.value);\n }catch(e){ toast(e.message,'danger'); }\n }\n $('pathList')?.addEventListener('click',e=>{\n const rename=e.target.closest('.path-rename-btn');\n if(rename){ e.preventDefault(); e.stopPropagation(); startInlineRename(rename.closest('.path-row')); return; }\n const cancel=e.target.closest('.path-rename-cancel');\n if(cancel){ e.preventDefault(); browsePath($('pathCurrent')?.value); return; }\n const open=e.target.closest('.path-row-open');\n if(open){ const r=open.closest('.path-row'); if(r) browsePath(r.dataset.path); }\n });\n $('pathList')?.addEventListener('submit',e=>{ const form=e.target.closest('.path-rename-form'); if(form){ e.preventDefault(); submitInlineRename(form.closest('.path-row')); } });\n $('pathCreateBtn')?.addEventListener('click',createInlineDirectory);\n $('pathCreateName')?.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); createInlineDirectory(); } });\n $('pathGoBtn')?.addEventListener('click',()=>browsePath($('pathCurrent')?.value));\n $('pathUpBtn')?.addEventListener('click',()=>browsePath(lastPathParent));\n $('pathReloadBtn')?.addEventListener('click',()=>browsePath($('pathCurrent')?.value));\n $('pathSelectBtn')?.addEventListener('click',async()=>{\n const p=($('pathCurrent')?.value||'').trim();\n if(!p) return toastMessage('toast.pathEmpty','warning');\n if(pathTarget==='move'){\n const hashes=selectedHashes();\n if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning');\n const j=await post('/api/torrents/move',{hashes,path:p,move_data:!!($('moveDataPhysical')?.checked),recheck:!!($('moveRecheck')?.checked)});\n markQueuedJobs(j,hashes,'move');\n const parts=Number(j.bulk_parts||1);\n toastMessage('toast.moveQueued','success',{parts,physical:$('moveDataPhysical')?.checked});\n } else if($(pathTarget)) {\n $(pathTarget).value=p;\n if(typeof window.__pathPickerOptions?.onSelect === 'function') window.__pathPickerOptions.onSelect(p);\n }\n bootstrap.Modal.getInstance($('pathModal'))?.hide();\n });\n document.querySelectorAll('.browse-path').forEach(b=>b.addEventListener('click',()=>openPathPicker(b.dataset.target)));\n document.addEventListener('click',e=>{\n const btn=e.target.closest('[data-profile-transfer-browse]');\n if(!btn) return;\n const profileId=Number($('profileTransferTargetId')?.value||0);\n if(!profileId) return toast('Choose target profile first.', 'warning');\n openPathPicker('profileTransferTargetPath',{profileId, initialPath:($('profileTransferTargetPath')?.value||''), onSelect:()=>validateProfileTransferSelection()});\n });\n";
diff --git a/pytorrent/static/js/profileSelection.js b/pytorrent/static/js/profileSelection.js
index 6ff272f..08d0d46 100644
--- a/pytorrent/static/js/profileSelection.js
+++ b/pytorrent/static/js/profileSelection.js
@@ -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 = `Select an rTorrent profile. ${esc(count)} profile(s) are configured for this trusted bypass session. Choose which one to open. Choose profile
`;\n }\n const list = $('mobileList');\n if(list) list.innerHTML = `Select an rTorrent profile. Choose a profile to load torrents.
`;\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?`${parts.map(x=>`${esc(x)} `).join('')}
`:'';\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 `${esc(p.name||('rTorrent '+id))} #${esc(id)}${id===activeId?' \u00b7 active':''}
${profileRuntimeStatsHtml(p.runtime_stats)} `;\n }).join('') || 'No profiles configured.
';\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 = `Select an rTorrent profile. ${esc(count)} profile(s) are configured for this trusted bypass session. Choose which one to open. Choose profile
`;\n }\n const list = $('mobileList');\n if(list) list.innerHTML = `Select an rTorrent profile. Choose a profile to load torrents.
`;\n if($('detailPane')) $('detailPane').innerHTML = 'Choose an rTorrent profile to load details.';\n }\n\n function formatProfileCachedAt(value){\n if(!value) return '';\n if(typeof value==='number') return formatDateTime(value);\n const text=String(value||'').trim();\n const numeric=Number(text);\n if(Number.isFinite(numeric) && numeric>0) return formatDateTime(numeric);\n const normalized=text.includes('T')?text:text.replace(' ', 'T');\n const dt=new Date(normalized.endsWith('Z')?normalized:`${normalized}Z`);\n if(Number.isNaN(dt.getTime())) return '';\n return dt.toLocaleString();\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 const cachedAt=formatProfileCachedAt(stats.updated_at);\n if(cachedAt) parts.push(`cached ${cachedAt}`);\n return parts.length?`${parts.map(x=>`${esc(x)} `).join('')}
`:'';\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 `${esc(p.name||('rTorrent '+id))} #${esc(id)}${id===activeId?' \u00b7 active':''}
${profileRuntimeStatsHtml(p.runtime_stats)} `;\n }).join('') || 'No profiles configured.
';\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";
diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html
index c6ed0c1..cd6f143 100644
--- a/pytorrent/templates/index.html
+++ b/pytorrent/templates/index.html
@@ -197,7 +197,7 @@
@@ -387,7 +387,7 @@
-
+