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 ``;\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=>``).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=>``).join('')}${rows.map(r=>`${r.map(c=>``).join('')}`).join('')}
${esc(h)}
${c}
`; }\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 ``;\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=>``).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=>``).join('')}${rows.map(r=>`${r.map(c=>``).join('')}`).join('')}
${esc(h)}
${c}
`; }\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='
';\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
`;\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='
';\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
`;\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 `
`;\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 `
`;\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.
`;\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 ``;\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.
`;\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 ``;\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 @@
Destination profile
Loading profiles...
- +
Choose a profile to load safe target roots.
@@ -208,7 +208,7 @@ - +
Select a target profile to see destination disk space.
Backend validation decides whether data files can be moved. If not, only torrent metadata will be transferred.
@@ -387,7 +387,7 @@
Feeds, rules and matches
Choose columns visible in the torrent list.
Smart Queue
Automatic queue balancing for slow or stalled downloads.
Run Smart Queue during polling. Stopped torrents are managed; Paused torrents stay user-controlled.
When enabled, Smart Queue disables itself after a check finds no active downloads and no waiting stopped candidates. Enable it again manually when you add more work.
Next Smart Queue runnext: readyAutomatic runs use the cooldown below. Manual Check now still runs immediately.
Queue refill during cooldownnext: readyAutomatic keeps the current poller cadence. Custom runs only after the selected number of minutes. Off disables refill completely.
Periodically start a large batch above the active-download target. Normal Smart Queue checks keep replacing stalled items and drain overflow back toward the target.
Next Surge refill runnext: readyAutomatic Surge refill uses the interval below. Manual Smart Queue check still ignores this timer.
Recommended for best efficiency. When enabled, Smart Queue refills empty slots first and does not stop stalled downloads while active downloads are below the cap. Stalled cleanup resumes once the cap is reached or exceeded. Disable only if you prefer aggressive cleanup over keeping the active count near the cap.
Start stopped torrents with existing progress first, so Smart Queue finishes already started downloads before opening fresh ones.
When enabled, Smart Queue does not use seed/peer count as a stalled criterion.
When enabled, low speed is not required. With source and speed ignores enabled, only Stalled after seconds decides.
Choose torrents ignored by Smart Queue. Existing behavior stays unchanged for all non-excluded torrents.
Last operations
-
Automations / rules
Build a rule as: conditions first, then ordered actions. Matching torrents are handled as one batch and the cooldown is applied to the whole rule.
1. Rule
2. Conditions
3. Actions, in order
Rules
History
+
Automations / rules
Build a rule as: conditions first, then ordered actions. Matching torrents are handled as one batch and the cooldown is applied to the whole rule.
1. Rule
2. Conditions
3. Actions, in order
Rules
History
rTorrent config
Grouped rTorrent runtime settings with inline recommendations and compatibility status.
Reference value is kept from the first override save. Later saves add or clear differences without replacing the original reference.
No changes
Loading config...
Cleanup / retention
One place to clear logs and active profile caches. Pending/running jobs, rules, settings and torrents are preserved.
Loading cleanup data...
Backup / restore
Profile backup restores only the active profile context. Application backup restores global application data and is available only to admins.
Creates and restores settings for the currently selected profile. User-scoped preferences are remapped to the current user where needed.
Admin-only full application backup. Restore can replace users, permissions, profiles and global application settings.