From 5d7abe6e5939762885efcd9483faee6f8bc8950d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 7 May 2026 08:11:58 +0200 Subject: [PATCH] automatyzacje-comit2 --- pytorrent/services/automation_rules.py | 53 +++++++++++++++++--------- pytorrent/static/app.js | 24 ++++++++++-- pytorrent/static/styles.css | 25 +++++++++++- 3 files changed, 79 insertions(+), 23 deletions(-) diff --git a/pytorrent/services/automation_rules.py b/pytorrent/services/automation_rules.py index 17670f4..a8c8fed 100644 --- a/pytorrent/services/automation_rules.py +++ b/pytorrent/services/automation_rules.py @@ -165,30 +165,39 @@ def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str 'keep_seeding': bool(eff.get('keep_seeding')), } result = rtorrent.action(profile, hashes, 'move', payload) - applied.append({'type': 'move', 'path': path, 'count': len(hashes), 'move_data': payload['move_data'], 'recheck': payload['recheck'], 'keep_seeding': payload['keep_seeding'], 'result': result}) + applied.append({'type': 'move', 'path': path, 'count': len(hashes), 'target_hashes': hashes, 'move_data': payload['move_data'], 'recheck': payload['recheck'], 'keep_seeding': payload['keep_seeding'], 'result': result}) elif typ == 'add_label': label = str(eff.get('label') or '').strip() if label: - for h in hashes: + # Note: Add-label automations are idempotent; torrents that already have the label are ignored. + target_hashes = [h for h in hashes if label not in labels_by_hash.get(h, [])] + for h in target_hashes: labels = labels_by_hash.setdefault(h, []) - if label not in labels: - labels.append(label); c.call('d.custom1.set', h, _label_value(labels)) - applied.append({'type': 'add_label', 'label': label, 'count': len(hashes)}) + labels.append(label); c.call('d.custom1.set', h, _label_value(labels)) + if target_hashes: + applied.append({'type': 'add_label', 'label': label, 'count': len(target_hashes), 'target_hashes': target_hashes}) elif typ == 'remove_label': label = str(eff.get('label') or '').strip() if label: - for h in hashes: + # Note: Remove-label automations run only on torrents that actually contain the label. + target_hashes = [h for h in hashes if label in labels_by_hash.get(h, [])] + for h in target_hashes: labels = [x for x in labels_by_hash.get(h, []) if x != label] labels_by_hash[h] = labels; c.call('d.custom1.set', h, _label_value(labels)) - applied.append({'type': 'remove_label', 'label': label, 'count': len(hashes)}) + if target_hashes: + applied.append({'type': 'remove_label', 'label': label, 'count': len(target_hashes), 'target_hashes': target_hashes}) elif typ == 'set_labels': value = _label_value(_label_names(eff.get('labels'))) - for h in hashes: - labels_by_hash[h] = _label_names(value); c.call('d.custom1.set', h, value) - applied.append({'type': 'set_labels', 'labels': value, 'count': len(hashes)}) + target_labels = _label_names(value) + # Note: Set-labels skips torrents whose current label list already matches the requested list. + target_hashes = [h for h in hashes if labels_by_hash.get(h, []) != target_labels] + for h in target_hashes: + labels_by_hash[h] = list(target_labels); c.call('d.custom1.set', h, value) + if target_hashes: + applied.append({'type': 'set_labels', 'labels': value, 'count': len(target_hashes), 'target_hashes': target_hashes}) elif typ in {'pause', 'stop', 'start', 'resume', 'recheck'}: result = rtorrent.action(profile, hashes, typ, {}) - applied.append({'type': typ, 'count': len(hashes), 'result': result}) + applied.append({'type': typ, 'count': len(hashes), 'target_hashes': hashes, 'result': result}) return applied @@ -213,14 +222,20 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = try: actions = _apply_effects_bulk(c, profile, matched, rule.get('effects') or []) except Exception as exc: - actions = [{'error': str(exc), 'count': len(hashes)}] - for t in matched: - h = str(t.get('hash') or '') + actions = [{'error': str(exc), 'count': len(hashes), 'target_hashes': hashes}] + changed_hashes = sorted({h for a in actions for h in (a.get('target_hashes') or [])}) + if not actions or not changed_hashes: + # Note: Matching torrents with no real action are not logged and do not restart the cooldown. + continue + matched_by_hash = {str(t.get('hash') or ''): t for t in matched} + for h in changed_hashes: + t = matched_by_hash.get(h, {}) conn.execute('INSERT INTO automation_rule_state(rule_id,profile_id,torrent_hash,last_matched_at,last_applied_at,updated_at) VALUES(?,?,?,?,?,?) ON CONFLICT(rule_id,profile_id,torrent_hash) DO UPDATE SET last_matched_at=excluded.last_matched_at, last_applied_at=excluded.last_applied_at, updated_at=excluded.updated_at', (rule['id'], profile_id, h, now, now, now)) - applied.append({'rule_id': rule['id'], 'rule_name': rule.get('name'), 'hash': h, 'name': t.get('name'), 'actions': [{'type': a.get('type', 'error'), 'count': a.get('count', len(hashes))} for a in actions]}) + applied.append({'rule_id': rule['id'], 'rule_name': rule.get('name'), 'hash': h, 'name': t.get('name'), 'actions': [{'type': a.get('type', 'error'), 'count': a.get('count', len(changed_hashes))} for a in actions]}) _mark_rule_cooldown(conn, rule, profile_id, now) - torrent_name = str(matched[0].get('name') or '') if len(matched) == 1 else f'{len(matched)} torrents' - torrent_hash = hashes[0] if len(hashes) == 1 else f'batch:{rule["id"]}:{now}' - conn.execute('INSERT INTO automation_history(user_id,profile_id,rule_id,torrent_hash,torrent_name,rule_name,actions_json,created_at) VALUES(?,?,?,?,?,?,?,?)', (user_id, profile_id, rule['id'], torrent_hash, torrent_name, str(rule.get('name') or ''), json.dumps(actions), now)) - batches.append({'rule_id': rule['id'], 'rule_name': rule.get('name'), 'count': len(hashes), 'actions': actions}) + torrent_name = str(matched_by_hash.get(changed_hashes[0], {}).get('name') or '') if len(changed_hashes) == 1 else f'{len(changed_hashes)} torrents' + torrent_hash = changed_hashes[0] if len(changed_hashes) == 1 else f'batch:{rule["id"]}:{now}' + history_actions = [{k: v for k, v in a.items() if k != 'target_hashes'} for a in actions] + conn.execute('INSERT INTO automation_history(user_id,profile_id,rule_id,torrent_hash,torrent_name,rule_name,actions_json,created_at) VALUES(?,?,?,?,?,?,?,?)', (user_id, profile_id, rule['id'], torrent_hash, torrent_name, str(rule.get('name') or ''), json.dumps(history_actions), now)) + batches.append({'rule_id': rule['id'], 'rule_name': rule.get('name'), 'count': len(changed_hashes), 'actions': history_actions}) return {'ok': True, 'checked': len(torrents), 'rules': len(rules), 'applied': applied, 'batches': batches} diff --git a/pytorrent/static/app.js b/pytorrent/static/app.js index b566b40..bfd4d88 100644 --- a/pytorrent/static/app.js +++ b/pytorrent/static/app.js @@ -637,7 +637,9 @@ function renderAutomationHistory(hist=[]){ if(!$('automationHistory')) return; const toolbar='
'; - const body=hist.length?table(['Time','Rule','Torrent / batch','Actions'],hist.map(h=>[esc(h.created_at),esc(h.rule_name||''),esc(h.torrent_name||h.torrent_hash||''),automationHistoryActions(h.actions_json||'')])):'
No automation history yet.
'; + const rows=hist.map(h=>[esc(h.created_at),esc(h.rule_name||''),esc(h.torrent_name||h.torrent_hash||''),automationHistoryActions(h.actions_json||'')]); + // Note: Automation history uses its own table class so long action JSON wraps inside the modal. + const body=hist.length?table(['Time','Rule','Torrent / batch','Actions'],rows).replace('detail-table','detail-table automation-history-table'):'
No automation history yet.
'; $('automationHistory').innerHTML=toolbar+body; } @@ -653,10 +655,26 @@ const j=await (await fetch('/api/automations')).json(); const rules=j.rules||[], hist=j.history||[]; automationRulesCache=rules; - 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($('automationManager')) $('automationManager').innerHTML=rules.length?rules.map(r=>{ + const enabled=!!r.enabled; + const toggleTitle=enabled?'Disable automation':'Enable automation'; + const toggleIcon=enabled?'fa-toggle-on':'fa-toggle-off'; + const toggleClass=enabled?'btn-outline-warning':'btn-outline-success'; + return `
${esc(r.name)} ${enabled?'on':'off'}
${esc(ruleSummary(r))} · cooldown ${esc(r.cooldown_minutes||0)} min
`; + }).join(''):'
No automation rules.
'; renderAutomationHistory(hist); } + async function toggleAutomationRule(rule){ + if(!rule) return; + const payload={...rule, enabled:!rule.enabled}; + // Note: Toggle keeps the rule definition unchanged and only switches automatic execution on or off. + setBusy(true); + try{ await post('/api/automations',payload); toast(payload.enabled?'Automation enabled':'Automation disabled','success'); await loadAutomations(); } + catch(e){ toast(e.message,'danger'); } + finally{ setBusy(false); } + } + async function saveAutomation(){ const currentCond=automationCondition(); const currentEff=automationEffect(); @@ -822,7 +840,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('#cleanupAutomationsBtn')) return runCleanupAction('/api/cleanup/automations','Clear automation logs'); if(e.target.closest('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear job, Smart Queue and automation 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); $('automationAddConditionBtn')?.addEventListener('click',()=>{automationConditions.push(automationCondition()); renderAutomationBuilder();}); $('automationAddEffectBtn')?.addEventListener('click',()=>{automationEffects.push(automationEffect()); renderAutomationBuilder();}); $('automationConditionList')?.addEventListener('click',e=>{const b=e.target.closest('.automation-remove-condition'); if(!b)return; automationConditions.splice(Number(b.dataset.index||0),1); renderAutomationBuilder();}); $('automationEffectList')?.addEventListener('click',e=>{const b=e.target.closest('.automation-remove-effect'); if(!b)return; automationEffects.splice(Number(b.dataset.index||0),1); renderAutomationBuilder();}); $('automationCancelEditBtn')?.addEventListener('click',resetAutomationForm); $('automationSaveBtn')?.addEventListener('click',saveAutomation); $('automationCheckBtn')?.addEventListener('click',async()=>{setBusy(true);try{const j=await post('/api/automations/check',{}); const torrents=j.result?.applied?.length||0; const batches=j.result?.batches?.length||0; toast(`Automations applied ${torrents} torrent(s) in ${batches} batch(es)`,'success'); await loadAutomations();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('automationManager')?.addEventListener('click',async e=>{const edit=e.target.closest('.automation-edit'); if(edit){ editAutomationRule(automationRulesCache.find(r=>String(r.id)===String(edit.dataset.id))); 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');await loadAutomations();}); $('automationHistory')?.addEventListener('click',e=>{ if(e.target.closest('#automationClearHistoryBtn')) clearAutomationHistory(); }); + $('autoConditionType')?.addEventListener('change',updateAutomationForm); $('autoEffectType')?.addEventListener('change',updateAutomationForm); $('automationAddConditionBtn')?.addEventListener('click',()=>{automationConditions.push(automationCondition()); renderAutomationBuilder();}); $('automationAddEffectBtn')?.addEventListener('click',()=>{automationEffects.push(automationEffect()); renderAutomationBuilder();}); $('automationConditionList')?.addEventListener('click',e=>{const b=e.target.closest('.automation-remove-condition'); if(!b)return; automationConditions.splice(Number(b.dataset.index||0),1); renderAutomationBuilder();}); $('automationEffectList')?.addEventListener('click',e=>{const b=e.target.closest('.automation-remove-effect'); if(!b)return; automationEffects.splice(Number(b.dataset.index||0),1); renderAutomationBuilder();}); $('automationCancelEditBtn')?.addEventListener('click',resetAutomationForm); $('automationSaveBtn')?.addEventListener('click',saveAutomation); $('automationCheckBtn')?.addEventListener('click',async()=>{setBusy(true);try{const j=await post('/api/automations/check',{}); const torrents=j.result?.applied?.length||0; const batches=j.result?.batches?.length||0; toast(`Automations applied ${torrents} torrent(s) in ${batches} batch(es)`,'success'); await loadAutomations();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('automationManager')?.addEventListener('click',async e=>{const toggle=e.target.closest('.automation-toggle'); if(toggle){ await toggleAutomationRule(automationRulesCache.find(r=>String(r.id)===String(toggle.dataset.id))); return; } const edit=e.target.closest('.automation-edit'); if(edit){ editAutomationRule(automationRulesCache.find(r=>String(r.id)===String(edit.dataset.id))); 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');await loadAutomations();}); $('automationHistory')?.addEventListener('click',e=>{ if(e.target.closest('#automationClearHistoryBtn')) clearAutomationHistory(); }); 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 90fb2d9..5a40cc1 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -1306,6 +1306,9 @@ body.mobile-mode .mobile-card { .automation-row-main { min-width: 0; } +.automation-rule-summary { + overflow-wrap: anywhere; +} .automation-action-pill { display: inline-flex; max-width: 100%; @@ -1321,8 +1324,28 @@ body.mobile-mode .mobile-card { justify-content: flex-end; margin-bottom: 0.5rem; } +.automation-history-table { + width: 100%; + table-layout: fixed; + white-space: normal; +} +.automation-history-table th, +.automation-history-table td { + max-width: 0; + overflow-wrap: anywhere; + vertical-align: top; + word-break: break-word; +} +.automation-history-table th:nth-child(1), +.automation-history-table td:nth-child(1) { + width: 9.5rem; +} +.automation-history-table th:nth-child(4), +.automation-history-table td:nth-child(4) { + width: 42%; +} .automation-history-details { - max-width: min(620px, 60vw); + max-width: 100%; } .automation-history-details summary { cursor: pointer;