diff --git a/pytorrent/routes/api.py b/pytorrent/routes/api.py index 51add59..cd923aa 100644 --- a/pytorrent/routes/api.py +++ b/pytorrent/routes/api.py @@ -326,9 +326,11 @@ def cleanup_summary() -> dict: "jobs_total": _table_count("jobs"), "jobs_clearable": _table_count("jobs", "WHERE status NOT IN ('pending', 'running')"), "smart_queue_history_total": _table_count("smart_queue_history"), + "automation_history_total": _table_count("automation_history"), "retention_days": { "jobs": JOBS_RETENTION_DAYS, "smart_queue_history": SMART_QUEUE_HISTORY_RETENTION_DAYS, + "automation_history": SMART_QUEUE_HISTORY_RETENTION_DAYS, }, "database": _db_size(), } @@ -731,6 +733,19 @@ def cleanup_smart_queue(): return ok({"deleted": deleted, "cleanup": cleanup_summary()}) +@bp.post("/cleanup/automations") +def cleanup_automations(): + with connect() as conn: + exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='automation_history'").fetchone() + if not exists: + deleted = 0 + else: + # Note: Cleanup panel removes only automation logs, not saved automation rules. + cur = conn.execute("DELETE FROM automation_history") + deleted = int(cur.rowcount or 0) + return ok({"deleted": deleted, "cleanup": cleanup_summary()}) + + @bp.post("/cleanup/all") def cleanup_all(): deleted_jobs = clear_jobs() @@ -741,7 +756,13 @@ def cleanup_all(): else: cur = conn.execute("DELETE FROM smart_queue_history") deleted_smart = int(cur.rowcount or 0) - return ok({"deleted": {"jobs": deleted_jobs, "smart_queue_history": deleted_smart}, "cleanup": cleanup_summary()}) + exists_auto = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='automation_history'").fetchone() + if not exists_auto: + deleted_auto = 0 + else: + cur = conn.execute("DELETE FROM automation_history") + deleted_auto = int(cur.rowcount or 0) + return ok({"deleted": {"jobs": deleted_jobs, "smart_queue_history": deleted_smart, "automation_history": deleted_auto}, "cleanup": cleanup_summary()}) @bp.post("/jobs//cancel") @@ -1062,3 +1083,17 @@ def automations_check(): return ok({'result': automation_rules.check(profile, force=True), 'history': automation_rules.list_history(profile['id'])}) except Exception as exc: return jsonify({'ok': False, 'error': str(exc)}), 500 + + +@bp.delete('/automations/history') +def automations_history_clear(): + from ..services import automation_rules + profile = preferences.active_profile() + if not profile: + return jsonify({'ok': False, 'error': 'No profile'}), 400 + try: + # Note: Clear only automation execution logs; rules and cooldown state stay unchanged. + deleted = automation_rules.clear_history(profile['id']) + return ok({'deleted': deleted, 'history': automation_rules.list_history(profile['id']), 'cleanup': cleanup_summary()}) + except Exception as exc: + return jsonify({'ok': False, 'error': str(exc)}), 500 diff --git a/pytorrent/services/automation_rules.py b/pytorrent/services/automation_rules.py index ce73eaf..17670f4 100644 --- a/pytorrent/services/automation_rules.py +++ b/pytorrent/services/automation_rules.py @@ -94,6 +94,14 @@ def list_history(profile_id: int, user_id: int | None = None, limit: int = 30) - return conn.execute('SELECT * FROM automation_history WHERE user_id=? AND profile_id=? ORDER BY created_at DESC LIMIT ?', (user_id, profile_id, max(1, min(int(limit or 30), 100)))).fetchall() +def clear_history(profile_id: int, user_id: int | None = None) -> int: + user_id = user_id or default_user_id() + with connect() as conn: + # Note: Manual automation log cleanup is scoped to the active profile and current user. + cur = conn.execute('DELETE FROM automation_history WHERE user_id=? AND profile_id=?', (user_id, profile_id)) + return int(cur.rowcount or 0) + + def _condition_true(t: dict[str, Any], cond: dict[str, Any]) -> bool: typ = str(cond.get('type') or '') if typ == 'completed': return bool(int(t.get('complete') or 0)) diff --git a/pytorrent/services/retention.py b/pytorrent/services/retention.py index d7a03a7..db9c707 100644 --- a/pytorrent/services/retention.py +++ b/pytorrent/services/retention.py @@ -30,6 +30,8 @@ def cleanup(force: bool = False) -> dict[str, int]: targets = { "traffic_history": ("created_at", TRAFFIC_HISTORY_RETENTION_DAYS), "smart_queue_history": ("created_at", SMART_QUEUE_HISTORY_RETENTION_DAYS), + # Note: Automation history follows Smart Queue retention; rules and rule state are never deleted here. + "automation_history": ("created_at", SMART_QUEUE_HISTORY_RETENTION_DAYS), "jobs": ("updated_at", JOBS_RETENTION_DAYS), "logs": ("created_at", LOG_RETENTION_DAYS), } diff --git a/pytorrent/services/smart_queue.py b/pytorrent/services/smart_queue.py index a8d64e8..65d4836 100644 --- a/pytorrent/services/smart_queue.py +++ b/pytorrent/services/smart_queue.py @@ -132,6 +132,32 @@ def _excluded_hashes(profile_id: int, user_id: int) -> set[str]: return {r['torrent_hash'] for r in list_exclusions(profile_id, user_id)} + +def _label_names(value: str | None) -> list[str]: + names: list[str] = [] + for part in str(value or '').replace(';', ',').replace('|', ',').split(','): + label = part.strip() + if label and label not in names: + names.append(label) + return names + + +def _label_value(labels: list[str]) -> str: + output: list[str] = [] + for label in labels: + item = str(label or '').strip() + if item and item not in output: + output.append(item) + return ', '.join(output) + + +def _has_smart_queue_label(value: str | None) -> bool: + return SMART_QUEUE_LABEL in _label_names(value) + + +def _without_smart_queue_label(value: str | None) -> str: + return _label_value([label for label in _label_names(value) if label != SMART_QUEUE_LABEL]) + def _remember_auto_label(profile_id: int, torrent_hash: str, previous_label: str) -> None: now = utcnow() with connect() as conn: @@ -166,18 +192,18 @@ def _restore_auto_label(client: Any, profile_id: int, torrent_hash: str, current ).fetchone() live_label = _read_label(client, torrent_hash, current_label or '') if not row: - if live_label != SMART_QUEUE_LABEL: + if not _has_smart_queue_label(live_label): return False try: - # Note: Clear the Smart Queue label even when the torrent was marked earlier but no previous-label entry remains. - client.call('d.custom1.set', torrent_hash, '') + # Note: Remove only the Smart Queue technical label and keep every user label untouched. + client.call('d.custom1.set', torrent_hash, _without_smart_queue_label(live_label)) return True except Exception: return False previous = row.get('previous_label') or '' try: - # Note: On resume, Smart Queue restores the previous label only while it still sees its own technical label. - if live_label == SMART_QUEUE_LABEL or current_label is None: + # Note: Restore the saved label only when the current label still contains the Smart Queue marker. + if _has_smart_queue_label(live_label) or current_label is None: client.call('d.custom1.set', torrent_hash, previous) conn.execute('DELETE FROM smart_queue_auto_labels WHERE profile_id=? AND torrent_hash=?', (profile_id, torrent_hash)) return True @@ -282,10 +308,16 @@ def _read_live_start_state(client: Any, torrent_hash: str) -> dict[str, Any]: result['started'] = bool(int(result.get('active') or 0)) return result -def _set_smart_queue_label(client: Any, torrent_hash: str, attempts: int = 3) -> bool: +def _set_smart_queue_label(client: Any, torrent_hash: str, current_label: str = '', attempts: int = 3) -> bool: + labels = _label_names(current_label) + if SMART_QUEUE_LABEL in labels: + return True + labels.append(SMART_QUEUE_LABEL) + value = _label_value(labels) for attempt in range(max(1, attempts)): try: - client.call('d.custom1.set', torrent_hash, SMART_QUEUE_LABEL) + # Note: Smart Queue appends its technical label instead of overwriting existing torrent labels. + client.call('d.custom1.set', torrent_hash, value) return True except Exception: if attempt < attempts - 1: @@ -298,15 +330,15 @@ def _mark_auto_paused(client: Any, profile_id: int, torrent: dict[str, Any]) -> if not torrent_hash: return False previous = str(torrent.get('label') or '') - if previous != SMART_QUEUE_LABEL: + if not _has_smart_queue_label(previous): _remember_auto_label(profile_id, torrent_hash, previous) - return _set_smart_queue_label(client, torrent_hash) + return _set_smart_queue_label(client, torrent_hash, previous) def _is_smart_queue_hold(torrent: dict[str, Any] | None, manage_stopped: bool = True) -> bool: if not torrent or int(torrent.get('complete') or 0): return False - if str(torrent.get('label') or '') == SMART_QUEUE_LABEL: + if _has_smart_queue_label(str(torrent.get('label') or '')): return True # Note: Paused in rTorrent usually has state=1 and active=0, so state=0 must not be required. # This lets Smart Queue treat paused torrents as pending and fill the queue target later. @@ -319,11 +351,11 @@ def _is_smart_queue_hold(torrent: dict[str, Any] | None, manage_stopped: bool = def _clear_untracked_smart_queue_label(client: Any, torrent_hash: str, current_label: str) -> bool: - if current_label != SMART_QUEUE_LABEL: + if not _has_smart_queue_label(current_label): return False try: - # Note: Clear an orphaned Smart Queue label when no previous-label entry exists in the database. - client.call('d.custom1.set', torrent_hash, '') + # Note: Clear only the orphaned Smart Queue marker and keep unrelated labels intact. + client.call('d.custom1.set', torrent_hash, _without_smart_queue_label(current_label)) return True except Exception: return False @@ -346,8 +378,8 @@ def _cleanup_auto_labels(client: Any, profile_id: int, torrents: list[dict[str, if _restore_auto_label(client, profile_id, h, None if t is None else current_label): restored.append(h) continue - if current_label != SMART_QUEUE_LABEL: - _set_smart_queue_label(client, h) + if not _has_smart_queue_label(current_label): + _set_smart_queue_label(client, h, current_label) for h, t in by_hash.items(): if not h or h in keep_hashes or h in tracked_hashes or _is_smart_queue_hold(t, manage_stopped): @@ -363,7 +395,7 @@ def _is_running_download_slot(t: dict[str, Any]) -> bool: # Paused can have state=1/open=1, so a slot is counted only after d.is_active=1. if int(t.get('complete') or 0): return False - if str(t.get('label') or '') == SMART_QUEUE_LABEL: + if _has_smart_queue_label(str(t.get('label') or '')): return False status = str(t.get('status') or '').lower() if status == 'checking' or status == 'paused' or bool(t.get('paused')): @@ -375,7 +407,7 @@ def _is_waiting_download_candidate(t: dict[str, Any], manage_stopped: bool) -> b """Return True for paused/held torrents Smart Queue may resume later.""" if int(t.get('complete') or 0): return False - if str(t.get('label') or '') == SMART_QUEUE_LABEL: + if _has_smart_queue_label(str(t.get('label') or '')): return True # Note: Paused items are the primary source for filling the queue, regardless of manage_stopped. if bool(t.get('paused')) or str(t.get('status') or '').lower() == 'paused': @@ -406,7 +438,7 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = excluded = _excluded_hashes(profile_id, user_id) manage_stopped = bool(settings.get('manage_stopped')) def is_managed_hold(t: dict[str, Any]) -> bool: - return str(t.get('label') or '') == SMART_QUEUE_LABEL + return _has_smart_queue_label(str(t.get('label') or '')) # Note: Count Smart Queue slots by d.is_active because Paused can have state=1/open=1 and must not occupy the limit. downloading = [ @@ -538,7 +570,7 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = keep_labels = ( set(paused) | {str(t.get('hash') or '') for t in to_label_waiting} - | {str(t.get('hash') or '') for t in stopped if str(t.get('label') or '') == SMART_QUEUE_LABEL and str(t.get('hash') or '') not in set(resumed)} + | {str(t.get('hash') or '') for t in stopped if _has_smart_queue_label(str(t.get('label') or '')) and str(t.get('hash') or '') not in set(resumed)} ) restored = _cleanup_auto_labels(c, profile_id, torrents, keep_labels, manage_stopped) details = {'excluded': len(excluded), 'enabled': bool(settings.get('enabled')), 'auto_label': SMART_QUEUE_LABEL, 'labels_restored': restored, 'labels_failed': label_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'start_results': start_results, 'resume_requested': resume_requested, 'active_verified': active_verified, 'waiting_labeled': len(to_label_waiting), 'manage_stopped': manage_stopped, 'max_active_downloads': max_active, 'active_before': len(downloading), 'active_after_expected': active_after_pause + len(resumed), 'paused_planned': len(to_pause), 'resumed_planned': len(to_resume), 'rtorrent_cap': rtorrent_cap} diff --git a/pytorrent/static/app.js b/pytorrent/static/app.js index e0ff916..b566b40 100644 --- a/pytorrent/static/app.js +++ b/pytorrent/static/app.js @@ -630,15 +630,31 @@ if(!Array.isArray(actions)) actions=[actions]; const summary=actions.map(summarizeActionObject).join(' '); const details=esc(JSON.stringify(actions,null,2)); + // Note: Large automation payloads are collapsed so JSON never stretches the modal width. return `
${summary||'No actions'}
${details}
`; } + 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.
'; + $('automationHistory').innerHTML=toolbar+body; + } + + async function clearAutomationHistory(){ + if(!confirm('Clear automation history?')) return; + setBusy(true); + 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'); toast(`Automation logs deleted: ${j.deleted||0}`,'success'); renderAutomationHistory(j.history||[]); } + catch(e){ toast(e.message,'danger'); } + finally{ setBusy(false); } + } + async function loadAutomations(){ 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($('automationHistory')) $('automationHistory').innerHTML=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.
'; + renderAutomationHistory(hist); } async function saveAutomation(){ @@ -666,9 +682,10 @@ cleanupCountCard('Job logs total', data.jobs_total, `retention ${retention.jobs||'-'} days`), cleanupCountCard('Job logs clearable', data.jobs_clearable, 'done / failed / cancelled'), cleanupCountCard('Smart Queue logs', data.smart_queue_history_total, `retention ${retention.smart_queue_history||'-'} days`), + cleanupCountCard('Automation logs', data.automation_history_total, `retention ${retention.automation_history||'-'} days`), cleanupCountCard('Database size', db.size_h||db.size||'-', db.path||'') ]; - box.innerHTML=`
${cards.join('')}
Job cleanup preserves pending and running jobs. Use Jobs modal for emergency clear when unfinished jobs must be removed.
`; + box.innerHTML=`
${cards.join('')}
Job cleanup preserves pending and running jobs. Automation cleanup removes only history, not rules.
`; } async function loadCleanup(){ const box=$('cleanupManager'); if(!box) return; @@ -689,6 +706,7 @@ renderCleanup(j.cleanup||{}); if(endpoint.includes('/jobs')){ jobsPage=0; loadJobs(0).catch(()=>{}); } if(endpoint.includes('/smart-queue')) loadSmartQueue().catch(()=>{}); + if(endpoint.includes('/automations')) loadAutomations().catch(()=>{}); }catch(e){ toast(e.message,'danger'); } finally{ setBusy(false); } } @@ -761,7 +779,7 @@ diagCard('Threads', py.threads), diagCard('CPU', `${py.cpu_percent ?? '-'}%`), diagCard('Jobs total', py.jobs_total), diagCard('Worker threads', py.worker_threads), diagCard('Python', py.python||'-'), diagCard('DB size', db.size_h||'-'), diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), - diagCard('Job logs clearable', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), + diagCard('Job logs clearable', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-'), diagCard('Port check', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':''), diagCard('Incoming port', pc.port||'-'), diagCard('Port check source', pc.source||(pc.enabled?'unknown':'disabled')), diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('SCGI connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('SCGI first byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('SCGI total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'), @@ -803,8 +821,8 @@ }catch(e){ box.innerHTML=`
${esc(e.message)}
`; } } - $('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); $('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();}); + $('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(); }); 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 b26ea9c..90fb2d9 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -1316,6 +1316,11 @@ body.mobile-mode .mobile-card { font-size: 0.78rem; white-space: normal; } +.automation-history-toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: 0.5rem; +} .automation-history-details { max-width: min(620px, 60vw); } @@ -1346,6 +1351,9 @@ body.mobile-mode .mobile-card { grid-column: auto; max-width: 100%; } + .automation-history-toolbar { + justify-content: flex-start; + } } .disk-status { display: inline-flex;