diff --git a/pytorrent/services/automation_rules.py b/pytorrent/services/automation_rules.py
index 0e92382..3bdc81d 100644
--- a/pytorrent/services/automation_rules.py
+++ b/pytorrent/services/automation_rules.py
@@ -137,8 +137,11 @@ def _apply_effects(c: Any, profile: dict[str, Any], torrent: dict[str, Any], eff
for eff in effects:
typ = str(eff.get('type') or '')
if typ == 'move':
+ # Note: Automation move-to-path now uses the same move implementation as the main app action.
path = str(eff.get('path') or '').strip() or rtorrent.default_download_path(profile)
- if path: c.call('d.directory.set', h, path); applied.append({'type': 'move', 'path': path})
+ move_payload = {'path': path, 'move_data': bool(eff.get('move_data')), 'recheck': bool(eff.get('recheck', eff.get('move_data'))), 'keep_seeding': bool(eff.get('keep_seeding'))}
+ result = rtorrent.move_torrents(profile, [h], move_payload) if path else None
+ if path: applied.append({'type': 'move', 'path': path, 'move_data': bool(eff.get('move_data')), 'recheck': bool(move_payload['recheck']), 'keep_seeding': bool(eff.get('keep_seeding')), 'result': result})
elif typ == 'add_label':
label = str(eff.get('label') or '').strip()
if label and label not in labels: labels.append(label); c.call('d.custom1.set', h, _label_value(labels))
diff --git a/pytorrent/services/rtorrent.py b/pytorrent/services/rtorrent.py
index fc43125..68632f8 100644
--- a/pytorrent/services/rtorrent.py
+++ b/pytorrent/services/rtorrent.py
@@ -1274,6 +1274,73 @@ def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
result['ok'] = result.get('ok', True)
return result
+
+def move_torrents(profile: dict, torrent_hashes: list[str], payload: dict | None = None) -> dict:
+ # Note: Shared move implementation keeps API move and automation move-to-path identical.
+ payload = payload or {}
+ c = client_for(profile)
+ path = _remote_clean_path(payload.get("path") or "")
+ move_data = bool(payload.get("move_data"))
+ recheck = bool(payload.get("recheck", move_data))
+ keep_seeding = bool(payload.get("keep_seeding"))
+ # Note: keep_seeding lets automation move completed data to another path and force the torrent back into seeding.
+ if not path:
+ raise ValueError("Missing path")
+ results = []
+ if move_data:
+ _rt_execute_allow_timeout(c, "execute.throw", "mkdir", "-p", path)
+ for h in torrent_hashes:
+ item = {"hash": h, "path": path, "move_data": move_data, "keep_seeding": keep_seeding}
+ try:
+ was_state = int(c.call("d.state", h) or 0)
+ except Exception:
+ was_state = 0
+ try:
+ was_active = int(c.call("d.is_active", h) or 0)
+ except Exception:
+ was_active = was_state
+ if move_data:
+ src = _remote_clean_path(_torrent_data_path(c, h))
+ if not src:
+ raise ValueError(f"Cannot determine source path for {h}")
+ dst = _remote_join(path, posixpath.basename(src.rstrip("/")))
+ if src != dst:
+ try:
+ c.call("d.stop", h)
+ except Exception:
+ pass
+ try:
+ c.call("d.close", h)
+ except Exception:
+ pass
+ _run_remote_move(c, src, dst)
+ item["moved_from"] = src
+ item["moved_to"] = dst
+ else:
+ item["skipped"] = "source and destination are the same"
+ c.call("d.directory.set", h, path)
+ if recheck:
+ try:
+ c.call("d.check_hash", h)
+ except Exception as exc:
+ item["recheck_error"] = str(exc)
+ if keep_seeding or was_state or was_active:
+ try:
+ c.call("d.start", h)
+ item["started_after_move"] = True
+ except Exception as exc:
+ item["start_error"] = str(exc)
+ else:
+ c.call("d.directory.set", h, path)
+ if keep_seeding:
+ try:
+ c.call("d.start", h)
+ item["started_after_path_change"] = True
+ except Exception as exc:
+ item["start_error"] = str(exc)
+ results.append(item)
+ return {"ok": True, "count": len(torrent_hashes), "move_data": move_data, "keep_seeding": keep_seeding, "results": results}
+
def action(profile: dict, torrent_hashes: list[str], name: str, payload: dict | None = None) -> dict:
payload = payload or {}
c = client_for(profile)
@@ -1294,58 +1361,8 @@ def action(profile: dict, torrent_hashes: list[str], name: str, payload: dict |
c.call("d.custom.set", h, "py_ratio_group", group)
return {"ok": True, "count": len(torrent_hashes), "ratio_group": group}
if name == "move":
- path = _remote_clean_path(payload.get("path") or "")
- move_data = bool(payload.get("move_data"))
- recheck = bool(payload.get("recheck", move_data))
- if not path:
- raise ValueError("Missing path")
- results = []
- if move_data:
- _rt_execute_allow_timeout(c, "execute.throw", "mkdir", "-p", path)
- for h in torrent_hashes:
- item = {"hash": h, "path": path, "move_data": move_data}
- try:
- was_state = int(c.call("d.state", h) or 0)
- except Exception:
- was_state = 0
- try:
- was_active = int(c.call("d.is_active", h) or 0)
- except Exception:
- was_active = was_state
- if move_data:
- src = _remote_clean_path(_torrent_data_path(c, h))
- if not src:
- raise ValueError(f"Cannot determine source path for {h}")
- dst = _remote_join(path, posixpath.basename(src.rstrip("/")))
- if src != dst:
- try:
- c.call("d.stop", h)
- except Exception:
- pass
- try:
- c.call("d.close", h)
- except Exception:
- pass
- _run_remote_move(c, src, dst)
- item["moved_from"] = src
- item["moved_to"] = dst
- else:
- item["skipped"] = "source and destination are the same"
- c.call("d.directory.set", h, path)
- if recheck:
- try:
- c.call("d.check_hash", h)
- except Exception as exc:
- item["recheck_error"] = str(exc)
- if was_state or was_active:
- try:
- c.call("d.start", h)
- except Exception:
- pass
- else:
- c.call("d.directory.set", h, path)
- results.append(item)
- return {"ok": True, "count": len(torrent_hashes), "move_data": move_data, "results": results}
+ # Note: Main move delegates to the shared helper used by automations.
+ return move_torrents(profile, torrent_hashes, payload)
if name == "pause":
# Note: The app pause action is now a pure d.pause so later resume works without stop/start.
results = [pause_hash(c, h) for h in torrent_hashes]
diff --git a/pytorrent/static/app.js b/pytorrent/static/app.js
index be14d24..76cd8af 100644
--- a/pytorrent/static/app.js
+++ b/pytorrent/static/app.js
@@ -525,12 +525,20 @@
function syncMobileMode(){ const auto=window.matchMedia&&window.matchMedia("(max-width: 900px)").matches; document.body.classList.toggle("mobile-mode", auto || document.body.classList.contains("mobile-mode-manual")); scheduleRender(true); }
+ // Note: Builder queues allow many conditions and many ordered actions in one automation without changing old single-step saves.
+ let automationConditionQueue=[];
+ let automationEffectQueue=[];
function automationCondition(){ const type=$('autoConditionType')?.value||'completed'; const cond={type}; if(type==='no_seeds'){cond.seeds=Number($('autoCondSeeds')?.value||0);cond.minutes=Number($('autoCondMinutes')?.value||0);} if(type==='ratio_gte')cond.ratio=Number($('autoCondRatio')?.value||1); if(type==='label_missing'||type==='label_has')cond.label=$('autoCondLabel')?.value||''; if(type==='status')cond.status=$('autoCondStatus')?.value||'Seeding'; if(type==='path_contains')cond.text=$('autoCondText')?.value||''; return cond; }
- function automationEffect(){ const type=$('autoEffectType')?.value||'add_label'; const eff={type}; if(type==='move')eff.path=$('autoEffectPath')?.value||''; if(type==='add_label'||type==='remove_label')eff.label=$('autoEffectLabel')?.value||''; if(type==='set_labels')eff.labels=$('autoEffectLabels')?.value||''; return eff; }
+ function automationEffect(){ const type=$('autoEffectType')?.value||'add_label'; const eff={type}; if(type==='move'){eff.path=$('autoEffectPath')?.value||''; eff.move_data=!!($('autoMoveDataPhysical')?.checked); eff.recheck=!!($('autoMoveRecheck')?.checked); eff.keep_seeding=!!($('autoMoveKeepSeeding')?.checked);} if(type==='add_label'||type==='remove_label')eff.label=$('autoEffectLabel')?.value||''; if(type==='set_labels')eff.labels=$('autoEffectLabels')?.value||''; return eff; }
function updateAutomationForm(){ const ct=$('autoConditionType')?.value||''; document.querySelectorAll('[data-auto-cond]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoCond.split(',').includes(ct))); const et=$('autoEffectType')?.value||''; document.querySelectorAll('[data-auto-effect]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoEffect.split(',').includes(et))); }
- function ruleSummary(r){ const cs=(r.conditions||[]).map(c=>c.type==='no_seeds'?`no seeds <=${c.seeds||0} for ${c.minutes||0} min`:c.type==='ratio_gte'?`ratio >= ${c.ratio}`: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').join(' + '); const es=(r.effects||[]).map(e=>e.type==='move'?`move to ${e.path||'default path'}`: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).join(' + '); return `${cs} → ${es}`; }
- async function loadAutomations(){ const j=await (await fetch('/api/automations')).json(); const rules=j.rules||[], hist=j.history||[]; if($('automationManager')) $('automationManager').innerHTML=rules.length?rules.map(r=>`
${esc(r.name)} ${r.enabled?'
on':'
off'}
${esc(ruleSummary(r))} · cooldown ${esc(r.cooldown_minutes||0)} min
`).join(''):'No automation rules.
'; if($('automationHistory')) $('automationHistory').innerHTML=hist.length?table(['Time','Rule','Torrent','Actions'],hist.map(h=>[esc(h.created_at),esc(h.rule_name||''),esc(h.torrent_name||h.torrent_hash||''),esc(h.actions_json||'')])):'No automation history yet.
'; }
- async function saveAutomation(){ const payload={name:$('autoName')?.value||'Automation rule',enabled:!!$('autoEnabled')?.checked,cooldown_minutes:Number($('autoCooldown')?.value||60),conditions:[automationCondition()],effects:[automationEffect()]}; setBusy(true); try{ await post('/api/automations',payload); toast('Automation rule saved','success'); await loadAutomations(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }
+ function conditionSummary(c){ return c.type==='no_seeds'?`no seeds <=${c.seeds||0} for ${c.minutes||0} min`:c.type==='ratio_gte'?`ratio >= ${c.ratio}`: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'; }
+ function effectSummary(e){ return e.type==='move'?`move to ${e.path||'default path'}${e.move_data?' + data move':''}${e.keep_seeding?' + keep seeding':''}${e.recheck?' + recheck':''}`: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; }
+ function renderAutomationBuilder(){ const cbox=$('autoConditionsList'), ebox=$('autoEffectsList'); if(cbox) cbox.innerHTML=automationConditionQueue.length?automationConditionQueue.map((c,i)=>`${esc(i+1)}. ${esc(conditionSummary(c))}
`).join(''):'No extra conditions added; current condition will be used on save.
'; if(ebox) ebox.innerHTML=automationEffectQueue.length?automationEffectQueue.map((e,i)=>`${esc(i+1)}. ${esc(effectSummary(e))}
`).join(''):'No action sequence added; current action will be used on save.
'; }
+ function addAutomationCondition(){ automationConditionQueue.push(automationCondition()); renderAutomationBuilder(); }
+ function addAutomationEffect(){ automationEffectQueue.push(automationEffect()); renderAutomationBuilder(); }
+ function ruleSummary(r){ const cs=(r.conditions||[]).map(conditionSummary).join(' + '); const es=(r.effects||[]).map(effectSummary).join(' → '); return `${cs} → ${es}`; }
+ async function loadAutomations(){ const j=await (await fetch('/api/automations')).json(); const rules=j.rules||[], hist=j.history||[]; if($('automationManager')) $('automationManager').innerHTML=rules.length?rules.map(r=>`${esc(r.name)} ${r.enabled?'
on':'
off'}
${esc(ruleSummary(r))} · cooldown ${esc(r.cooldown_minutes||0)} min
`).join(''):'No automation rules.
'; if($('automationHistory')) $('automationHistory').innerHTML=hist.length?table(['Time','Rule','Torrent','Actions'],hist.map(h=>[esc(h.created_at),esc(h.rule_name||''),esc(h.torrent_name||h.torrent_hash||''),esc(h.actions_json||'')])):'No automation history yet.
'; renderAutomationBuilder(); }
+ async function saveAutomation(){ const conditions=automationConditionQueue.length?automationConditionQueue:[automationCondition()]; const effects=automationEffectQueue.length?automationEffectQueue:[automationEffect()]; const payload={name:$('autoName')?.value||'Automation rule',enabled:!!$('autoEnabled')?.checked,cooldown_minutes:Number($('autoCooldown')?.value||60),conditions,effects}; setBusy(true); try{ await post('/api/automations',payload); automationConditionQueue=[]; automationEffectQueue=[]; renderAutomationBuilder(); toast('Automation rule saved','success'); await loadAutomations(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }
function cleanupCountCard(label, value, note=''){
@@ -682,7 +690,7 @@
}
$('toolsModal')?.addEventListener('show.bs.modal',()=>{refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadAppStatus();loadPreferences();loadAuthUsers();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',torrentstats:'toolTorrentStats',preferences:'toolPreferences',users:'toolUsers',labels:'toolLabels',ratio:'toolRatio',rss:'toolRss',columns:'toolColumns',smart:'toolSmart',automations:'toolAutomations',rtconfig:'toolRtconfig',cleanup:'toolCleanup',appstatus:'toolAppstatus'}; const hideToolPanels=()=>Object.values(toolPanelIds).filter((v,i,a)=>a.indexOf(v)===i).forEach(id=>$(id)?.classList.add('d-none')); const showToolPanel=tool=>{hideToolPanels(); $(toolPanelIds[tool]||'toolRtorrents')?.classList.remove('d-none');}; const activateToolTab=tool=>{document.querySelectorAll('.tool-tab').forEach(x=>x.classList.toggle('active',(x.dataset.tool||'rtorrents')===tool)); showToolPanel(tool); if(tool==='torrentstats') loadTorrentStats(false); if(tool==='appstatus') loadAppStatus(); if(tool==='cleanup') loadCleanup(); if(tool==='preferences') loadPreferences(); if(tool==='users') loadAuthUsers();}; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); $('torrentStatsRefreshBtn')?.addEventListener('click',()=>loadTorrentStats(true)); $('authUserSaveBtn')?.addEventListener('click',saveAuthUser); $('authUserCancelBtn')?.addEventListener('click',resetAuthUserForm); $('authUsersManager')?.addEventListener('click',async e=>{ const edit=e.target.closest('.auth-edit'); const del=e.target.closest('.auth-delete'); if(edit){ editAuthUser(JSON.parse(edit.dataset.user||'{}')); return; } if(del && confirm('Delete user?')){ await fetch(`/api/auth/users/${del.dataset.id}`,{method:'DELETE'}); loadAuthUsers(); } }); $('rssFeedBtn')?.addEventListener('click',async()=>{await post('/api/rss/feeds',{name:$('rssName').value,url:$('rssUrl').value}); loadRss();}); $('rssRuleBtn')?.addEventListener('click',async()=>{await post('/api/rss/rules',{name:$('rssRuleName').value,pattern:$('rssPattern').value,save_path:$('rssPath').value,label:$('rssLabel').value}); loadRss();}); $('rssCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/rss/check',{}); toast(`RSS queued ${j.queued} item(s)`,'success');}catch(e){toast(e.message,'danger');} finally{setBusy(false);}}); $('smartSaveBtn')?.addEventListener('click',saveSmartQueue); $('smartCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/smart-queue/check',{}); const r=j.result||{}; if(j.torrent_patch) patchRows(j.torrent_patch); const noEffect=r.start_no_effect?.length||0; const requested=r.resume_requested?.length||0; const cap=r.rtorrent_cap?.updated?`, cap ${r.rtorrent_cap.current}->${r.rtorrent_cap.new}`:''; const waiting=r.waiting_labeled||0; const tail=noEffect?`, no effect ${noEffect}`:requested?`, requested ${requested}`:''; const waitTail=waiting?`, waiting labeled ${waiting}`:''; toast(`Smart Queue: paused ${r.paused?.length||0}, resumed ${r.resumed?.length||0}${tail}${waitTail}${cap}`,'success'); await loadSmartQueue();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('smartManager')?.addEventListener('click',async e=>{const h=e.target.closest('.smart-unexclude')?.dataset.hash; if(!h)return; await post('/api/smart-queue/exclusion',{hash:h,excluded:false}); await loadSmartQueue();}); $('cleanupManager')?.addEventListener('click',async e=>{ if(e.target.closest('#cleanupRefreshBtn')) return loadCleanup(); if(e.target.closest('#cleanupJobsBtn')) return runCleanupAction('/api/cleanup/jobs','Clear finished job logs'); if(e.target.closest('#cleanupSmartQueueBtn')) return runCleanupAction('/api/cleanup/smart-queue','Clear Smart Queue logs'); if(e.target.closest('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear job and Smart Queue logs'); }); $('rtConfigReloadBtn')?.addEventListener('click',loadRtConfig); $('rtConfigSaveBtn')?.addEventListener('click',saveRtConfig); $('rtConfigGenerateBtn')?.addEventListener('click',generateRtConfig); $('rtConfigManager')?.addEventListener('input',e=>{ if(e.target.classList.contains('rt-config-input')) updateRtConfigDirty(); }); $('rtConfigManager')?.addEventListener('change',e=>{ if(e.target.classList.contains('rt-config-input')){ const label=e.target.closest('.rt-config-switch')?.querySelector('.form-check-label'); if(label) label.textContent=e.target.checked?'On':'Off'; updateRtConfigDirty(); } }); $('rtConfigApplyOnStart')?.addEventListener('change',updateRtConfigDirty); $('peersRefreshSelect')?.addEventListener('change',async e=>{peersRefreshSeconds=Number(e.target.value||0); await post('/api/preferences',{peers_refresh_seconds:peersRefreshSeconds}).catch(()=>{}); setupPeersRefresh(activeTab()); toast('Peers refresh preference saved','success');});
- $('autoConditionType')?.addEventListener('change',updateAutomationForm); $('autoEffectType')?.addEventListener('change',updateAutomationForm); $('automationSaveBtn')?.addEventListener('click',saveAutomation); $('automationCheckBtn')?.addEventListener('click',async()=>{setBusy(true);try{const j=await post('/api/automations/check',{}); toast(`Automations applied ${j.result?.applied?.length||0} item(s)`,'success'); await loadAutomations();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('automationManager')?.addEventListener('click',async e=>{const id=e.target.closest('.automation-delete')?.dataset.id;if(!id)return;if(!confirm('Delete this automation rule?'))return;const r=await fetch('/api/automations/'+id,{method:'DELETE'});const j=await r.json();if(!j.ok)toast(j.error||'Delete failed','danger');await loadAutomations();});
+ $('autoConditionType')?.addEventListener('change',updateAutomationForm); $('autoEffectType')?.addEventListener('change',updateAutomationForm); $('autoAddConditionBtn')?.addEventListener('click',addAutomationCondition); $('autoAddEffectBtn')?.addEventListener('click',addAutomationEffect); $('automationSaveBtn')?.addEventListener('click',saveAutomation); $('automationCheckBtn')?.addEventListener('click',async()=>{setBusy(true);try{const j=await post('/api/automations/check',{}); toast(`Automations applied ${j.result?.applied?.length||0} item(s)`,'success'); await loadAutomations();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('automationManager')?.addEventListener('click',async e=>{const id=e.target.closest('.automation-delete')?.dataset.id;if(!id)return;if(!confirm('Delete this automation rule?'))return;const r=await fetch('/api/automations/'+id,{method:'DELETE'});const j=await r.json();if(!j.ok)toast(j.error||'Delete failed','danger');await loadAutomations();}); $('autoConditionsList')?.addEventListener('click',e=>{const btn=e.target.closest('.auto-remove-condition'); if(!btn)return; automationConditionQueue.splice(Number(btn.dataset.index||0),1); renderAutomationBuilder();}); $('autoEffectsList')?.addEventListener('click',e=>{const btn=e.target.closest('.auto-remove-effect'); if(!btn)return; automationEffectQueue.splice(Number(btn.dataset.index||0),1); renderAutomationBuilder();});
document.addEventListener('click',async e=>{ const btn=e.target.closest('.delete-label'); if(!btn)return; if(!confirm('Delete this label?')) return; setBusy(true); try{ const r=await fetch('/api/labels/'+btn.dataset.id,{method:'DELETE'}); const j=await r.json(); if(!j.ok) throw new Error(j.error||'Delete failed'); await loadLabels(); toast('Label deleted','success'); }catch(err){toast(err.message,'danger');} finally{setBusy(false);} });
$('bulkClearBtn')?.addEventListener('click',()=>{selected.clear(); selectedHash=null; lastSelectedHash=null; updateBulkBar(); if($('selectAll')) $('selectAll').checked=false; if($('detailPane')) $('detailPane').innerHTML='Select a torrent.'; setupPeersRefresh('general'); scheduleRender(true);});
$('smartExcludeSelectedBtn')?.addEventListener('click',()=>setSmartException(selectedHashes(),true,'manual'));
diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css
index c9161f3..b64de87 100644
--- a/pytorrent/static/styles.css
+++ b/pytorrent/static/styles.css
@@ -1244,6 +1244,30 @@ body.mobile-mode .mobile-card {
gap: 0.5rem;
align-items: center;
}
+
+.auto-move-option {
+ gap: 0.45rem;
+ margin: 0;
+}
+
+
+.automation-builder-list {
+ display: grid;
+ grid-column: 1 / -1;
+ gap: 0.4rem;
+}
+
+.automation-chip {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ padding: 0.4rem 0.55rem;
+ border: 1px solid var(--bs-border-color);
+ border-radius: 0.55rem;
+ background: var(--bs-secondary-bg);
+}
+
.automation-row {
display: flex;
justify-content: space-between;
diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html
index ba4fd4a..632bda9 100644
--- a/pytorrent/templates/index.html
+++ b/pytorrent/templates/index.html
@@ -148,7 +148,7 @@
-
+