diff --git a/pytorrent/static/app.js b/pytorrent/static/app.js index ac59165..717a176 100644 --- a/pytorrent/static/app.js +++ b/pytorrent/static/app.js @@ -528,17 +528,22 @@ // Note: Builder queues allow many conditions and many ordered actions in one automation without changing old single-step saves. let automationConditionQueue=[]; let automationEffectQueue=[]; + let automationRulesCache=new Map(); + let editingAutomationId=null; function automationCondition(){ const type=$('autoConditionType')?.value||'completed'; const cond={type}; if($('autoCondNegate')?.checked) cond.negate=true; 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||''; 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 conditionSummary(c){ const base=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'; return c.negate?`NOT (${base})`:base; } 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 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.
'; if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=editingAutomationId?' Save changes':' Save rule'; $('automationCancelEditBtn')?.classList.toggle('d-none', !editingAutomationId); } + function addAutomationCondition(){ automationConditionQueue.push(automationCondition()); if($('autoCondNegate')) $('autoCondNegate').checked=false; 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 resetAutomationForm(){ editingAutomationId=null; automationConditionQueue=[]; automationEffectQueue=[]; if($('autoName')) $('autoName').value=''; if($('autoEnabled')) $('autoEnabled').checked=true; if($('autoCooldown')) $('autoCooldown').value='60'; if($('autoCondNegate')) $('autoCondNegate').checked=false; renderAutomationBuilder(); } + function editAutomation(rule){ if(!rule) return; editingAutomationId=rule.id; automationConditionQueue=JSON.parse(JSON.stringify(rule.conditions||[])); automationEffectQueue=JSON.parse(JSON.stringify(rule.effects||[])); if($('autoName')) $('autoName').value=rule.name||''; if($('autoEnabled')) $('autoEnabled').checked=!!rule.enabled; if($('autoCooldown')) $('autoCooldown').value=rule.cooldown_minutes??60; renderAutomationBuilder(); $('autoName')?.focus(); } + // Note: Existing automation rules can now be reopened, changed and saved with the same id instead of being recreated manually. + async function loadAutomations(){ const j=await (await fetch('/api/automations')).json(); const rules=j.rules||[], hist=j.history||[]; automationRulesCache=new Map(rules.map(r=>[String(r.id),r])); 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 wasEditing=!!editingAutomationId; const payload={id:editingAutomationId,name:$('autoName')?.value||'Automation rule',enabled:!!$('autoEnabled')?.checked,cooldown_minutes:Number($('autoCooldown')?.value||60),conditions,effects}; setBusy(true); try{ await post('/api/automations',payload); resetAutomationForm(); toast(wasEditing?'Automation rule updated':'Automation rule saved','success'); await loadAutomations(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} } function cleanupCountCard(label, value, note=''){ @@ -690,7 +695,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); $('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();}); + $('autoConditionType')?.addEventListener('change',updateAutomationForm); $('autoEffectType')?.addEventListener('change',updateAutomationForm); $('autoAddConditionBtn')?.addEventListener('click',addAutomationCondition); $('autoAddEffectBtn')?.addEventListener('click',addAutomationEffect); $('automationSaveBtn')?.addEventListener('click',saveAutomation); $('automationCancelEditBtn')?.addEventListener('click',resetAutomationForm); $('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 edit=e.target.closest('.automation-edit')?.dataset.id; if(edit){editAutomation(automationRulesCache.get(String(edit))); return;} 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');if(String(editingAutomationId)===String(id)) resetAutomationForm();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 0eabea5..18afea1 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -1280,6 +1280,12 @@ body.mobile-mode .mobile-card { margin-bottom: 0.45rem; background: var(--bs-body-bg); } + +.automation-row-actions { + display: inline-flex; + flex-shrink: 0; + gap: 0.35rem; +} @media (max-width: 900px) { .automation-form-grid { grid-template-columns: 1fr; diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index b87f368..0069d83 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -148,7 +148,7 @@ - +