From a72b6eb3641adcb1e3c8616110511e164ae9a042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 6 May 2026 22:13:52 +0200 Subject: [PATCH 01/13] labels and automatizations --- pytorrent/services/automation_rules.py | 7 +- pytorrent/services/smart_queue.py | 88 ++++++++++++++++++-------- pytorrent/static/app.js | 4 +- pytorrent/static/styles.css | 3 +- pytorrent/templates/index.html | 2 +- 5 files changed, 70 insertions(+), 34 deletions(-) diff --git a/pytorrent/services/automation_rules.py b/pytorrent/services/automation_rules.py index 3bdc81d..7e9e8fc 100644 --- a/pytorrent/services/automation_rules.py +++ b/pytorrent/services/automation_rules.py @@ -111,8 +111,11 @@ def _conditions_match(conn, rule: dict[str, Any], profile_id: int, t: dict[str, if not h: return False immediate_ok = True; delayed_ok = True; now = utcnow(); now_ts = _now_ts() for cond in rule.get('conditions') or []: - ok = _condition_true(t, cond) - if cond.get('type') == 'no_seeds' and int(cond.get('minutes') or 0) > 0: + raw_ok = _condition_true(t, cond) + negated = bool(cond.get('negate')) + ok = (not raw_ok) if negated else raw_ok + # Note: Conditions can now be negated in automation rules. Timed no-seeds keeps its old delayed behavior only for the positive condition, so old rules do not change. + if cond.get('type') == 'no_seeds' and int(cond.get('minutes') or 0) > 0 and not negated: row = conn.execute('SELECT condition_since_at FROM automation_rule_state WHERE rule_id=? AND profile_id=? AND torrent_hash=?', (rule['id'], profile_id, h)).fetchone() if ok: since = row['condition_since_at'] if row and row.get('condition_since_at') else now diff --git a/pytorrent/services/smart_queue.py b/pytorrent/services/smart_queue.py index a8d64e8..1779ee3 100644 --- a/pytorrent/services/smart_queue.py +++ b/pytorrent/services/smart_queue.py @@ -132,6 +132,39 @@ 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: + out: list[str] = [] + for label in labels: + label = str(label or '').strip() + if label and label not in out: + out.append(label) + return ', '.join(out) + + +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 _with_smart_queue_label(value: str | None) -> str: + labels = _label_names(value) + if SMART_QUEUE_LABEL not in labels: + labels.append(SMART_QUEUE_LABEL) + return _label_value(labels) + + def _remember_auto_label(profile_id: int, torrent_hash: str, previous_label: str) -> None: now = utcnow() with connect() as conn: @@ -165,21 +198,18 @@ def _restore_auto_label(client: Any, profile_id: int, torrent_hash: str, current (profile_id, torrent_hash), ).fetchone() live_label = _read_label(client, torrent_hash, current_label or '') - if not row: - if live_label != SMART_QUEUE_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, '') - return True - except Exception: - return False - previous = row.get('previous_label') or '' + if not row and not _has_smart_queue_label(live_label): + return False + restored = _without_smart_queue_label(live_label) + previous = _without_smart_queue_label((row or {}).get('previous_label') or '') + if not restored and previous: + restored = previous 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: - 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)) + # Note: Smart Queue now removes only its technical label, preserving labels added manually while the torrent was waiting in the queue. + if _has_smart_queue_label(live_label) or current_label is None: + client.call('d.custom1.set', torrent_hash, restored) + if row: + conn.execute('DELETE FROM smart_queue_auto_labels WHERE profile_id=? AND torrent_hash=?', (profile_id, torrent_hash)) return True except Exception: return False @@ -282,10 +312,12 @@ 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 | None = None, attempts: int = 3) -> bool: + # Note: The queue label is appended as a technical label instead of replacing the user's labels. + target = _with_smart_queue_label(current_label if current_label is not None else _read_label(client, torrent_hash, '')) for attempt in range(max(1, attempts)): try: - client.call('d.custom1.set', torrent_hash, SMART_QUEUE_LABEL) + client.call('d.custom1.set', torrent_hash, target) 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: - _remember_auto_label(profile_id, torrent_hash, previous) - return _set_smart_queue_label(client, torrent_hash) + if not _has_smart_queue_label(previous): + _remember_auto_label(profile_id, torrent_hash, _without_smart_queue_label(previous)) + 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(torrent.get('label')): 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: Orphan cleanup removes only the Smart Queue technical label and keeps manual labels intact. + client.call('d.custom1.set', torrent_hash, _without_smart_queue_label(current_label)) return True except Exception: return False @@ -346,7 +378,7 @@ 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: + if not _has_smart_queue_label(current_label): _set_smart_queue_label(client, h) for h, t in by_hash.items(): @@ -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(t.get('label')): 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(t.get('label')): 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(t.get('label')) # 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(t.get('label')) 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 76cd8af..ac59165 100644 --- a/pytorrent/static/app.js +++ b/pytorrent/static/app.js @@ -528,10 +528,10 @@ // 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 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){ 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 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(); } diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index b64de87..0eabea5 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -1245,7 +1245,8 @@ body.mobile-mode .mobile-card { align-items: center; } -.auto-move-option { +.auto-move-option, +.auto-condition-option { gap: 0.45rem; margin: 0; } diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index 632bda9..b87f368 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -148,7 +148,7 @@ - + From 7c31136535c32eef825cb413d5e0c713d9494a66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 6 May 2026 22:32:06 +0200 Subject: [PATCH 02/13] labels and automatizations --- pytorrent/static/app.js | 15 ++++++++++----- pytorrent/static/styles.css | 6 ++++++ pytorrent/templates/index.html | 2 +- 3 files changed, 17 insertions(+), 6 deletions(-) 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 @@ - + From 0730e7316c96c1e273a2d955e4f3c732f44ab5db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 6 May 2026 23:00:06 +0200 Subject: [PATCH 03/13] labels and automatizations --- pytorrent/services/automation_rules.py | 85 ++++++++++---- pytorrent/services/rtorrent.py | 124 +++++++++----------- pytorrent/services/smart_queue.py | 88 +++++---------- pytorrent/static/app.js | 149 +++++++++++++++++++++---- pytorrent/static/styles.css | 112 ++++++++++++++----- pytorrent/templates/index.html | 2 +- 6 files changed, 359 insertions(+), 201 deletions(-) diff --git a/pytorrent/services/automation_rules.py b/pytorrent/services/automation_rules.py index 7e9e8fc..ce73eaf 100644 --- a/pytorrent/services/automation_rules.py +++ b/pytorrent/services/automation_rules.py @@ -113,8 +113,8 @@ def _conditions_match(conn, rule: dict[str, Any], profile_id: int, t: dict[str, for cond in rule.get('conditions') or []: raw_ok = _condition_true(t, cond) negated = bool(cond.get('negate')) + # Note: Negation is applied in the backend, so UI and API only store the condition flag. ok = (not raw_ok) if negated else raw_ok - # Note: Conditions can now be negated in automation rules. Timed no-seeds keeps its old delayed behavior only for the positive condition, so old rules do not change. if cond.get('type') == 'no_seeds' and int(cond.get('minutes') or 0) > 0 and not negated: row = conn.execute('SELECT condition_since_at FROM automation_rule_state WHERE rule_id=? AND profile_id=? AND torrent_hash=?', (rule['id'], profile_id, h)).fetchone() if ok: @@ -128,33 +128,59 @@ def _conditions_match(conn, rule: dict[str, Any], profile_id: int, t: dict[str, return immediate_ok and delayed_ok -def _cooldown_ok(conn, rule: dict[str, Any], profile_id: int, torrent_hash: str) -> bool: +def _cooldown_ok(conn, rule: dict[str, Any], profile_id: int, torrent_hash: str = '__rule__') -> bool: cooldown = int(rule.get('cooldown_minutes') or 0) + if cooldown <= 0: return True row = conn.execute('SELECT last_applied_at FROM automation_rule_state WHERE rule_id=? AND profile_id=? AND torrent_hash=?', (rule['id'], profile_id, torrent_hash)).fetchone() if not row or not row.get('last_applied_at'): return True return _now_ts() - _ts(row['last_applied_at']) >= cooldown * 60 -def _apply_effects(c: Any, profile: dict[str, Any], torrent: dict[str, Any], effects: list[dict[str, Any]]) -> list[dict[str, Any]]: - h = str(torrent.get('hash') or ''); labels = _label_names(torrent.get('label')); applied = [] +def _mark_rule_cooldown(conn, rule: dict[str, Any], profile_id: int, now: str) -> None: + # Note: Cooldown is rule-level, so one batch execution blocks the whole automation until the cooldown expires. + conn.execute('INSERT INTO automation_rule_state(rule_id,profile_id,torrent_hash,last_applied_at,updated_at) VALUES(?,?,?,?,?) ON CONFLICT(rule_id,profile_id,torrent_hash) DO UPDATE SET last_applied_at=excluded.last_applied_at, updated_at=excluded.updated_at', (rule['id'], profile_id, '__rule__', now, now)) + + +def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str, Any]], effects: list[dict[str, Any]]) -> list[dict[str, Any]]: + hashes = [str(t.get('hash') or '') for t in torrents if str(t.get('hash') or '')] + labels_by_hash = {str(t.get('hash') or ''): _label_names(t.get('label')) for t in torrents} + applied: list[dict[str, Any]] = [] + if not hashes: return applied 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) - 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}) + 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.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}) 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)) - if label: applied.append({'type': 'add_label', 'label': label}) + if label: + for h in 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)}) elif typ == 'remove_label': - label = str(eff.get('label') or '').strip(); labels = [x for x in labels if x != label]; c.call('d.custom1.set', h, _label_value(labels)); applied.append({'type': 'remove_label', 'label': label}) + label = str(eff.get('label') or '').strip() + if label: + for h in 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)}) elif typ == 'set_labels': - value = _label_value(_label_names(eff.get('labels'))); c.call('d.custom1.set', h, value); labels = _label_names(value); applied.append({'type': 'set_labels', 'labels': value}) + 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)}) elif typ in {'pause', 'stop', 'start', 'resume', 'recheck'}: - method = {'pause':'d.pause','stop':'d.stop','start':'d.start','resume':'d.resume','recheck':'d.check_hash'}[typ]; c.call(method, h); applied.append({'type': typ}) + result = rtorrent.action(profile, hashes, typ, {}) + applied.append({'type': typ, 'count': len(hashes), 'result': result}) return applied @@ -163,17 +189,30 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = if not profile: return {'ok': False, 'error': 'No active rTorrent profile'} user_id = user_id or default_user_id(); profile_id = int(profile['id']) rules = [r for r in list_rules(profile_id, user_id) if force or int(r.get('enabled') or 0)] - if not rules: return {'ok': True, 'checked': 0, 'applied': [], 'rules': 0} - torrents = rtorrent.list_torrents(profile); c = rtorrent.client_for(profile); applied = []; now = utcnow() + if not rules: return {'ok': True, 'checked': 0, 'applied': [], 'batches': [], 'rules': 0} + torrents = rtorrent.list_torrents(profile); c = rtorrent.client_for(profile); applied = []; batches = []; now = utcnow() with connect() as conn: for rule in rules: - for t in torrents: + # Note: Automations now execute as one batch per rule, not as one independent action per torrent. + if not force and not _cooldown_ok(conn, rule, profile_id): + continue + matched = [t for t in torrents if _conditions_match(conn, rule, profile_id, t)] + if not matched: + continue + hashes = [str(t.get('hash') or '') for t in matched if str(t.get('hash') or '')] + if not hashes: + continue + 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 '') - if not _conditions_match(conn, rule, profile_id, t): continue - if not force and not _cooldown_ok(conn, rule, profile_id, h): continue - try: actions = _apply_effects(c, profile, t, rule.get('effects') or []) - except Exception as exc: actions = [{'error': str(exc)}] 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)) - 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'], h, str(t.get('name') or ''), str(rule.get('name') or ''), json.dumps(actions), now)) - applied.append({'rule_id': rule['id'], 'rule_name': rule.get('name'), 'hash': h, 'name': t.get('name'), 'actions': actions}) - return {'ok': True, 'checked': len(torrents), 'rules': len(rules), 'applied': applied} + 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]}) + _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}) + return {'ok': True, 'checked': len(torrents), 'rules': len(rules), 'applied': applied, 'batches': batches} diff --git a/pytorrent/services/rtorrent.py b/pytorrent/services/rtorrent.py index 68632f8..b853464 100644 --- a/pytorrent/services/rtorrent.py +++ b/pytorrent/services/rtorrent.py @@ -1274,73 +1274,6 @@ 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) @@ -1361,8 +1294,61 @@ 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": - # Note: Main move delegates to the shared helper used by automations. - return move_torrents(profile, torrent_hashes, payload) + 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: Automations can force seeding after a physical move even if the torrent was not active before. + 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_after_move_error"] = str(exc) + else: + c.call("d.directory.set", h, path) + results.append(item) + return {"ok": True, "count": len(torrent_hashes), "move_data": move_data, "keep_seeding": keep_seeding, "results": results} 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/services/smart_queue.py b/pytorrent/services/smart_queue.py index 1779ee3..a8d64e8 100644 --- a/pytorrent/services/smart_queue.py +++ b/pytorrent/services/smart_queue.py @@ -132,39 +132,6 @@ 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: - out: list[str] = [] - for label in labels: - label = str(label or '').strip() - if label and label not in out: - out.append(label) - return ', '.join(out) - - -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 _with_smart_queue_label(value: str | None) -> str: - labels = _label_names(value) - if SMART_QUEUE_LABEL not in labels: - labels.append(SMART_QUEUE_LABEL) - return _label_value(labels) - - def _remember_auto_label(profile_id: int, torrent_hash: str, previous_label: str) -> None: now = utcnow() with connect() as conn: @@ -198,18 +165,21 @@ def _restore_auto_label(client: Any, profile_id: int, torrent_hash: str, current (profile_id, torrent_hash), ).fetchone() live_label = _read_label(client, torrent_hash, current_label or '') - if not row and not _has_smart_queue_label(live_label): - return False - restored = _without_smart_queue_label(live_label) - previous = _without_smart_queue_label((row or {}).get('previous_label') or '') - if not restored and previous: - restored = previous + if not row: + if live_label != SMART_QUEUE_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, '') + return True + except Exception: + return False + previous = row.get('previous_label') or '' try: - # Note: Smart Queue now removes only its technical label, preserving labels added manually while the torrent was waiting in the queue. - if _has_smart_queue_label(live_label) or current_label is None: - client.call('d.custom1.set', torrent_hash, restored) - if row: - conn.execute('DELETE FROM smart_queue_auto_labels WHERE profile_id=? AND torrent_hash=?', (profile_id, torrent_hash)) + # 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: + 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 except Exception: return False @@ -312,12 +282,10 @@ 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, current_label: str | None = None, attempts: int = 3) -> bool: - # Note: The queue label is appended as a technical label instead of replacing the user's labels. - target = _with_smart_queue_label(current_label if current_label is not None else _read_label(client, torrent_hash, '')) +def _set_smart_queue_label(client: Any, torrent_hash: str, attempts: int = 3) -> bool: for attempt in range(max(1, attempts)): try: - client.call('d.custom1.set', torrent_hash, target) + client.call('d.custom1.set', torrent_hash, SMART_QUEUE_LABEL) return True except Exception: if attempt < attempts - 1: @@ -330,15 +298,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 not _has_smart_queue_label(previous): - _remember_auto_label(profile_id, torrent_hash, _without_smart_queue_label(previous)) - return _set_smart_queue_label(client, torrent_hash, previous) + if previous != SMART_QUEUE_LABEL: + _remember_auto_label(profile_id, torrent_hash, previous) + return _set_smart_queue_label(client, torrent_hash) 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 _has_smart_queue_label(torrent.get('label')): + if str(torrent.get('label') or '') == SMART_QUEUE_LABEL: 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. @@ -351,11 +319,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 not _has_smart_queue_label(current_label): + if current_label != SMART_QUEUE_LABEL: return False try: - # Note: Orphan cleanup removes only the Smart Queue technical label and keeps manual labels intact. - client.call('d.custom1.set', torrent_hash, _without_smart_queue_label(current_label)) + # Note: Clear an orphaned Smart Queue label when no previous-label entry exists in the database. + client.call('d.custom1.set', torrent_hash, '') return True except Exception: return False @@ -378,7 +346,7 @@ 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 not _has_smart_queue_label(current_label): + if current_label != SMART_QUEUE_LABEL: _set_smart_queue_label(client, h) for h, t in by_hash.items(): @@ -395,7 +363,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 _has_smart_queue_label(t.get('label')): + if str(t.get('label') or '') == SMART_QUEUE_LABEL: return False status = str(t.get('status') or '').lower() if status == 'checking' or status == 'paused' or bool(t.get('paused')): @@ -407,7 +375,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 _has_smart_queue_label(t.get('label')): + if str(t.get('label') or '') == SMART_QUEUE_LABEL: 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': @@ -438,7 +406,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 _has_smart_queue_label(t.get('label')) + return str(t.get('label') or '') == SMART_QUEUE_LABEL # Note: Count Smart Queue slots by d.is_active because Paused can have state=1/open=1 and must not occupy the limit. downloading = [ @@ -570,7 +538,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 _has_smart_queue_label(t.get('label')) and str(t.get('hash') or '') not in set(resumed)} + | {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)} ) 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 717a176..e0ff916 100644 --- a/pytorrent/static/app.js +++ b/pytorrent/static/app.js @@ -525,25 +525,134 @@ 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=[]; - 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.
'; 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}`; } - 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);} } + let automationRulesCache=[]; + let automationConditions=[]; + let automationEffects=[]; + + function automationCondition(){ + const type=$('autoConditionType')?.value||'completed'; + const cond={type, negate:!!$('autoCondNegate')?.checked}; + 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=!!$('autoMoveData')?.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 conditionText(c={}){ + const base=c.type==='no_seeds'?`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 effectText(e={}){ + if(e.type==='move'){ + const flags=[]; + if(e.move_data) flags.push('move data'); + if(e.recheck) flags.push('recheck'); + if(e.keep_seeding) flags.push('keep seeding'); + return `move to ${e.path||'default path'}${flags.length?` (${flags.join(', ')})`:''}`; + } + return e.type==='add_label'?`add label ${e.label||''}`:e.type==='remove_label'?`remove label ${e.label||''}`:e.type==='set_labels'?`set labels ${e.labels||''}`:e.type; + } + function ruleSummary(r){ + const cs=(r.conditions||[]).map(conditionText).join(' + ')||'no conditions'; + const es=(r.effects||[]).map(effectText).join(' → ')||'no actions'; + return `${cs} → ${es}`; + } + + function renderAutomationBuilder(){ + const cBox=$('automationConditionList'); + if(cBox) cBox.innerHTML=automationConditions.length?automationConditions.map((c,i)=>`IF ${esc(conditionText(c))}`).join(''):'No conditions added yet.'; + const eBox=$('automationEffectList'); + if(eBox) eBox.innerHTML=automationEffects.length?automationEffects.map((e,i)=>`${i+1} ${esc(effectText(e))}`).join(''):'No actions added yet.'; + } + function resetAutomationForm(){ + if($('autoEditId')) $('autoEditId').value=''; + if($('autoName')) $('autoName').value=''; + if($('autoEnabled')) $('autoEnabled').checked=true; + if($('autoCooldown')) $('autoCooldown').value='60'; + automationConditions=[]; automationEffects=[]; + $('automationCancelEditBtn')?.classList.add('d-none'); + if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Save rule'; + renderAutomationBuilder(); updateAutomationForm(); + } + function editAutomationRule(rule){ + if(!rule) return; + if($('autoEditId')) $('autoEditId').value=rule.id||''; + if($('autoName')) $('autoName').value=rule.name||''; + if($('autoEnabled')) $('autoEnabled').checked=!!rule.enabled; + if($('autoCooldown')) $('autoCooldown').value=rule.cooldown_minutes ?? 60; + automationConditions=Array.isArray(rule.conditions)?JSON.parse(JSON.stringify(rule.conditions)):[]; + automationEffects=Array.isArray(rule.effects)?JSON.parse(JSON.stringify(rule.effects)):[]; + $('automationCancelEditBtn')?.classList.remove('d-none'); + if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Update rule'; + renderAutomationBuilder(); + } + + function summarizeActionObject(a={}){ + if(a.error) return `${esc(a.error)}`; + const count=a.count || a.result?.count || a.result?.results?.length || ''; + const parts=[]; + if(a.type) parts.push(a.type); + if(count) parts.push(`${count} torrent(s)`); + if(a.path) parts.push(a.path); + if(a.label) parts.push(`label ${a.label}`); + if(a.labels) parts.push(`labels ${a.labels}`); + if(a.move_data) parts.push('move data'); + if(a.recheck) parts.push('recheck'); + if(a.keep_seeding) parts.push('keep seeding'); + return `${esc(parts.join(' · ')||'action')}`; + } + function automationHistoryActions(raw){ + let actions=[]; + try{ actions=JSON.parse(raw||'[]'); }catch(e){ return `
${esc(raw||'')}
`; } + if(!Array.isArray(actions)) actions=[actions]; + const summary=actions.map(summarizeActionObject).join(' '); + const details=esc(JSON.stringify(actions,null,2)); + return `
${summary||'No actions'}
${details}
`; + } + + 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.
'; + } + + async function saveAutomation(){ + const currentCond=automationCondition(); + const currentEff=automationEffect(); + const conditions=automationConditions.length?automationConditions:[currentCond]; + const effects=automationEffects.length?automationEffects:[currentEff]; + const payload={id:Number($('autoEditId')?.value||0)||undefined,name:$('autoName')?.value||'Automation rule',enabled:!!$('autoEnabled')?.checked,cooldown_minutes:Number($('autoCooldown')?.value||60),conditions,effects}; + setBusy(true); + try{ await post('/api/automations',payload); toast(payload.id?'Automation rule updated':'Automation rule saved','success'); resetAutomationForm(); await loadAutomations(); } + catch(e){toast(e.message,'danger');} + finally{setBusy(false);} + } + function cleanupCountCard(label, value, note=''){ @@ -695,7 +804,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); $('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();}); + $('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();}); 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 18afea1..b26ea9c 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -1238,37 +1238,60 @@ body.mobile-mode .mobile-card { color: var(--bs-primary-text-emphasis); } -.automation-form-grid { +.automation-shell { + display: grid; + gap: 0.75rem; +} +.automation-main-card { + padding: 0.75rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + background: var(--bs-body-bg); +} +.automation-card-title { + margin-bottom: 0.5rem; + font-weight: 700; +} +.automation-rule-grid, +.automation-builder-grid { display: grid; grid-template-columns: repeat(4, minmax(160px, 1fr)); gap: 0.5rem; align-items: center; } - -.auto-move-option, -.auto-condition-option { - gap: 0.45rem; +.automation-enabled, +.automation-negate { 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; + padding: 0.45rem 0.6rem 0.45rem 2.5rem; border: 1px solid var(--bs-border-color); - border-radius: 0.55rem; - background: var(--bs-secondary-bg); + border-radius: 0.5rem; +} +.automation-path-input { + grid-column: span 2; +} +.automation-chip-list { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} +.automation-chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + max-width: 100%; + padding: 0.25rem 0.5rem; + border: 1px solid var(--bs-border-color); + border-radius: 999px; + background: var(--bs-tertiary-bg); + font-size: 0.82rem; +} +.automation-actions, +.automation-row-actions { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + align-items: center; } - .automation-row { display: flex; justify-content: space-between; @@ -1280,16 +1303,49 @@ body.mobile-mode .mobile-card { margin-bottom: 0.45rem; background: var(--bs-body-bg); } - -.automation-row-actions { +.automation-row-main { + min-width: 0; +} +.automation-action-pill { display: inline-flex; - flex-shrink: 0; - gap: 0.35rem; + max-width: 100%; + margin: 0.1rem; + padding: 0.15rem 0.4rem; + border-radius: 999px; + background: var(--bs-secondary-bg); + font-size: 0.78rem; + white-space: normal; +} +.automation-history-details { + max-width: min(620px, 60vw); +} +.automation-history-details summary { + cursor: pointer; + list-style-position: inside; +} +.automation-history-details pre, +.automation-history-raw { + max-width: 100%; + max-height: 220px; + margin: 0.35rem 0 0; + padding: 0.5rem; + overflow: auto; + border: 1px solid var(--bs-border-color); + border-radius: 0.5rem; + background: var(--bs-tertiary-bg); + white-space: pre-wrap; + word-break: break-word; } @media (max-width: 900px) { - .automation-form-grid { + .automation-rule-grid, + .automation-builder-grid { grid-template-columns: 1fr; } + .automation-path-input, + .automation-history-details { + grid-column: auto; + max-width: 100%; + } } .disk-status { display: inline-flex; diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index 0069d83..eb02036 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -148,7 +148,7 @@ - + From 98f155b53a673b090020302d19e8c9a9075082df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 6 May 2026 23:07:19 +0200 Subject: [PATCH 04/13] labels and automatizations --- pytorrent/config.py | 1 + pytorrent/routes/api.py | 43 +++++- pytorrent/services/automation_rules.py | 107 +++++++------- pytorrent/services/retention.py | 3 +- pytorrent/services/rtorrent.py | 124 +++++++++------- pytorrent/services/smart_queue.py | 88 +++++++---- pytorrent/static/app.js | 152 +++---------------- pytorrent/static/styles.css | 195 ++++++++++++++----------- pytorrent/templates/index.html | 2 +- 9 files changed, 365 insertions(+), 350 deletions(-) diff --git a/pytorrent/config.py b/pytorrent/config.py index 751db11..8458539 100644 --- a/pytorrent/config.py +++ b/pytorrent/config.py @@ -69,5 +69,6 @@ SOCKETIO_CORS_ALLOWED_ORIGINS = None if not _SOCKETIO_CORS else [item.strip() fo TRAFFIC_HISTORY_RETENTION_DAYS = _env_int("PYTORRENT_TRAFFIC_HISTORY_RETENTION_DAYS", 90, 1) JOBS_RETENTION_DAYS = _env_int("PYTORRENT_JOBS_RETENTION_DAYS", 30, 1) SMART_QUEUE_HISTORY_RETENTION_DAYS = _env_int("PYTORRENT_SMART_QUEUE_HISTORY_RETENTION_DAYS", 30, 1) +AUTOMATION_HISTORY_RETENTION_DAYS = _env_int("PYTORRENT_AUTOMATION_HISTORY_RETENTION_DAYS", 30, 1) LOG_RETENTION_DAYS = _env_int("PYTORRENT_LOG_RETENTION_DAYS", 30, 1) SMART_QUEUE_LABEL = os.getenv("PYTORRENT_SMART_QUEUE_LABEL", "Smart Queue Paused") diff --git a/pytorrent/routes/api.py b/pytorrent/routes/api.py index 51add59..59fc6fb 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": AUTOMATION_HISTORY_RETENTION_DAYS, }, "database": _db_size(), } @@ -731,6 +733,16 @@ def cleanup_smart_queue(): return ok({"deleted": deleted, "cleanup": cleanup_summary()}) +@bp.post("/cleanup/automations") +def cleanup_automations(): + from ..services import automation_rules + profile = preferences.active_profile() + if not profile: + return jsonify({"ok": False, "error": "No profile"}), 400 + deleted = automation_rules.clear_history(profile["id"]) + return ok({"deleted": deleted, "cleanup": cleanup_summary()}) + + @bp.post("/cleanup/all") def cleanup_all(): deleted_jobs = clear_jobs() @@ -741,7 +753,10 @@ 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()}) + from ..services import automation_rules + profile = preferences.active_profile() + deleted_automation = automation_rules.clear_history(profile["id"]) if profile else 0 + return ok({"deleted": {"jobs": deleted_jobs, "smart_queue_history": deleted_smart, "automation_history": deleted_automation}, "cleanup": cleanup_summary()}) @bp.post("/jobs//cancel") @@ -1052,6 +1067,32 @@ def automations_delete(rule_id: int): return jsonify({'ok': False, 'error': str(exc)}), 400 +@bp.delete('/automations/history/') +def automations_history_delete(history_id: int): + from ..services import automation_rules + profile = preferences.active_profile() + if not profile: + return jsonify({'ok': False, 'error': 'No profile'}), 400 + try: + deleted = automation_rules.delete_history_item(history_id, profile['id']) + return ok({'deleted': deleted, 'history': automation_rules.list_history(profile['id'])}) + except Exception as exc: + return jsonify({'ok': False, 'error': str(exc)}), 400 + + +@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: + deleted = automation_rules.clear_history(profile['id']) + return ok({'deleted': deleted, 'history': []}) + except Exception as exc: + return jsonify({'ok': False, 'error': str(exc)}), 400 + + @bp.post('/automations/check') def automations_check(): from ..services import automation_rules diff --git a/pytorrent/services/automation_rules.py b/pytorrent/services/automation_rules.py index ce73eaf..fdc45a4 100644 --- a/pytorrent/services/automation_rules.py +++ b/pytorrent/services/automation_rules.py @@ -88,10 +88,26 @@ def delete_rule(rule_id: int, profile_id: int, user_id: int | None = None) -> No conn.execute('DELETE FROM automation_rule_state WHERE rule_id=? AND profile_id=?', (rule_id, profile_id)) -def list_history(profile_id: int, user_id: int | None = None, limit: int = 30) -> list[dict[str, Any]]: +def list_history(profile_id: int, user_id: int | None = None, limit: int = 50) -> list[dict[str, Any]]: user_id = user_id or default_user_id() with connect() as conn: - 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() + 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 50), 200)))).fetchall() + + +def delete_history_item(history_id: int, profile_id: int, user_id: int | None = None) -> int: + # Note: Allow removing a single automation history card from the UI without touching rules. + user_id = user_id or default_user_id() + with connect() as conn: + cur = conn.execute('DELETE FROM automation_history WHERE id=? AND user_id=? AND profile_id=?', (int(history_id), user_id, profile_id)) + return int(cur.rowcount or 0) + + +def clear_history(profile_id: int, user_id: int | None = None) -> int: + # Note: History cleanup is separate from deleting automation rules. + user_id = user_id or default_user_id() + with connect() as conn: + 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: @@ -113,8 +129,8 @@ def _conditions_match(conn, rule: dict[str, Any], profile_id: int, t: dict[str, for cond in rule.get('conditions') or []: raw_ok = _condition_true(t, cond) negated = bool(cond.get('negate')) - # Note: Negation is applied in the backend, so UI and API only store the condition flag. ok = (not raw_ok) if negated else raw_ok + # Note: Conditions can now be negated in automation rules. Timed no-seeds keeps its old delayed behavior only for the positive condition, so old rules do not change. if cond.get('type') == 'no_seeds' and int(cond.get('minutes') or 0) > 0 and not negated: row = conn.execute('SELECT condition_since_at FROM automation_rule_state WHERE rule_id=? AND profile_id=? AND torrent_hash=?', (rule['id'], profile_id, h)).fetchone() if ok: @@ -128,7 +144,8 @@ def _conditions_match(conn, rule: dict[str, Any], profile_id: int, t: dict[str, return immediate_ok and delayed_ok -def _cooldown_ok(conn, rule: dict[str, Any], profile_id: int, torrent_hash: str = '__rule__') -> bool: +def _cooldown_ok(conn, rule: dict[str, Any], profile_id: int, torrent_hash: str = '*') -> bool: + # Note: Automation cooldown is rule-wide for batch execution; '*' stores the last run for the whole rule. cooldown = int(rule.get('cooldown_minutes') or 0) if cooldown <= 0: return True row = conn.execute('SELECT last_applied_at FROM automation_rule_state WHERE rule_id=? AND profile_id=? AND torrent_hash=?', (rule['id'], profile_id, torrent_hash)).fetchone() @@ -136,12 +153,12 @@ def _cooldown_ok(conn, rule: dict[str, Any], profile_id: int, torrent_hash: str return _now_ts() - _ts(row['last_applied_at']) >= cooldown * 60 -def _mark_rule_cooldown(conn, rule: dict[str, Any], profile_id: int, now: str) -> None: - # Note: Cooldown is rule-level, so one batch execution blocks the whole automation until the cooldown expires. - conn.execute('INSERT INTO automation_rule_state(rule_id,profile_id,torrent_hash,last_applied_at,updated_at) VALUES(?,?,?,?,?) ON CONFLICT(rule_id,profile_id,torrent_hash) DO UPDATE SET last_applied_at=excluded.last_applied_at, updated_at=excluded.updated_at', (rule['id'], profile_id, '__rule__', now, now)) +def _touch_rule_cooldown(conn, rule: dict[str, Any], profile_id: int, now: str) -> None: + 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, '*', now, now, now)) -def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str, Any]], effects: list[dict[str, Any]]) -> list[dict[str, Any]]: +def _apply_effects_batch(c: Any, profile: dict[str, Any], torrents: list[dict[str, Any]], effects: list[dict[str, Any]]) -> list[dict[str, Any]]: + # Note: Rules now execute actions once for all matching torrents instead of calling move/check/start one item at a time. hashes = [str(t.get('hash') or '') for t in torrents if str(t.get('hash') or '')] labels_by_hash = {str(t.get('hash') or ''): _label_names(t.get('label')) for t in torrents} applied: list[dict[str, Any]] = [] @@ -150,37 +167,36 @@ def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str typ = str(eff.get('type') or '') if typ == 'move': path = str(eff.get('path') or '').strip() or rtorrent.default_download_path(profile) - 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.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}) + 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, hashes, move_payload) if path else None + if path: applied.append({'type': 'move', 'path': path, 'count': len(hashes), '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() + changed = 0 if label: for h in hashes: - labels = labels_by_hash.setdefault(h, []) + labels = labels_by_hash.get(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); labels_by_hash[h] = labels; c.call('d.custom1.set', h, _label_value(labels)); changed += 1 + applied.append({'type': 'add_label', 'label': label, 'count': changed}) elif typ == 'remove_label': - label = str(eff.get('label') or '').strip() + label = str(eff.get('label') or '').strip(); changed = 0 if label: for h in 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)}) + labels = labels_by_hash.get(h, []) + new_labels = [x for x in labels if x != label] + if new_labels != labels: + labels_by_hash[h] = new_labels; c.call('d.custom1.set', h, _label_value(new_labels)); changed += 1 + applied.append({'type': 'remove_label', 'label': label, 'count': changed}) elif typ == 'set_labels': - value = _label_value(_label_names(eff.get('labels'))) + value = _label_value(_label_names(eff.get('labels'))); new_labels = _label_names(value) for h in hashes: - labels_by_hash[h] = _label_names(value); c.call('d.custom1.set', h, value) + labels_by_hash[h] = list(new_labels); c.call('d.custom1.set', h, value) applied.append({'type': 'set_labels', 'labels': value, 'count': len(hashes)}) elif typ in {'pause', 'stop', 'start', 'resume', 'recheck'}: - result = rtorrent.action(profile, hashes, typ, {}) - applied.append({'type': typ, 'count': len(hashes), 'result': result}) + method = {'pause':'d.pause','stop':'d.stop','start':'d.start','resume':'d.resume','recheck':'d.check_hash'}[typ] + for h in hashes: c.call(method, h) + applied.append({'type': typ, 'count': len(hashes)}) return applied @@ -189,30 +205,23 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = if not profile: return {'ok': False, 'error': 'No active rTorrent profile'} user_id = user_id or default_user_id(); profile_id = int(profile['id']) rules = [r for r in list_rules(profile_id, user_id) if force or int(r.get('enabled') or 0)] - if not rules: return {'ok': True, 'checked': 0, 'applied': [], 'batches': [], 'rules': 0} - torrents = rtorrent.list_torrents(profile); c = rtorrent.client_for(profile); applied = []; batches = []; now = utcnow() + if not rules: return {'ok': True, 'checked': 0, 'applied': [], 'rules': 0} + torrents = rtorrent.list_torrents(profile); c = rtorrent.client_for(profile); applied = []; now = utcnow() with connect() as conn: for rule in rules: - # Note: Automations now execute as one batch per rule, not as one independent action per torrent. - if not force and not _cooldown_ok(conn, rule, profile_id): - continue matched = [t for t in torrents if _conditions_match(conn, rule, profile_id, t)] - if not matched: - continue + if not matched: continue + if not force and not _cooldown_ok(conn, rule, profile_id, '*'): continue hashes = [str(t.get('hash') or '') for t in matched if str(t.get('hash') or '')] - if not hashes: - continue - 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 '') + names = [str(t.get('name') or '') for t in matched] + try: actions = _apply_effects_batch(c, profile, matched, rule.get('effects') or []) + except Exception as exc: actions = [{'error': str(exc), 'count': len(hashes)}] + _touch_rule_cooldown(conn, rule, profile_id, now) + for h in hashes: 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]}) - _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}) - return {'ok': True, 'checked': len(torrents), 'rules': len(rules), 'applied': applied, 'batches': batches} + history_payload = {'mode': 'batch', 'count': len(hashes), 'hashes': hashes, 'names': names[:50], 'actions': actions} + torrent_name = names[0] if len(names) == 1 else f'{len(hashes)} torrents' + torrent_hash = hashes[0] if len(hashes) == 1 else ','.join(hashes[:20]) + 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_payload), now)) + applied.append({'rule_id': rule['id'], 'rule_name': rule.get('name'), 'count': len(hashes), 'hashes': hashes, 'names': names[:20], 'actions': actions}) + return {'ok': True, 'checked': len(torrents), 'rules': len(rules), 'applied': applied} diff --git a/pytorrent/services/retention.py b/pytorrent/services/retention.py index d7a03a7..4c2bdbf 100644 --- a/pytorrent/services/retention.py +++ b/pytorrent/services/retention.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone -from ..config import JOBS_RETENTION_DAYS, LOG_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, TRAFFIC_HISTORY_RETENTION_DAYS +from ..config import AUTOMATION_HISTORY_RETENTION_DAYS, JOBS_RETENTION_DAYS, LOG_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, TRAFFIC_HISTORY_RETENTION_DAYS from ..db import connect _LAST_CLEANUP = 0.0 @@ -30,6 +30,7 @@ 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), + "automation_history": ("created_at", AUTOMATION_HISTORY_RETENTION_DAYS), "jobs": ("updated_at", JOBS_RETENTION_DAYS), "logs": ("created_at", LOG_RETENTION_DAYS), } diff --git a/pytorrent/services/rtorrent.py b/pytorrent/services/rtorrent.py index b853464..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,61 +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)) - keep_seeding = bool(payload.get("keep_seeding")) - # Note: Automations can force seeding after a physical move even if the torrent was not active before. - 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_after_move_error"] = str(exc) - else: - c.call("d.directory.set", h, path) - results.append(item) - return {"ok": True, "count": len(torrent_hashes), "move_data": move_data, "keep_seeding": keep_seeding, "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/services/smart_queue.py b/pytorrent/services/smart_queue.py index a8d64e8..1779ee3 100644 --- a/pytorrent/services/smart_queue.py +++ b/pytorrent/services/smart_queue.py @@ -132,6 +132,39 @@ 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: + out: list[str] = [] + for label in labels: + label = str(label or '').strip() + if label and label not in out: + out.append(label) + return ', '.join(out) + + +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 _with_smart_queue_label(value: str | None) -> str: + labels = _label_names(value) + if SMART_QUEUE_LABEL not in labels: + labels.append(SMART_QUEUE_LABEL) + return _label_value(labels) + + def _remember_auto_label(profile_id: int, torrent_hash: str, previous_label: str) -> None: now = utcnow() with connect() as conn: @@ -165,21 +198,18 @@ def _restore_auto_label(client: Any, profile_id: int, torrent_hash: str, current (profile_id, torrent_hash), ).fetchone() live_label = _read_label(client, torrent_hash, current_label or '') - if not row: - if live_label != SMART_QUEUE_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, '') - return True - except Exception: - return False - previous = row.get('previous_label') or '' + if not row and not _has_smart_queue_label(live_label): + return False + restored = _without_smart_queue_label(live_label) + previous = _without_smart_queue_label((row or {}).get('previous_label') or '') + if not restored and previous: + restored = previous 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: - 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)) + # Note: Smart Queue now removes only its technical label, preserving labels added manually while the torrent was waiting in the queue. + if _has_smart_queue_label(live_label) or current_label is None: + client.call('d.custom1.set', torrent_hash, restored) + if row: + conn.execute('DELETE FROM smart_queue_auto_labels WHERE profile_id=? AND torrent_hash=?', (profile_id, torrent_hash)) return True except Exception: return False @@ -282,10 +312,12 @@ 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 | None = None, attempts: int = 3) -> bool: + # Note: The queue label is appended as a technical label instead of replacing the user's labels. + target = _with_smart_queue_label(current_label if current_label is not None else _read_label(client, torrent_hash, '')) for attempt in range(max(1, attempts)): try: - client.call('d.custom1.set', torrent_hash, SMART_QUEUE_LABEL) + client.call('d.custom1.set', torrent_hash, target) 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: - _remember_auto_label(profile_id, torrent_hash, previous) - return _set_smart_queue_label(client, torrent_hash) + if not _has_smart_queue_label(previous): + _remember_auto_label(profile_id, torrent_hash, _without_smart_queue_label(previous)) + 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(torrent.get('label')): 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: Orphan cleanup removes only the Smart Queue technical label and keeps manual labels intact. + client.call('d.custom1.set', torrent_hash, _without_smart_queue_label(current_label)) return True except Exception: return False @@ -346,7 +378,7 @@ 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: + if not _has_smart_queue_label(current_label): _set_smart_queue_label(client, h) for h, t in by_hash.items(): @@ -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(t.get('label')): 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(t.get('label')): 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(t.get('label')) # 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(t.get('label')) 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..bf3f443 100644 --- a/pytorrent/static/app.js +++ b/pytorrent/static/app.js @@ -525,133 +525,24 @@ 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); } - let automationRulesCache=[]; - let automationConditions=[]; - let automationEffects=[]; - - function automationCondition(){ - const type=$('autoConditionType')?.value||'completed'; - const cond={type, negate:!!$('autoCondNegate')?.checked}; - 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=!!$('autoMoveData')?.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 conditionText(c={}){ - const base=c.type==='no_seeds'?`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 effectText(e={}){ - if(e.type==='move'){ - const flags=[]; - if(e.move_data) flags.push('move data'); - if(e.recheck) flags.push('recheck'); - if(e.keep_seeding) flags.push('keep seeding'); - return `move to ${e.path||'default path'}${flags.length?` (${flags.join(', ')})`:''}`; - } - return e.type==='add_label'?`add label ${e.label||''}`:e.type==='remove_label'?`remove label ${e.label||''}`:e.type==='set_labels'?`set labels ${e.labels||''}`:e.type; - } - function ruleSummary(r){ - const cs=(r.conditions||[]).map(conditionText).join(' + ')||'no conditions'; - const es=(r.effects||[]).map(effectText).join(' → ')||'no actions'; - return `${cs} → ${es}`; - } - - function renderAutomationBuilder(){ - const cBox=$('automationConditionList'); - if(cBox) cBox.innerHTML=automationConditions.length?automationConditions.map((c,i)=>`IF ${esc(conditionText(c))}`).join(''):'No conditions added yet.'; - const eBox=$('automationEffectList'); - if(eBox) eBox.innerHTML=automationEffects.length?automationEffects.map((e,i)=>`${i+1} ${esc(effectText(e))}`).join(''):'No actions added yet.'; - } - function resetAutomationForm(){ - if($('autoEditId')) $('autoEditId').value=''; - if($('autoName')) $('autoName').value=''; - if($('autoEnabled')) $('autoEnabled').checked=true; - if($('autoCooldown')) $('autoCooldown').value='60'; - automationConditions=[]; automationEffects=[]; - $('automationCancelEditBtn')?.classList.add('d-none'); - if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Save rule'; - renderAutomationBuilder(); updateAutomationForm(); - } - function editAutomationRule(rule){ - if(!rule) return; - if($('autoEditId')) $('autoEditId').value=rule.id||''; - if($('autoName')) $('autoName').value=rule.name||''; - if($('autoEnabled')) $('autoEnabled').checked=!!rule.enabled; - if($('autoCooldown')) $('autoCooldown').value=rule.cooldown_minutes ?? 60; - automationConditions=Array.isArray(rule.conditions)?JSON.parse(JSON.stringify(rule.conditions)):[]; - automationEffects=Array.isArray(rule.effects)?JSON.parse(JSON.stringify(rule.effects)):[]; - $('automationCancelEditBtn')?.classList.remove('d-none'); - if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Update rule'; - renderAutomationBuilder(); - } - - function summarizeActionObject(a={}){ - if(a.error) return `${esc(a.error)}`; - const count=a.count || a.result?.count || a.result?.results?.length || ''; - const parts=[]; - if(a.type) parts.push(a.type); - if(count) parts.push(`${count} torrent(s)`); - if(a.path) parts.push(a.path); - if(a.label) parts.push(`label ${a.label}`); - if(a.labels) parts.push(`labels ${a.labels}`); - if(a.move_data) parts.push('move data'); - if(a.recheck) parts.push('recheck'); - if(a.keep_seeding) parts.push('keep seeding'); - return `${esc(parts.join(' · ')||'action')}`; - } - function automationHistoryActions(raw){ - let actions=[]; - try{ actions=JSON.parse(raw||'[]'); }catch(e){ return `
${esc(raw||'')}
`; } - if(!Array.isArray(actions)) actions=[actions]; - const summary=actions.map(summarizeActionObject).join(' '); - const details=esc(JSON.stringify(actions,null,2)); - return `
${summary||'No actions'}
${details}
`; - } - - 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.
'; - } - - async function saveAutomation(){ - const currentCond=automationCondition(); - const currentEff=automationEffect(); - const conditions=automationConditions.length?automationConditions:[currentCond]; - const effects=automationEffects.length?automationEffects:[currentEff]; - const payload={id:Number($('autoEditId')?.value||0)||undefined,name:$('autoName')?.value||'Automation rule',enabled:!!$('autoEnabled')?.checked,cooldown_minutes:Number($('autoCooldown')?.value||60),conditions,effects}; - setBusy(true); - try{ await post('/api/automations',payload); toast(payload.id?'Automation rule updated':'Automation rule saved','success'); resetAutomationForm(); await loadAutomations(); } - catch(e){toast(e.message,'danger');} - finally{setBusy(false);} - } + // 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($('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 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}`; } + function parseAutomationActions(raw){ try{ const value=JSON.parse(raw||'{}'); if(Array.isArray(value)) return {actions:value,count:1,names:[]}; if(value && typeof value==='object') return {actions:value.actions||[],count:value.count||1,names:value.names||[],hashes:value.hashes||[]}; }catch(e){} return {actions:[],count:1,names:[],raw:raw||''}; } + function automationActionBadge(a){ if(a.error) return `error`; const count=a.count||a.result?.count||''; const tail=count?` · ${count}`:''; if(a.type==='move') return `move${tail}`; if(a.type==='remove_label') return `remove label${tail}`; if(a.type==='add_label') return `add label${tail}`; if(a.type==='recheck') return `recheck${tail}`; if(a.type==='start'||a.type==='resume') return `${esc(a.type)}${tail}`; return `${esc(a.type||'action')}${tail}`; } + function automationActionDetail(a){ if(a.error) return `
${esc(a.error)}
`; if(a.type==='move'){ const result=a.result||{}; return `
Path: ${esc(a.path||'')}
Files moved: ${a.move_data?'yes':'no'} · recheck: ${a.recheck?'yes':'no'} · seed: ${a.keep_seeding?'yes':'no'}
${result.ok!==undefined?`
Result: ${result.ok?'ok':'failed'} · ${esc(result.count||a.count||0)} item(s)
`:''}`; } if(a.label) return `
Label: ${esc(a.label)}
Changed: ${esc(a.count||1)}
`; if(a.labels) return `
Labels: ${esc(a.labels)}
`; return `
Count: ${esc(a.count||1)}
`; } + function renderAutomationHistory(hist){ const box=$('automationHistory'); if(!box) return; if(!hist.length){ box.innerHTML='
No automation history yet.
'; return; } const items=hist.map((h,i)=>{ const parsed=parseAutomationActions(h.actions_json); const title=parsed.count>1?`${parsed.count} torrents`:h.torrent_name||h.torrent_hash||'torrent'; const names=(parsed.names||[]).slice(0,20); const actions=parsed.actions||[]; return `
${esc(title)}
${esc(h.created_at)} · ${esc(h.rule_name||'')}
${actions.length?actions.map(automationActionBadge).join(''):'details'}
${names.length?``:''}
${actions.map(a=>`
${automationActionBadge(a)}${automationActionDetail(a)}
`).join('')||esc(parsed.raw||'')}
`; }).join(''); box.innerHTML=`
`; } + 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.
'; renderAutomationHistory(hist); 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);} } @@ -666,9 +557,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 history only, not rules.
`; } async function loadCleanup(){ const box=$('cleanupManager'); if(!box) return; @@ -803,8 +695,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 all cleanup 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();}); $('automationHistory')?.addEventListener('click',async e=>{ const toggle=e.target.closest('.automation-history-toggle'); if(toggle){ const detail=document.querySelector(`[data-detail-index="${CSS.escape(String(toggle.dataset.index||''))}"]`); detail?.classList.toggle('d-none'); return; } const del=e.target.closest('.automation-history-delete'); if(del){ if(!confirm('Delete this automation log?')) return; const r=await fetch('/api/automations/history/'+del.dataset.id,{method:'DELETE'}); const j=await r.json(); if(!j.ok)toast(j.error||'Delete failed','danger'); await loadAutomations(); return; } if(e.target.closest('#automationHistoryClearBtn')){ if(!confirm('Clear automation history?')) return; const r=await fetch('/api/automations/history',{method:'DELETE'}); const j=await r.json(); if(!j.ok)toast(j.error||'Clear 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 b26ea9c..6676a59 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -1238,60 +1238,37 @@ body.mobile-mode .mobile-card { color: var(--bs-primary-text-emphasis); } -.automation-shell { - display: grid; - gap: 0.75rem; -} -.automation-main-card { - padding: 0.75rem; - border: 1px solid var(--bs-border-color); - border-radius: 0.75rem; - background: var(--bs-body-bg); -} -.automation-card-title { - margin-bottom: 0.5rem; - font-weight: 700; -} -.automation-rule-grid, -.automation-builder-grid { +.automation-form-grid { display: grid; grid-template-columns: repeat(4, minmax(160px, 1fr)); gap: 0.5rem; align-items: center; } -.automation-enabled, -.automation-negate { - margin: 0; - padding: 0.45rem 0.6rem 0.45rem 2.5rem; - border: 1px solid var(--bs-border-color); - border-radius: 0.5rem; -} -.automation-path-input { - grid-column: span 2; -} -.automation-chip-list { - display: flex; - flex-wrap: wrap; + +.auto-move-option, +.auto-condition-option { gap: 0.45rem; + margin: 0; } -.automation-chip { - display: inline-flex; - align-items: center; - gap: 0.35rem; - max-width: 100%; - padding: 0.25rem 0.5rem; - border: 1px solid var(--bs-border-color); - border-radius: 999px; - background: var(--bs-tertiary-bg); - font-size: 0.82rem; -} -.automation-actions, -.automation-row-actions { - display: flex; - flex-wrap: wrap; + + +.automation-builder-list { + display: grid; + grid-column: 1 / -1; gap: 0.4rem; - align-items: center; } + +.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; @@ -1303,49 +1280,10 @@ body.mobile-mode .mobile-card { margin-bottom: 0.45rem; background: var(--bs-body-bg); } -.automation-row-main { - min-width: 0; -} -.automation-action-pill { - display: inline-flex; - max-width: 100%; - margin: 0.1rem; - padding: 0.15rem 0.4rem; - border-radius: 999px; - background: var(--bs-secondary-bg); - font-size: 0.78rem; - white-space: normal; -} -.automation-history-details { - max-width: min(620px, 60vw); -} -.automation-history-details summary { - cursor: pointer; - list-style-position: inside; -} -.automation-history-details pre, -.automation-history-raw { - max-width: 100%; - max-height: 220px; - margin: 0.35rem 0 0; - padding: 0.5rem; - overflow: auto; - border: 1px solid var(--bs-border-color); - border-radius: 0.5rem; - background: var(--bs-tertiary-bg); - white-space: pre-wrap; - word-break: break-word; -} @media (max-width: 900px) { - .automation-rule-grid, - .automation-builder-grid { + .automation-form-grid { grid-template-columns: 1fr; } - .automation-path-input, - .automation-history-details { - grid-column: auto; - max-width: 100%; - } } .disk-status { display: inline-flex; @@ -2233,3 +2171,90 @@ body.mobile-mode .mobile-filter-bar { width: 100%; } } + +/* Note: Automation history uses compact cards with horizontal scrolling so long torrent names never expand the modal. */ +.automation-history-toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: 0.5rem; +} + +.automation-history-carousel { + display: grid; + gap: 0.6rem; + max-height: 430px; + overflow-y: auto; + padding-right: 0.25rem; +} + +.automation-history-card { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.6rem; + padding: 0.65rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.7rem; + background: var(--bs-body-bg); +} + +.automation-history-main { + min-width: 0; +} + +.automation-history-title { + overflow: hidden; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.automation-history-buttons, +.automation-action-strip { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.automation-history-buttons { + align-content: flex-start; + justify-content: flex-end; +} + +.automation-history-detail { + grid-column: 1 / -1; + min-width: 0; +} + +.automation-name-carousel { + display: flex; + gap: 0.4rem; + overflow-x: auto; + padding-bottom: 0.35rem; +} + +.automation-name-carousel span { + flex: 0 0 auto; + max-width: 260px; + overflow: hidden; + padding: 0.25rem 0.45rem; + border: 1px solid var(--bs-border-color); + border-radius: 999px; + background: var(--bs-secondary-bg); + text-overflow: ellipsis; + white-space: nowrap; +} + +.automation-action-detail-list { + display: grid; + gap: 0.45rem; +} + +.automation-action-detail { + display: grid; + gap: 0.25rem; + min-width: 0; + padding: 0.5rem; + border-radius: 0.55rem; + background: var(--bs-tertiary-bg); + overflow-wrap: anywhere; +} diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index eb02036..b87f368 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -148,7 +148,7 @@ - + From 2691442fc18bfeb5e7ab4350f4ce1d6bcd0875c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 6 May 2026 23:10:53 +0200 Subject: [PATCH 05/13] revert --- pytorrent/config.py | 1 - pytorrent/routes/api.py | 43 +----- pytorrent/services/automation_rules.py | 107 ++++++-------- pytorrent/services/retention.py | 3 +- pytorrent/services/rtorrent.py | 124 +++++++--------- pytorrent/services/smart_queue.py | 88 ++++------- pytorrent/static/app.js | 152 ++++++++++++++++--- pytorrent/static/styles.css | 197 +++++++++++-------------- pytorrent/templates/index.html | 2 +- 9 files changed, 351 insertions(+), 366 deletions(-) diff --git a/pytorrent/config.py b/pytorrent/config.py index 8458539..751db11 100644 --- a/pytorrent/config.py +++ b/pytorrent/config.py @@ -69,6 +69,5 @@ SOCKETIO_CORS_ALLOWED_ORIGINS = None if not _SOCKETIO_CORS else [item.strip() fo TRAFFIC_HISTORY_RETENTION_DAYS = _env_int("PYTORRENT_TRAFFIC_HISTORY_RETENTION_DAYS", 90, 1) JOBS_RETENTION_DAYS = _env_int("PYTORRENT_JOBS_RETENTION_DAYS", 30, 1) SMART_QUEUE_HISTORY_RETENTION_DAYS = _env_int("PYTORRENT_SMART_QUEUE_HISTORY_RETENTION_DAYS", 30, 1) -AUTOMATION_HISTORY_RETENTION_DAYS = _env_int("PYTORRENT_AUTOMATION_HISTORY_RETENTION_DAYS", 30, 1) LOG_RETENTION_DAYS = _env_int("PYTORRENT_LOG_RETENTION_DAYS", 30, 1) SMART_QUEUE_LABEL = os.getenv("PYTORRENT_SMART_QUEUE_LABEL", "Smart Queue Paused") diff --git a/pytorrent/routes/api.py b/pytorrent/routes/api.py index 59fc6fb..51add59 100644 --- a/pytorrent/routes/api.py +++ b/pytorrent/routes/api.py @@ -326,11 +326,9 @@ 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": AUTOMATION_HISTORY_RETENTION_DAYS, }, "database": _db_size(), } @@ -733,16 +731,6 @@ def cleanup_smart_queue(): return ok({"deleted": deleted, "cleanup": cleanup_summary()}) -@bp.post("/cleanup/automations") -def cleanup_automations(): - from ..services import automation_rules - profile = preferences.active_profile() - if not profile: - return jsonify({"ok": False, "error": "No profile"}), 400 - deleted = automation_rules.clear_history(profile["id"]) - return ok({"deleted": deleted, "cleanup": cleanup_summary()}) - - @bp.post("/cleanup/all") def cleanup_all(): deleted_jobs = clear_jobs() @@ -753,10 +741,7 @@ def cleanup_all(): else: cur = conn.execute("DELETE FROM smart_queue_history") deleted_smart = int(cur.rowcount or 0) - from ..services import automation_rules - profile = preferences.active_profile() - deleted_automation = automation_rules.clear_history(profile["id"]) if profile else 0 - return ok({"deleted": {"jobs": deleted_jobs, "smart_queue_history": deleted_smart, "automation_history": deleted_automation}, "cleanup": cleanup_summary()}) + return ok({"deleted": {"jobs": deleted_jobs, "smart_queue_history": deleted_smart}, "cleanup": cleanup_summary()}) @bp.post("/jobs//cancel") @@ -1067,32 +1052,6 @@ def automations_delete(rule_id: int): return jsonify({'ok': False, 'error': str(exc)}), 400 -@bp.delete('/automations/history/') -def automations_history_delete(history_id: int): - from ..services import automation_rules - profile = preferences.active_profile() - if not profile: - return jsonify({'ok': False, 'error': 'No profile'}), 400 - try: - deleted = automation_rules.delete_history_item(history_id, profile['id']) - return ok({'deleted': deleted, 'history': automation_rules.list_history(profile['id'])}) - except Exception as exc: - return jsonify({'ok': False, 'error': str(exc)}), 400 - - -@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: - deleted = automation_rules.clear_history(profile['id']) - return ok({'deleted': deleted, 'history': []}) - except Exception as exc: - return jsonify({'ok': False, 'error': str(exc)}), 400 - - @bp.post('/automations/check') def automations_check(): from ..services import automation_rules diff --git a/pytorrent/services/automation_rules.py b/pytorrent/services/automation_rules.py index fdc45a4..ce73eaf 100644 --- a/pytorrent/services/automation_rules.py +++ b/pytorrent/services/automation_rules.py @@ -88,26 +88,10 @@ def delete_rule(rule_id: int, profile_id: int, user_id: int | None = None) -> No conn.execute('DELETE FROM automation_rule_state WHERE rule_id=? AND profile_id=?', (rule_id, profile_id)) -def list_history(profile_id: int, user_id: int | None = None, limit: int = 50) -> list[dict[str, Any]]: +def list_history(profile_id: int, user_id: int | None = None, limit: int = 30) -> list[dict[str, Any]]: user_id = user_id or default_user_id() with connect() as conn: - 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 50), 200)))).fetchall() - - -def delete_history_item(history_id: int, profile_id: int, user_id: int | None = None) -> int: - # Note: Allow removing a single automation history card from the UI without touching rules. - user_id = user_id or default_user_id() - with connect() as conn: - cur = conn.execute('DELETE FROM automation_history WHERE id=? AND user_id=? AND profile_id=?', (int(history_id), user_id, profile_id)) - return int(cur.rowcount or 0) - - -def clear_history(profile_id: int, user_id: int | None = None) -> int: - # Note: History cleanup is separate from deleting automation rules. - user_id = user_id or default_user_id() - with connect() as conn: - cur = conn.execute('DELETE FROM automation_history WHERE user_id=? AND profile_id=?', (user_id, profile_id)) - return int(cur.rowcount or 0) + 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 _condition_true(t: dict[str, Any], cond: dict[str, Any]) -> bool: @@ -129,8 +113,8 @@ def _conditions_match(conn, rule: dict[str, Any], profile_id: int, t: dict[str, for cond in rule.get('conditions') or []: raw_ok = _condition_true(t, cond) negated = bool(cond.get('negate')) + # Note: Negation is applied in the backend, so UI and API only store the condition flag. ok = (not raw_ok) if negated else raw_ok - # Note: Conditions can now be negated in automation rules. Timed no-seeds keeps its old delayed behavior only for the positive condition, so old rules do not change. if cond.get('type') == 'no_seeds' and int(cond.get('minutes') or 0) > 0 and not negated: row = conn.execute('SELECT condition_since_at FROM automation_rule_state WHERE rule_id=? AND profile_id=? AND torrent_hash=?', (rule['id'], profile_id, h)).fetchone() if ok: @@ -144,8 +128,7 @@ def _conditions_match(conn, rule: dict[str, Any], profile_id: int, t: dict[str, return immediate_ok and delayed_ok -def _cooldown_ok(conn, rule: dict[str, Any], profile_id: int, torrent_hash: str = '*') -> bool: - # Note: Automation cooldown is rule-wide for batch execution; '*' stores the last run for the whole rule. +def _cooldown_ok(conn, rule: dict[str, Any], profile_id: int, torrent_hash: str = '__rule__') -> bool: cooldown = int(rule.get('cooldown_minutes') or 0) if cooldown <= 0: return True row = conn.execute('SELECT last_applied_at FROM automation_rule_state WHERE rule_id=? AND profile_id=? AND torrent_hash=?', (rule['id'], profile_id, torrent_hash)).fetchone() @@ -153,12 +136,12 @@ def _cooldown_ok(conn, rule: dict[str, Any], profile_id: int, torrent_hash: str return _now_ts() - _ts(row['last_applied_at']) >= cooldown * 60 -def _touch_rule_cooldown(conn, rule: dict[str, Any], profile_id: int, now: str) -> None: - 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, '*', now, now, now)) +def _mark_rule_cooldown(conn, rule: dict[str, Any], profile_id: int, now: str) -> None: + # Note: Cooldown is rule-level, so one batch execution blocks the whole automation until the cooldown expires. + conn.execute('INSERT INTO automation_rule_state(rule_id,profile_id,torrent_hash,last_applied_at,updated_at) VALUES(?,?,?,?,?) ON CONFLICT(rule_id,profile_id,torrent_hash) DO UPDATE SET last_applied_at=excluded.last_applied_at, updated_at=excluded.updated_at', (rule['id'], profile_id, '__rule__', now, now)) -def _apply_effects_batch(c: Any, profile: dict[str, Any], torrents: list[dict[str, Any]], effects: list[dict[str, Any]]) -> list[dict[str, Any]]: - # Note: Rules now execute actions once for all matching torrents instead of calling move/check/start one item at a time. +def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str, Any]], effects: list[dict[str, Any]]) -> list[dict[str, Any]]: hashes = [str(t.get('hash') or '') for t in torrents if str(t.get('hash') or '')] labels_by_hash = {str(t.get('hash') or ''): _label_names(t.get('label')) for t in torrents} applied: list[dict[str, Any]] = [] @@ -167,36 +150,37 @@ def _apply_effects_batch(c: Any, profile: dict[str, Any], torrents: list[dict[st typ = str(eff.get('type') or '') if typ == 'move': path = str(eff.get('path') or '').strip() or rtorrent.default_download_path(profile) - 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, hashes, move_payload) if path else None - if path: applied.append({'type': 'move', 'path': path, 'count': len(hashes), 'move_data': bool(eff.get('move_data')), 'recheck': bool(move_payload['recheck']), 'keep_seeding': bool(eff.get('keep_seeding')), 'result': result}) + 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.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}) elif typ == 'add_label': label = str(eff.get('label') or '').strip() - changed = 0 if label: for h in hashes: - labels = labels_by_hash.get(h, []) + labels = labels_by_hash.setdefault(h, []) if label not in labels: - labels.append(label); labels_by_hash[h] = labels; c.call('d.custom1.set', h, _label_value(labels)); changed += 1 - applied.append({'type': 'add_label', 'label': label, 'count': changed}) + labels.append(label); c.call('d.custom1.set', h, _label_value(labels)) + applied.append({'type': 'add_label', 'label': label, 'count': len(hashes)}) elif typ == 'remove_label': - label = str(eff.get('label') or '').strip(); changed = 0 + label = str(eff.get('label') or '').strip() if label: for h in hashes: - labels = labels_by_hash.get(h, []) - new_labels = [x for x in labels if x != label] - if new_labels != labels: - labels_by_hash[h] = new_labels; c.call('d.custom1.set', h, _label_value(new_labels)); changed += 1 - applied.append({'type': 'remove_label', 'label': label, 'count': changed}) + 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)}) elif typ == 'set_labels': - value = _label_value(_label_names(eff.get('labels'))); new_labels = _label_names(value) + value = _label_value(_label_names(eff.get('labels'))) for h in hashes: - labels_by_hash[h] = list(new_labels); c.call('d.custom1.set', h, value) + labels_by_hash[h] = _label_names(value); c.call('d.custom1.set', h, value) applied.append({'type': 'set_labels', 'labels': value, 'count': len(hashes)}) elif typ in {'pause', 'stop', 'start', 'resume', 'recheck'}: - method = {'pause':'d.pause','stop':'d.stop','start':'d.start','resume':'d.resume','recheck':'d.check_hash'}[typ] - for h in hashes: c.call(method, h) - applied.append({'type': typ, 'count': len(hashes)}) + result = rtorrent.action(profile, hashes, typ, {}) + applied.append({'type': typ, 'count': len(hashes), 'result': result}) return applied @@ -205,23 +189,30 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = if not profile: return {'ok': False, 'error': 'No active rTorrent profile'} user_id = user_id or default_user_id(); profile_id = int(profile['id']) rules = [r for r in list_rules(profile_id, user_id) if force or int(r.get('enabled') or 0)] - if not rules: return {'ok': True, 'checked': 0, 'applied': [], 'rules': 0} - torrents = rtorrent.list_torrents(profile); c = rtorrent.client_for(profile); applied = []; now = utcnow() + if not rules: return {'ok': True, 'checked': 0, 'applied': [], 'batches': [], 'rules': 0} + torrents = rtorrent.list_torrents(profile); c = rtorrent.client_for(profile); applied = []; batches = []; now = utcnow() with connect() as conn: for rule in rules: + # Note: Automations now execute as one batch per rule, not as one independent action per torrent. + if not force and not _cooldown_ok(conn, rule, profile_id): + continue matched = [t for t in torrents if _conditions_match(conn, rule, profile_id, t)] - if not matched: continue - if not force and not _cooldown_ok(conn, rule, profile_id, '*'): continue + if not matched: + continue hashes = [str(t.get('hash') or '') for t in matched if str(t.get('hash') or '')] - names = [str(t.get('name') or '') for t in matched] - try: actions = _apply_effects_batch(c, profile, matched, rule.get('effects') or []) - except Exception as exc: actions = [{'error': str(exc), 'count': len(hashes)}] - _touch_rule_cooldown(conn, rule, profile_id, now) - for h in hashes: + if not hashes: + continue + 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 '') 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)) - history_payload = {'mode': 'batch', 'count': len(hashes), 'hashes': hashes, 'names': names[:50], 'actions': actions} - torrent_name = names[0] if len(names) == 1 else f'{len(hashes)} torrents' - torrent_hash = hashes[0] if len(hashes) == 1 else ','.join(hashes[:20]) - 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_payload), now)) - applied.append({'rule_id': rule['id'], 'rule_name': rule.get('name'), 'count': len(hashes), 'hashes': hashes, 'names': names[:20], 'actions': actions}) - return {'ok': True, 'checked': len(torrents), 'rules': len(rules), 'applied': applied} + 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]}) + _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}) + return {'ok': True, 'checked': len(torrents), 'rules': len(rules), 'applied': applied, 'batches': batches} diff --git a/pytorrent/services/retention.py b/pytorrent/services/retention.py index 4c2bdbf..d7a03a7 100644 --- a/pytorrent/services/retention.py +++ b/pytorrent/services/retention.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone -from ..config import AUTOMATION_HISTORY_RETENTION_DAYS, JOBS_RETENTION_DAYS, LOG_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, TRAFFIC_HISTORY_RETENTION_DAYS +from ..config import JOBS_RETENTION_DAYS, LOG_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, TRAFFIC_HISTORY_RETENTION_DAYS from ..db import connect _LAST_CLEANUP = 0.0 @@ -30,7 +30,6 @@ 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), - "automation_history": ("created_at", AUTOMATION_HISTORY_RETENTION_DAYS), "jobs": ("updated_at", JOBS_RETENTION_DAYS), "logs": ("created_at", LOG_RETENTION_DAYS), } diff --git a/pytorrent/services/rtorrent.py b/pytorrent/services/rtorrent.py index 68632f8..b853464 100644 --- a/pytorrent/services/rtorrent.py +++ b/pytorrent/services/rtorrent.py @@ -1274,73 +1274,6 @@ 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) @@ -1361,8 +1294,61 @@ 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": - # Note: Main move delegates to the shared helper used by automations. - return move_torrents(profile, torrent_hashes, payload) + 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: Automations can force seeding after a physical move even if the torrent was not active before. + 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_after_move_error"] = str(exc) + else: + c.call("d.directory.set", h, path) + results.append(item) + return {"ok": True, "count": len(torrent_hashes), "move_data": move_data, "keep_seeding": keep_seeding, "results": results} 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/services/smart_queue.py b/pytorrent/services/smart_queue.py index 1779ee3..a8d64e8 100644 --- a/pytorrent/services/smart_queue.py +++ b/pytorrent/services/smart_queue.py @@ -132,39 +132,6 @@ 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: - out: list[str] = [] - for label in labels: - label = str(label or '').strip() - if label and label not in out: - out.append(label) - return ', '.join(out) - - -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 _with_smart_queue_label(value: str | None) -> str: - labels = _label_names(value) - if SMART_QUEUE_LABEL not in labels: - labels.append(SMART_QUEUE_LABEL) - return _label_value(labels) - - def _remember_auto_label(profile_id: int, torrent_hash: str, previous_label: str) -> None: now = utcnow() with connect() as conn: @@ -198,18 +165,21 @@ def _restore_auto_label(client: Any, profile_id: int, torrent_hash: str, current (profile_id, torrent_hash), ).fetchone() live_label = _read_label(client, torrent_hash, current_label or '') - if not row and not _has_smart_queue_label(live_label): - return False - restored = _without_smart_queue_label(live_label) - previous = _without_smart_queue_label((row or {}).get('previous_label') or '') - if not restored and previous: - restored = previous + if not row: + if live_label != SMART_QUEUE_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, '') + return True + except Exception: + return False + previous = row.get('previous_label') or '' try: - # Note: Smart Queue now removes only its technical label, preserving labels added manually while the torrent was waiting in the queue. - if _has_smart_queue_label(live_label) or current_label is None: - client.call('d.custom1.set', torrent_hash, restored) - if row: - conn.execute('DELETE FROM smart_queue_auto_labels WHERE profile_id=? AND torrent_hash=?', (profile_id, torrent_hash)) + # 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: + 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 except Exception: return False @@ -312,12 +282,10 @@ 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, current_label: str | None = None, attempts: int = 3) -> bool: - # Note: The queue label is appended as a technical label instead of replacing the user's labels. - target = _with_smart_queue_label(current_label if current_label is not None else _read_label(client, torrent_hash, '')) +def _set_smart_queue_label(client: Any, torrent_hash: str, attempts: int = 3) -> bool: for attempt in range(max(1, attempts)): try: - client.call('d.custom1.set', torrent_hash, target) + client.call('d.custom1.set', torrent_hash, SMART_QUEUE_LABEL) return True except Exception: if attempt < attempts - 1: @@ -330,15 +298,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 not _has_smart_queue_label(previous): - _remember_auto_label(profile_id, torrent_hash, _without_smart_queue_label(previous)) - return _set_smart_queue_label(client, torrent_hash, previous) + if previous != SMART_QUEUE_LABEL: + _remember_auto_label(profile_id, torrent_hash, previous) + return _set_smart_queue_label(client, torrent_hash) 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 _has_smart_queue_label(torrent.get('label')): + if str(torrent.get('label') or '') == SMART_QUEUE_LABEL: 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. @@ -351,11 +319,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 not _has_smart_queue_label(current_label): + if current_label != SMART_QUEUE_LABEL: return False try: - # Note: Orphan cleanup removes only the Smart Queue technical label and keeps manual labels intact. - client.call('d.custom1.set', torrent_hash, _without_smart_queue_label(current_label)) + # Note: Clear an orphaned Smart Queue label when no previous-label entry exists in the database. + client.call('d.custom1.set', torrent_hash, '') return True except Exception: return False @@ -378,7 +346,7 @@ 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 not _has_smart_queue_label(current_label): + if current_label != SMART_QUEUE_LABEL: _set_smart_queue_label(client, h) for h, t in by_hash.items(): @@ -395,7 +363,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 _has_smart_queue_label(t.get('label')): + if str(t.get('label') or '') == SMART_QUEUE_LABEL: return False status = str(t.get('status') or '').lower() if status == 'checking' or status == 'paused' or bool(t.get('paused')): @@ -407,7 +375,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 _has_smart_queue_label(t.get('label')): + if str(t.get('label') or '') == SMART_QUEUE_LABEL: 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': @@ -438,7 +406,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 _has_smart_queue_label(t.get('label')) + return str(t.get('label') or '') == SMART_QUEUE_LABEL # Note: Count Smart Queue slots by d.is_active because Paused can have state=1/open=1 and must not occupy the limit. downloading = [ @@ -570,7 +538,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 _has_smart_queue_label(t.get('label')) and str(t.get('hash') or '') not in set(resumed)} + | {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)} ) 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 bf3f443..e0ff916 100644 --- a/pytorrent/static/app.js +++ b/pytorrent/static/app.js @@ -525,24 +525,133 @@ 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($('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 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}`; } - function parseAutomationActions(raw){ try{ const value=JSON.parse(raw||'{}'); if(Array.isArray(value)) return {actions:value,count:1,names:[]}; if(value && typeof value==='object') return {actions:value.actions||[],count:value.count||1,names:value.names||[],hashes:value.hashes||[]}; }catch(e){} return {actions:[],count:1,names:[],raw:raw||''}; } - function automationActionBadge(a){ if(a.error) return `error`; const count=a.count||a.result?.count||''; const tail=count?` · ${count}`:''; if(a.type==='move') return `move${tail}`; if(a.type==='remove_label') return `remove label${tail}`; if(a.type==='add_label') return `add label${tail}`; if(a.type==='recheck') return `recheck${tail}`; if(a.type==='start'||a.type==='resume') return `${esc(a.type)}${tail}`; return `${esc(a.type||'action')}${tail}`; } - function automationActionDetail(a){ if(a.error) return `
${esc(a.error)}
`; if(a.type==='move'){ const result=a.result||{}; return `
Path: ${esc(a.path||'')}
Files moved: ${a.move_data?'yes':'no'} · recheck: ${a.recheck?'yes':'no'} · seed: ${a.keep_seeding?'yes':'no'}
${result.ok!==undefined?`
Result: ${result.ok?'ok':'failed'} · ${esc(result.count||a.count||0)} item(s)
`:''}`; } if(a.label) return `
Label: ${esc(a.label)}
Changed: ${esc(a.count||1)}
`; if(a.labels) return `
Labels: ${esc(a.labels)}
`; return `
Count: ${esc(a.count||1)}
`; } - function renderAutomationHistory(hist){ const box=$('automationHistory'); if(!box) return; if(!hist.length){ box.innerHTML='
No automation history yet.
'; return; } const items=hist.map((h,i)=>{ const parsed=parseAutomationActions(h.actions_json); const title=parsed.count>1?`${parsed.count} torrents`:h.torrent_name||h.torrent_hash||'torrent'; const names=(parsed.names||[]).slice(0,20); const actions=parsed.actions||[]; return `
${esc(title)}
${esc(h.created_at)} · ${esc(h.rule_name||'')}
${actions.length?actions.map(automationActionBadge).join(''):'details'}
${names.length?``:''}
${actions.map(a=>`
${automationActionBadge(a)}${automationActionDetail(a)}
`).join('')||esc(parsed.raw||'')}
`; }).join(''); box.innerHTML=`
`; } - 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.
'; renderAutomationHistory(hist); 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);} } + let automationRulesCache=[]; + let automationConditions=[]; + let automationEffects=[]; + + function automationCondition(){ + const type=$('autoConditionType')?.value||'completed'; + const cond={type, negate:!!$('autoCondNegate')?.checked}; + 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=!!$('autoMoveData')?.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 conditionText(c={}){ + const base=c.type==='no_seeds'?`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 effectText(e={}){ + if(e.type==='move'){ + const flags=[]; + if(e.move_data) flags.push('move data'); + if(e.recheck) flags.push('recheck'); + if(e.keep_seeding) flags.push('keep seeding'); + return `move to ${e.path||'default path'}${flags.length?` (${flags.join(', ')})`:''}`; + } + return e.type==='add_label'?`add label ${e.label||''}`:e.type==='remove_label'?`remove label ${e.label||''}`:e.type==='set_labels'?`set labels ${e.labels||''}`:e.type; + } + function ruleSummary(r){ + const cs=(r.conditions||[]).map(conditionText).join(' + ')||'no conditions'; + const es=(r.effects||[]).map(effectText).join(' → ')||'no actions'; + return `${cs} → ${es}`; + } + + function renderAutomationBuilder(){ + const cBox=$('automationConditionList'); + if(cBox) cBox.innerHTML=automationConditions.length?automationConditions.map((c,i)=>`IF ${esc(conditionText(c))}`).join(''):'No conditions added yet.'; + const eBox=$('automationEffectList'); + if(eBox) eBox.innerHTML=automationEffects.length?automationEffects.map((e,i)=>`${i+1} ${esc(effectText(e))}`).join(''):'No actions added yet.'; + } + function resetAutomationForm(){ + if($('autoEditId')) $('autoEditId').value=''; + if($('autoName')) $('autoName').value=''; + if($('autoEnabled')) $('autoEnabled').checked=true; + if($('autoCooldown')) $('autoCooldown').value='60'; + automationConditions=[]; automationEffects=[]; + $('automationCancelEditBtn')?.classList.add('d-none'); + if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Save rule'; + renderAutomationBuilder(); updateAutomationForm(); + } + function editAutomationRule(rule){ + if(!rule) return; + if($('autoEditId')) $('autoEditId').value=rule.id||''; + if($('autoName')) $('autoName').value=rule.name||''; + if($('autoEnabled')) $('autoEnabled').checked=!!rule.enabled; + if($('autoCooldown')) $('autoCooldown').value=rule.cooldown_minutes ?? 60; + automationConditions=Array.isArray(rule.conditions)?JSON.parse(JSON.stringify(rule.conditions)):[]; + automationEffects=Array.isArray(rule.effects)?JSON.parse(JSON.stringify(rule.effects)):[]; + $('automationCancelEditBtn')?.classList.remove('d-none'); + if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Update rule'; + renderAutomationBuilder(); + } + + function summarizeActionObject(a={}){ + if(a.error) return `${esc(a.error)}`; + const count=a.count || a.result?.count || a.result?.results?.length || ''; + const parts=[]; + if(a.type) parts.push(a.type); + if(count) parts.push(`${count} torrent(s)`); + if(a.path) parts.push(a.path); + if(a.label) parts.push(`label ${a.label}`); + if(a.labels) parts.push(`labels ${a.labels}`); + if(a.move_data) parts.push('move data'); + if(a.recheck) parts.push('recheck'); + if(a.keep_seeding) parts.push('keep seeding'); + return `${esc(parts.join(' · ')||'action')}`; + } + function automationHistoryActions(raw){ + let actions=[]; + try{ actions=JSON.parse(raw||'[]'); }catch(e){ return `
${esc(raw||'')}
`; } + if(!Array.isArray(actions)) actions=[actions]; + const summary=actions.map(summarizeActionObject).join(' '); + const details=esc(JSON.stringify(actions,null,2)); + return `
${summary||'No actions'}
${details}
`; + } + + 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.
'; + } + + async function saveAutomation(){ + const currentCond=automationCondition(); + const currentEff=automationEffect(); + const conditions=automationConditions.length?automationConditions:[currentCond]; + const effects=automationEffects.length?automationEffects:[currentEff]; + const payload={id:Number($('autoEditId')?.value||0)||undefined,name:$('autoName')?.value||'Automation rule',enabled:!!$('autoEnabled')?.checked,cooldown_minutes:Number($('autoCooldown')?.value||60),conditions,effects}; + setBusy(true); + try{ await post('/api/automations',payload); toast(payload.id?'Automation rule updated':'Automation rule saved','success'); resetAutomationForm(); await loadAutomations(); } + catch(e){toast(e.message,'danger');} + finally{setBusy(false);} + } @@ -557,10 +666,9 @@ 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. Automation cleanup removes history only, not rules.
`; + box.innerHTML=`
${cards.join('')}
Job cleanup preserves pending and running jobs. Use Jobs modal for emergency clear when unfinished jobs must be removed.
`; } async function loadCleanup(){ const box=$('cleanupManager'); if(!box) return; @@ -695,8 +803,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('#cleanupAutomationsBtn')) return runCleanupAction('/api/cleanup/automations','Clear automation logs'); if(e.target.closest('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear all cleanup 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();}); $('automationHistory')?.addEventListener('click',async e=>{ const toggle=e.target.closest('.automation-history-toggle'); if(toggle){ const detail=document.querySelector(`[data-detail-index="${CSS.escape(String(toggle.dataset.index||''))}"]`); detail?.classList.toggle('d-none'); return; } const del=e.target.closest('.automation-history-delete'); if(del){ if(!confirm('Delete this automation log?')) return; const r=await fetch('/api/automations/history/'+del.dataset.id,{method:'DELETE'}); const j=await r.json(); if(!j.ok)toast(j.error||'Delete failed','danger'); await loadAutomations(); return; } if(e.target.closest('#automationHistoryClearBtn')){ if(!confirm('Clear automation history?')) return; const r=await fetch('/api/automations/history',{method:'DELETE'}); const j=await r.json(); if(!j.ok)toast(j.error||'Clear 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();}); + $('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();}); 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 6676a59..b26ea9c 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -1238,37 +1238,60 @@ body.mobile-mode .mobile-card { color: var(--bs-primary-text-emphasis); } -.automation-form-grid { +.automation-shell { + display: grid; + gap: 0.75rem; +} +.automation-main-card { + padding: 0.75rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + background: var(--bs-body-bg); +} +.automation-card-title { + margin-bottom: 0.5rem; + font-weight: 700; +} +.automation-rule-grid, +.automation-builder-grid { display: grid; grid-template-columns: repeat(4, minmax(160px, 1fr)); gap: 0.5rem; align-items: center; } - -.auto-move-option, -.auto-condition-option { - gap: 0.45rem; +.automation-enabled, +.automation-negate { 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; + padding: 0.45rem 0.6rem 0.45rem 2.5rem; border: 1px solid var(--bs-border-color); - border-radius: 0.55rem; - background: var(--bs-secondary-bg); + border-radius: 0.5rem; +} +.automation-path-input { + grid-column: span 2; +} +.automation-chip-list { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} +.automation-chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + max-width: 100%; + padding: 0.25rem 0.5rem; + border: 1px solid var(--bs-border-color); + border-radius: 999px; + background: var(--bs-tertiary-bg); + font-size: 0.82rem; +} +.automation-actions, +.automation-row-actions { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + align-items: center; } - .automation-row { display: flex; justify-content: space-between; @@ -1280,10 +1303,49 @@ body.mobile-mode .mobile-card { margin-bottom: 0.45rem; background: var(--bs-body-bg); } +.automation-row-main { + min-width: 0; +} +.automation-action-pill { + display: inline-flex; + max-width: 100%; + margin: 0.1rem; + padding: 0.15rem 0.4rem; + border-radius: 999px; + background: var(--bs-secondary-bg); + font-size: 0.78rem; + white-space: normal; +} +.automation-history-details { + max-width: min(620px, 60vw); +} +.automation-history-details summary { + cursor: pointer; + list-style-position: inside; +} +.automation-history-details pre, +.automation-history-raw { + max-width: 100%; + max-height: 220px; + margin: 0.35rem 0 0; + padding: 0.5rem; + overflow: auto; + border: 1px solid var(--bs-border-color); + border-radius: 0.5rem; + background: var(--bs-tertiary-bg); + white-space: pre-wrap; + word-break: break-word; +} @media (max-width: 900px) { - .automation-form-grid { + .automation-rule-grid, + .automation-builder-grid { grid-template-columns: 1fr; } + .automation-path-input, + .automation-history-details { + grid-column: auto; + max-width: 100%; + } } .disk-status { display: inline-flex; @@ -2171,90 +2233,3 @@ body.mobile-mode .mobile-filter-bar { width: 100%; } } - -/* Note: Automation history uses compact cards with horizontal scrolling so long torrent names never expand the modal. */ -.automation-history-toolbar { - display: flex; - justify-content: flex-end; - margin-bottom: 0.5rem; -} - -.automation-history-carousel { - display: grid; - gap: 0.6rem; - max-height: 430px; - overflow-y: auto; - padding-right: 0.25rem; -} - -.automation-history-card { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 0.6rem; - padding: 0.65rem; - border: 1px solid var(--bs-border-color); - border-radius: 0.7rem; - background: var(--bs-body-bg); -} - -.automation-history-main { - min-width: 0; -} - -.automation-history-title { - overflow: hidden; - font-weight: 600; - text-overflow: ellipsis; - white-space: nowrap; -} - -.automation-history-buttons, -.automation-action-strip { - display: flex; - flex-wrap: wrap; - gap: 0.35rem; -} - -.automation-history-buttons { - align-content: flex-start; - justify-content: flex-end; -} - -.automation-history-detail { - grid-column: 1 / -1; - min-width: 0; -} - -.automation-name-carousel { - display: flex; - gap: 0.4rem; - overflow-x: auto; - padding-bottom: 0.35rem; -} - -.automation-name-carousel span { - flex: 0 0 auto; - max-width: 260px; - overflow: hidden; - padding: 0.25rem 0.45rem; - border: 1px solid var(--bs-border-color); - border-radius: 999px; - background: var(--bs-secondary-bg); - text-overflow: ellipsis; - white-space: nowrap; -} - -.automation-action-detail-list { - display: grid; - gap: 0.45rem; -} - -.automation-action-detail { - display: grid; - gap: 0.25rem; - min-width: 0; - padding: 0.5rem; - border-radius: 0.55rem; - background: var(--bs-tertiary-bg); - overflow-wrap: anywhere; -} diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index b87f368..eb02036 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -148,7 +148,7 @@ - + From 440b187c391e4784f7932767556346d2086e01ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 7 May 2026 07:24:16 +0200 Subject: [PATCH 06/13] automatyzacje-comit1 --- pytorrent/routes/api.py | 37 +++++++++++++- pytorrent/services/automation_rules.py | 8 +++ pytorrent/services/retention.py | 2 + pytorrent/services/smart_queue.py | 70 +++++++++++++++++++------- pytorrent/static/app.js | 28 +++++++++-- pytorrent/static/styles.css | 8 +++ 6 files changed, 128 insertions(+), 25 deletions(-) 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; 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 07/13] 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; From f9280daa2188561075056430334b00d448d92c2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 7 May 2026 08:36:00 +0200 Subject: [PATCH 08/13] automatyzacje-comit3 --- pytorrent/config.py | 1 + pytorrent/db.py | 2 + pytorrent/routes/main.py | 2 +- pytorrent/services/smart_queue.py | 101 ++++++++++++++++++++++-------- pytorrent/static/app.js | 8 +-- pytorrent/static/styles.css | 22 +++++-- pytorrent/templates/index.html | 2 +- 7 files changed, 100 insertions(+), 38 deletions(-) diff --git a/pytorrent/config.py b/pytorrent/config.py index 751db11..cac301b 100644 --- a/pytorrent/config.py +++ b/pytorrent/config.py @@ -71,3 +71,4 @@ JOBS_RETENTION_DAYS = _env_int("PYTORRENT_JOBS_RETENTION_DAYS", 30, 1) SMART_QUEUE_HISTORY_RETENTION_DAYS = _env_int("PYTORRENT_SMART_QUEUE_HISTORY_RETENTION_DAYS", 30, 1) LOG_RETENTION_DAYS = _env_int("PYTORRENT_LOG_RETENTION_DAYS", 30, 1) SMART_QUEUE_LABEL = os.getenv("PYTORRENT_SMART_QUEUE_LABEL", "Smart Queue Paused") +SMART_QUEUE_STALLED_LABEL = os.getenv("PYTORRENT_SMART_QUEUE_STALLED_LABEL", "Stalled") diff --git a/pytorrent/db.py b/pytorrent/db.py index ed8cac9..67186bf 100644 --- a/pytorrent/db.py +++ b/pytorrent/db.py @@ -139,6 +139,7 @@ CREATE TABLE IF NOT EXISTS smart_queue_settings ( stalled_seconds INTEGER DEFAULT 300, min_speed_bytes INTEGER DEFAULT 1024, min_seeds INTEGER DEFAULT 1, + min_peers INTEGER DEFAULT 0, manage_stopped INTEGER DEFAULT 0, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id) @@ -280,6 +281,7 @@ MIGRATIONS = [ "ALTER TABLE rtorrent_config_overrides ADD COLUMN baseline_value TEXT", "ALTER TABLE torrent_stats_cache ADD COLUMN updated_epoch REAL DEFAULT 0", "ALTER TABLE smart_queue_settings ADD COLUMN manage_stopped INTEGER DEFAULT 0", + "ALTER TABLE smart_queue_settings ADD COLUMN min_peers INTEGER DEFAULT 0", ] diff --git a/pytorrent/routes/main.py b/pytorrent/routes/main.py index 0d2c9f3..9cd4c4c 100644 --- a/pytorrent/routes/main.py +++ b/pytorrent/routes/main.py @@ -106,7 +106,7 @@ def openapi(): "/api/rss/feeds": {"post": {"summary": "Add RSS feed", "requestBody": {"content": {"application/json": {"schema": {"type": "object"}}}}, "responses": {"200": {"description": "RSS config"}}}}, "/api/rss/rules": {"post": {"summary": "Add RSS rule", "requestBody": {"content": {"application/json": {"schema": {"type": "object"}}}}, "responses": {"200": {"description": "RSS config"}}}}, "/api/rss/check": {"post": {"summary": "Manually check RSS feeds", "responses": {"200": {"description": "Queued matches"}}}}, - "/api/smart-queue": {"get": {"summary": "Get Smart Queue settings, exceptions and history", "parameters": [{"name": "history_limit", "in": "query", "schema": {"type": "integer", "default": 10, "minimum": 1, "maximum": 100}, "description": "Number of Smart Queue history rows to return"}], "responses": {"200": {"description": "Smart Queue config with history and history_total"}}}, "post": {"summary": "Save Smart Queue settings", "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"enabled": {"type": "boolean"}, "max_active_downloads": {"type": "integer"}, "stalled_seconds": {"type": "integer"}, "min_speed_bytes": {"type": "integer"}, "min_seeds": {"type": "integer"}}}}}}, "responses": {"200": {"description": "Saved"}}}}, + "/api/smart-queue": {"get": {"summary": "Get Smart Queue settings, exceptions and history", "parameters": [{"name": "history_limit", "in": "query", "schema": {"type": "integer", "default": 10, "minimum": 1, "maximum": 100}, "description": "Number of Smart Queue history rows to return"}], "responses": {"200": {"description": "Smart Queue config with history and history_total"}}}, "post": {"summary": "Save Smart Queue settings", "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"enabled": {"type": "boolean"}, "max_active_downloads": {"type": "integer"}, "stalled_seconds": {"type": "integer"}, "min_speed_bytes": {"type": "integer"}, "min_seeds": {"type": "integer"}, "min_peers": {"type": "integer"}}}}}}, "responses": {"200": {"description": "Saved"}}}}, "/api/smart-queue/check": {"post": {"summary": "Run Smart Queue immediately", "responses": {"200": {"description": "Smart Queue action result"}}}}, "/api/smart-queue/exclusion": {"post": {"summary": "Add or remove a torrent Smart Queue exception", "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"hash": {"type": "string"}, "excluded": {"type": "boolean"}, "reason": {"type": "string"}}}}}}, "responses": {"200": {"description": "Exception list"}}}}, "/api/traffic/history": {"get": {"summary": "Transfer history for charts", "parameters": [{"name": "range", "in": "query", "schema": {"type": "string", "enum": ["15m", "1h", "3h", "6h", "24h", "7d", "30d", "90d"]}}], "responses": {"200": {"description": "Aggregated traffic history"}}}} diff --git a/pytorrent/services/smart_queue.py b/pytorrent/services/smart_queue.py index 65d4836..7a1b6d2 100644 --- a/pytorrent/services/smart_queue.py +++ b/pytorrent/services/smart_queue.py @@ -5,7 +5,7 @@ from typing import Any import json import time -from ..config import SMART_QUEUE_LABEL +from ..config import SMART_QUEUE_LABEL, SMART_QUEUE_STALLED_LABEL from ..db import connect, default_user_id, utcnow from . import rtorrent from .preferences import active_profile, get_profile @@ -20,6 +20,14 @@ def _ts(value: str | None) -> float: return 0.0 +def _int_setting(data: dict[str, Any], current: dict[str, Any], key: str, default: int, minimum: int = 0) -> int: + raw = data.get(key) if key in data else current.get(key) + try: + return max(minimum, int(raw if raw is not None and raw != '' else default)) + except (TypeError, ValueError): + return max(minimum, int(default)) + + def _default_settings(user_id: int, profile_id: int) -> dict[str, Any]: return { 'user_id': user_id, @@ -29,6 +37,7 @@ def _default_settings(user_id: int, profile_id: int) -> dict[str, Any]: 'stalled_seconds': 300, 'min_speed_bytes': 1024, 'min_seeds': 1, + 'min_peers': 0, 'manage_stopped': 0, 'updated_at': utcnow(), } @@ -49,27 +58,30 @@ def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = N current = get_settings(profile_id, user_id) settings = { 'enabled': 1 if data.get('enabled', current.get('enabled')) else 0, - 'max_active_downloads': max(1, int(data.get('max_active_downloads') or current.get('max_active_downloads') or 5)), - 'stalled_seconds': max(30, int(data.get('stalled_seconds') or current.get('stalled_seconds') or 300)), - 'min_speed_bytes': max(0, int(data.get('min_speed_bytes') or current.get('min_speed_bytes') or 0)), - 'min_seeds': max(0, int(data.get('min_seeds') or current.get('min_seeds') or 0)), + 'max_active_downloads': _int_setting(data, current, 'max_active_downloads', 5, 1), + 'stalled_seconds': _int_setting(data, current, 'stalled_seconds', 300, 30), + 'min_speed_bytes': _int_setting(data, current, 'min_speed_bytes', 0, 0), + 'min_seeds': _int_setting(data, current, 'min_seeds', 0, 0), + # Note: Min peers is optional; when set, stalled detection requires low speed, low seeds and low peers. + 'min_peers': _int_setting(data, current, 'min_peers', 0, 0), # Note: This switch protects fully stopped torrents from automatic starts; by default Smart Queue manages only paused items. 'manage_stopped': 1 if data.get('manage_stopped', current.get('manage_stopped')) else 0, } now = utcnow() with connect() as conn: conn.execute( - '''INSERT INTO smart_queue_settings(user_id,profile_id,enabled,max_active_downloads,stalled_seconds,min_speed_bytes,min_seeds,manage_stopped,updated_at) - VALUES(?,?,?,?,?,?,?,?,?) + '''INSERT INTO smart_queue_settings(user_id,profile_id,enabled,max_active_downloads,stalled_seconds,min_speed_bytes,min_seeds,min_peers,manage_stopped,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(user_id, profile_id) DO UPDATE SET enabled=excluded.enabled, max_active_downloads=excluded.max_active_downloads, stalled_seconds=excluded.stalled_seconds, min_speed_bytes=excluded.min_speed_bytes, min_seeds=excluded.min_seeds, + min_peers=excluded.min_peers, manage_stopped=excluded.manage_stopped, updated_at=excluded.updated_at''', - (user_id, profile_id, settings['enabled'], settings['max_active_downloads'], settings['stalled_seconds'], settings['min_speed_bytes'], settings['min_seeds'], settings['manage_stopped'], now), + (user_id, profile_id, settings['enabled'], settings['max_active_downloads'], settings['stalled_seconds'], settings['min_speed_bytes'], settings['min_seeds'], settings['min_peers'], settings['manage_stopped'], now), ) return get_settings(profile_id, user_id) @@ -158,6 +170,32 @@ def _has_smart_queue_label(value: str | None) -> bool: 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 _has_stalled_label(value: str | None) -> bool: + return SMART_QUEUE_STALLED_LABEL in _label_names(value) + + +def _without_queue_technical_labels(value: str | None) -> str: + return _label_value([label for label in _label_names(value) if label != SMART_QUEUE_LABEL]) + + +def _ensure_stalled_label(client: Any, torrent_hash: str, current_label: str = '') -> bool: + labels = [label for label in _label_names(current_label) if label != SMART_QUEUE_LABEL] + changed = False + if SMART_QUEUE_STALLED_LABEL not in labels: + labels.append(SMART_QUEUE_STALLED_LABEL) + changed = True + if SMART_QUEUE_LABEL in _label_names(current_label): + changed = True + if not changed: + return True + try: + # Note: Stalled marking is idempotent; it adds Stalled and removes only the Smart Queue technical marker. + client.call('d.custom1.set', torrent_hash, _label_value(labels)) + return True + except Exception: + return False + def _remember_auto_label(profile_id: int, torrent_hash: str, previous_label: str) -> None: now = utcnow() with connect() as conn: @@ -200,11 +238,10 @@ def _restore_auto_label(client: Any, profile_id: int, torrent_hash: str, current return True except Exception: return False - previous = row.get('previous_label') or '' try: - # 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) + # Note: Starting a torrent removes only Smart Queue's technical marker, so labels added while paused stay untouched. + if _has_smart_queue_label(live_label): + client.call('d.custom1.set', torrent_hash, _without_smart_queue_label(live_label)) conn.execute('DELETE FROM smart_queue_auto_labels WHERE profile_id=? AND torrent_hash=?', (profile_id, torrent_hash)) return True except Exception: @@ -338,6 +375,8 @@ def _mark_auto_paused(client: Any, profile_id: int, torrent: dict[str, Any]) -> 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 _has_stalled_label(str(torrent.get('label') or '')): + return False 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. @@ -395,7 +434,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 _has_smart_queue_label(str(t.get('label') or '')): + if _has_smart_queue_label(str(t.get('label') or '')) or _has_stalled_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')): @@ -407,6 +446,8 @@ 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 _has_stalled_label(str(t.get('label') or '')): + return False 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. @@ -457,6 +498,7 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = ] min_speed = int(settings.get('min_speed_bytes') or 0) min_seeds = int(settings.get('min_seeds') or 0) + min_peers = int(settings.get('min_peers') or 0) stalled_seconds = int(settings.get('stalled_seconds') or 300) now = utcnow() now_ts = datetime.now(timezone.utc).timestamp() @@ -464,7 +506,8 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = with connect() as conn: for t in downloading: - is_stalled = int(t.get('down_rate') or 0) <= min_speed and int(t.get('seeds') or 0) <= min_seeds + # Note: Stalled detection requires low speed plus low seeds and, when configured, low peers. + is_stalled = int(t.get('down_rate') or 0) <= min_speed and int(t.get('seeds') or 0) <= min_seeds and (min_peers <= 0 or int(t.get('peers') or 0) <= min_peers) h = t.get('hash') if not h: continue @@ -504,13 +547,12 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = to_pause: list[dict[str, Any]] = pause_rank[:max(0, len(downloading) - max_active)] pause_hashes = {str(t.get('hash') or '') for t in to_pause} - # Note: Stalled rotation runs only when the queue is full. When slots are missing, Smart Queue should - # first add missing items instead of pausing existing or incorrectly detected stalled items. - if candidates and len(downloading) >= max_active: - replaceable_stalled = [t for t in stalled if str(t.get('hash') or '') not in pause_hashes] - for t in replaceable_stalled[:max(0, len(candidates) - len(to_pause))]: + # Note: Confirmed stalled downloads are removed from the active queue immediately, then new candidates can fill those slots. + for t in stalled: + h = str(t.get('hash') or '') + if h and h not in pause_hashes: to_pause.append(t) - pause_hashes.add(str(t.get('hash') or '')) + pause_hashes.add(h) active_after_pause = max(0, len(downloading) - len(to_pause)) available_slots = max(0, max_active - active_after_pause) @@ -523,6 +565,7 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = paused: list[str] = [] resumed: list[str] = [] label_failed: list[str] = [] + stalled_labeled: list[str] = [] start_failed: list[dict[str, str]] = [] start_no_effect: list[dict[str, Any]] = [] resume_requested: list[str] = [] @@ -530,12 +573,18 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = for t in to_pause: try: - pause_result = rtorrent.pause_hash(c, t['hash']) + h = str(t.get('hash') or '') + pause_result = rtorrent.pause_hash(c, h) if not pause_result.get('ok'): raise RuntimeError(pause_result.get('error') or 'pause failed') - if not _mark_auto_paused(c, profile_id, t): - label_failed.append(t['hash']) - paused.append(t['hash']) + if h in stalled_hashes: + if _ensure_stalled_label(c, h, _read_label(c, h, str(t.get('label') or ''))): + stalled_labeled.append(h) + else: + label_failed.append(h) + elif not _mark_auto_paused(c, profile_id, t): + label_failed.append(h) + paused.append(h) except Exception: pass @@ -573,6 +622,6 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = | {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} + details = {'excluded': len(excluded), 'enabled': bool(settings.get('enabled')), 'auto_label': SMART_QUEUE_LABEL, 'stalled_label': SMART_QUEUE_STALLED_LABEL, 'stalled_labeled': stalled_labeled, '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} add_history(profile_id, 'force_check' if force else 'auto_check', paused, resumed, len(torrents), details, user_id) - return {'ok': True, 'enabled': bool(settings.get('enabled')), 'paused': paused, 'resumed': resumed, 'resume_requested': resume_requested, 'waiting_labeled': len(to_label_waiting), 'labels_restored': restored, 'labels_failed': label_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'active_verified': active_verified, 'rtorrent_cap': rtorrent_cap, 'checked': len(torrents), 'excluded': len(excluded), 'settings': settings} + return {'ok': True, 'enabled': bool(settings.get('enabled')), 'paused': paused, 'resumed': resumed, 'resume_requested': resume_requested, 'waiting_labeled': len(to_label_waiting), 'stalled_labeled': stalled_labeled, 'labels_restored': restored, 'labels_failed': label_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'active_verified': active_verified, 'rtorrent_cap': rtorrent_cap, 'checked': len(torrents), 'excluded': len(excluded), 'settings': settings} diff --git a/pytorrent/static/app.js b/pytorrent/static/app.js index bfd4d88..b4a59ff 100644 --- a/pytorrent/static/app.js +++ b/pytorrent/static/app.js @@ -383,9 +383,9 @@ $('ratioAssignModal')?.addEventListener('show.bs.modal',loadRatios); $('applyRatioBtn')?.addEventListener('click',async()=>{ await runAction('set_ratio_group',{ratio_group:$('ratioAssignSelect').value}); bootstrap.Modal.getInstance($('ratioAssignModal'))?.hide(); }); $('ratioSaveBtn')?.addEventListener('click',async()=>{ await post('/api/ratio-groups',{name:$('ratioName').value,min_ratio:$('ratioMin').value,max_ratio:$('ratioMax').value,seed_time_minutes:$('ratioSeed').value,action:$('ratioAction').value}); loadRatios(); }); async function loadRss(){ const j=await (await fetch('/api/rss')).json(); const feeds=j.feeds||[], rules=j.rules||[]; if($('rssManager')) $('rssManager').innerHTML=`
Feeds
${table(['Name','URL','Last error'],feeds.map(f=>[esc(f.name),esc(f.url),esc(f.last_error||'')]))}
Rules
${table(['Name','Pattern','Path','Label'],rules.map(r=>[esc(r.name),esc(r.pattern),esc(r.save_path),esc(r.label)]))}`; } - async function loadSmartQueue(){ if($('smartManager')) $('smartManager').innerHTML=loadingMarkup('Loading Smart Queue...'); if($('smartHistory')) $('smartHistory').innerHTML=loadingMarkup('Loading Smart Queue history...'); const historyLimit=smartHistoryExpanded?100:10; const j=await (await fetch(`/api/smart-queue?history_limit=${historyLimit}`)).json(); if(!j.ok) return; const st=j.settings||{}, ex=j.exclusions||[], hist=j.history||[]; const totalHistory=Number(j.history_total ?? hist.length); if($('smartEnabled')) $('smartEnabled').checked=!!st.enabled; if($('smartManageStopped')) $('smartManageStopped').checked=!!st.manage_stopped; if($('smartMaxActive')) $('smartMaxActive').value=st.max_active_downloads||5; if($('smartStalled')) $('smartStalled').value=st.stalled_seconds||300; if($('smartMinSpeed')) $('smartMinSpeed').value=Math.round((st.min_speed_bytes||0)/1024); if($('smartMinSeeds')) $('smartMinSeeds').value=st.min_seeds||1; if($('smartManager')) $('smartManager').innerHTML=ex.length?table(['Hash','Reason','Created','Action'],ex.map(x=>[esc(x.torrent_hash),esc(x.reason||''),dateCell(x.created_at),``])):'
No Smart Queue exceptions. Select torrents and use Exclude selected to keep them outside the queue.
'; if($('smartHistory')) { const body=hist.length?table(['Time','Event','Checked','Paused','Resumed'],hist.map(h=>[dateCell(h.created_at),esc(h.event),esc(h.checked_count||0),esc(h.paused_count||0),esc(h.resumed_count||0)])):'
No Smart Queue operations yet.
'; const canToggle=totalHistory>10; const toggle=canToggle?``:''; $('smartHistory').innerHTML=`${body}${toggle}`; } } + async function loadSmartQueue(){ if($('smartManager')) $('smartManager').innerHTML=loadingMarkup('Loading Smart Queue...'); if($('smartHistory')) $('smartHistory').innerHTML=loadingMarkup('Loading Smart Queue history...'); const historyLimit=smartHistoryExpanded?100:10; const j=await (await fetch(`/api/smart-queue?history_limit=${historyLimit}`)).json(); if(!j.ok) return; const st=j.settings||{}, ex=j.exclusions||[], hist=j.history||[]; const totalHistory=Number(j.history_total ?? hist.length); if($('smartEnabled')) $('smartEnabled').checked=!!st.enabled; if($('smartManageStopped')) $('smartManageStopped').checked=!!st.manage_stopped; if($('smartMaxActive')) $('smartMaxActive').value=st.max_active_downloads||5; if($('smartStalled')) $('smartStalled').value=st.stalled_seconds||300; if($('smartMinSpeed')) $('smartMinSpeed').value=Math.round((st.min_speed_bytes||0)/1024); if($('smartMinSeeds')) $('smartMinSeeds').value=st.min_seeds||1; if($('smartMinPeers')) $('smartMinPeers').value=st.min_peers||0; if($('smartManager')) $('smartManager').innerHTML=ex.length?table(['Hash','Reason','Created','Action'],ex.map(x=>[esc(x.torrent_hash),esc(x.reason||''),dateCell(x.created_at),``])):'
No Smart Queue exceptions. Select torrents and use Exclude selected to keep them outside the queue.
'; if($('smartHistory')) { const body=hist.length?table(['Time','Event','Checked','Paused','Resumed'],hist.map(h=>[dateCell(h.created_at),esc(h.event),esc(h.checked_count||0),esc(h.paused_count||0),esc(h.resumed_count||0)])):'
No Smart Queue operations yet.
'; const canToggle=totalHistory>10; const toggle=canToggle?``:''; $('smartHistory').innerHTML=`${body}${toggle}`; } } async function setSmartException(hashes, excluded, reason='manual'){ const list=[...new Set(hashes||[])].filter(Boolean); if(!list.length) return toast('No torrents selected','warning'); setBusy(true); try{ for(const h of list) await post('/api/smart-queue/exclusion',{hash:h,excluded,reason}); toast(excluded?'Smart Queue exception added':'Smart Queue exception removed','success'); await loadSmartQueue(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} } - async function saveSmartQueue(){ await post('/api/smart-queue',{enabled:$('smartEnabled')?.checked,manage_stopped:$('smartManageStopped')?.checked,max_active_downloads:$('smartMaxActive')?.value,stalled_seconds:$('smartStalled')?.value,min_speed_bytes:Math.round(Number($('smartMinSpeed')?.value||0)*1024),min_seeds:$('smartMinSeeds')?.value}); toast('Smart Queue saved','success'); await loadSmartQueue(); } + async function saveSmartQueue(){ await post('/api/smart-queue',{enabled:$('smartEnabled')?.checked,manage_stopped:$('smartManageStopped')?.checked,max_active_downloads:$('smartMaxActive')?.value,stalled_seconds:$('smartStalled')?.value,min_speed_bytes:Math.round(Number($('smartMinSpeed')?.value||0)*1024),min_seeds:$('smartMinSeeds')?.value,min_peers:$('smartMinPeers')?.value}); toast('Smart Queue saved','success'); await loadSmartQueue(); } async function loadAuthUsers(){ if(!window.PYTORRENT.authEnabled || !$('authUsersManager')) return; @@ -839,7 +839,7 @@ }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('#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');}); + $('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 stalled=r.stalled_labeled?.length||0; const tail=noEffect?`, no effect ${noEffect}`:requested?`, requested ${requested}`:''; const waitTail=waiting?`, waiting labeled ${waiting}`:''; const stalledTail=stalled?`, stalled ${stalled}`:''; toast(`Smart Queue: paused ${r.paused?.length||0}, resumed ${r.resumed?.length||0}${tail}${waitTail}${stalledTail}${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 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);}); @@ -1011,6 +1011,6 @@ ${disk.error}`:''}`; b.classList.add("btn-primary"); b.classList.remove("btn-outline-secondary"); loadTrafficHistory(b.dataset.range||"7d"); })); - socket.on('connect',()=>{ if(!hasActiveProfile){ showFirstRunSetup(); return; } $('connBadge').className='badge text-bg-success'; $('connBadge').textContent='online'; setInitialLoader('Loading torrents...','Connection is ready. Waiting for the first torrent snapshot.'); socket.emit('select_profile',{profile_id:window.PYTORRENT.activeProfile}); }); socket.on('disconnect',()=>{ $('connBadge').className='badge text-bg-danger'; $('connBadge').textContent='offline'; setInitialLoader('Waiting for connection...','pyTorrent is not connected yet. The application will open after data is received.'); }); socket.io.on('reconnect_attempt',()=>{ $('connBadge').className='badge text-bg-warning'; $('connBadge').textContent='reconnecting'; setInitialLoader('Reconnecting...','Trying to restore the live connection and load torrent data.'); }); socket.io.on('reconnect',()=>{ if(!hasActiveProfile){ showFirstRunSetup(); return; } $('connBadge').className='badge text-bg-success'; $('connBadge').textContent='online'; setInitialLoader('Loading torrents...','Connection restored. Waiting for the first torrent snapshot.'); socket.emit('select_profile',{profile_id:window.PYTORRENT.activeProfile}); }); socket.on('profile_required',()=>showFirstRunSetup()); socket.on('torrent_snapshot',msg=>{hasTorrentSnapshot=true;torrentSummary=msg.summary||null;torrents.clear();(msg.torrents||[]).forEach(t=>torrents.set(t.hash,t));scheduleRender(true);hideInitialLoader();}); socket.on('torrent_patch',patchRows); socket.on('job_update',()=>{ if(document.body.classList.contains('modal-open')) loadJobs().catch(()=>{}); }); socket.on('operation_started',msg=>{setBusy(true);markTorrentOperation(msg.hashes||[],msg.action,msg.job_id,'running');toast(`${msg.action} started`,'secondary');}); socket.on('operation_finished',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);toast(`${msg.action} done`,'success');}); socket.on('operation_failed',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);toast(`${msg.action}: ${msg.error}`,'danger');}); socket.on('rtorrent_error',msg=>{ if(msg.error){$('connBadge').className='badge badge-degraded';$('connBadge').textContent='degraded'; setInitialLoader('Waiting for rTorrent...','rTorrent is not ready yet. Data will appear automatically after it responds.');} }); socket.on('heartbeat',msg=>{ if(msg.error){$('connBadge').className='badge badge-degraded';$('connBadge').textContent='degraded'; setInitialLoader('Waiting for rTorrent...','rTorrent is not ready yet. Data will appear automatically after it responds.');} else if(socket.connected){$('connBadge').className='badge text-bg-success';$('connBadge').textContent='online';} }); socket.on('smart_queue_update',msg=>{ if(msg && msg.enabled){ const noEffect=msg.start_no_effect?.length||0; const requested=msg.resume_requested?.length||0; const cap=msg.rtorrent_cap?.updated?`, cap ${msg.rtorrent_cap.current}->${msg.rtorrent_cap.new}`:''; const waiting=msg.waiting_labeled||0; const tail=noEffect?`, no effect ${noEffect}`:requested?`, requested ${requested}`:''; const waitTail=waiting?`, waiting labeled ${waiting}`:''; toast(`Smart Queue: paused ${msg.paused?.length||0}, resumed ${msg.resumed?.length||0}${tail}${waitTail}${cap}`,'secondary'); } }); socket.on('automation_update',msg=>{ if(msg?.applied?.length) toast(`Automations applied ${msg.applied.length} item(s)`,'secondary'); }); socket.on('torrent_stats_update',msg=>{ if(msg?.stats){ renderTorrentStats(msg.stats); } else if(msg?.error && $('toolTorrentStats') && !$('toolTorrentStats').classList.contains('d-none')){ toast(`Torrent stats: ${msg.error}`,'danger'); } }); socket.on('rtorrent_config_applied',msg=>{ if(msg?.result?.updated?.length) toast(`Startup rTorrent config applied (${msg.result.updated.length})`,'success'); if(msg?.error) toast(`Startup rTorrent config: ${msg.error}`,'danger'); }); socket.on('system_stats',s=>{ const usageAvailable=s.usage_available!==false && s.cpu!==undefined && s.ram!==undefined; $('statCpuBox')?.classList.toggle('d-none',!usageAvailable);$('statRamBox')?.classList.toggle('d-none',!usageAvailable);$('systemChart')?.classList.toggle('d-none',!usageAvailable); if(usageAvailable){$('statCpu').textContent=s.cpu??'-';$('statRam').textContent=s.ram??'-';drawSystemUsage(s.cpu,s.ram);} $('statVersion').textContent=s.version||'-';$('statDl').textContent=s.down_rate_h||'0 B/s';$('statUl').textContent=s.up_rate_h||'0 B/s';if($('mobileSpeedDl')) $('mobileSpeedDl').textContent=s.down_rate_h||'0 B/s';if($('mobileSpeedUl')) $('mobileSpeedUl').textContent=s.up_rate_h||'0 B/s';lastLimits={down:Number(s.down_limit||0),up:Number(s.up_limit||0)};$('statDlLimit').textContent=s.down_limit_h||'∞';$('statUlLimit').textContent=s.up_limit_h||'∞';$('statTotalDl').textContent=compactTransferText(s.total_down_h);$('statTotalUl').textContent=compactTransferText(s.total_up_h);drawTraffic(s.down_rate,s.up_rate);drawDiskUsage(s.disk);updateSocketStatus(s);applyFooterPreferences();}); + socket.on('connect',()=>{ if(!hasActiveProfile){ showFirstRunSetup(); return; } $('connBadge').className='badge text-bg-success'; $('connBadge').textContent='online'; setInitialLoader('Loading torrents...','Connection is ready. Waiting for the first torrent snapshot.'); socket.emit('select_profile',{profile_id:window.PYTORRENT.activeProfile}); }); socket.on('disconnect',()=>{ $('connBadge').className='badge text-bg-danger'; $('connBadge').textContent='offline'; setInitialLoader('Waiting for connection...','pyTorrent is not connected yet. The application will open after data is received.'); }); socket.io.on('reconnect_attempt',()=>{ $('connBadge').className='badge text-bg-warning'; $('connBadge').textContent='reconnecting'; setInitialLoader('Reconnecting...','Trying to restore the live connection and load torrent data.'); }); socket.io.on('reconnect',()=>{ if(!hasActiveProfile){ showFirstRunSetup(); return; } $('connBadge').className='badge text-bg-success'; $('connBadge').textContent='online'; setInitialLoader('Loading torrents...','Connection restored. Waiting for the first torrent snapshot.'); socket.emit('select_profile',{profile_id:window.PYTORRENT.activeProfile}); }); socket.on('profile_required',()=>showFirstRunSetup()); socket.on('torrent_snapshot',msg=>{hasTorrentSnapshot=true;torrentSummary=msg.summary||null;torrents.clear();(msg.torrents||[]).forEach(t=>torrents.set(t.hash,t));scheduleRender(true);hideInitialLoader();}); socket.on('torrent_patch',patchRows); socket.on('job_update',()=>{ if(document.body.classList.contains('modal-open')) loadJobs().catch(()=>{}); }); socket.on('operation_started',msg=>{setBusy(true);markTorrentOperation(msg.hashes||[],msg.action,msg.job_id,'running');toast(`${msg.action} started`,'secondary');}); socket.on('operation_finished',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);toast(`${msg.action} done`,'success');}); socket.on('operation_failed',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);toast(`${msg.action}: ${msg.error}`,'danger');}); socket.on('rtorrent_error',msg=>{ if(msg.error){$('connBadge').className='badge badge-degraded';$('connBadge').textContent='degraded'; setInitialLoader('Waiting for rTorrent...','rTorrent is not ready yet. Data will appear automatically after it responds.');} }); socket.on('heartbeat',msg=>{ if(msg.error){$('connBadge').className='badge badge-degraded';$('connBadge').textContent='degraded'; setInitialLoader('Waiting for rTorrent...','rTorrent is not ready yet. Data will appear automatically after it responds.');} else if(socket.connected){$('connBadge').className='badge text-bg-success';$('connBadge').textContent='online';} }); socket.on('smart_queue_update',msg=>{ if(msg && msg.enabled){ const noEffect=msg.start_no_effect?.length||0; const requested=msg.resume_requested?.length||0; const cap=msg.rtorrent_cap?.updated?`, cap ${msg.rtorrent_cap.current}->${msg.rtorrent_cap.new}`:''; const waiting=msg.waiting_labeled||0; const stalled=msg.stalled_labeled?.length||0; const tail=noEffect?`, no effect ${noEffect}`:requested?`, requested ${requested}`:''; const waitTail=waiting?`, waiting labeled ${waiting}`:''; const stalledTail=stalled?`, stalled ${stalled}`:''; toast(`Smart Queue: paused ${msg.paused?.length||0}, resumed ${msg.resumed?.length||0}${tail}${waitTail}${stalledTail}${cap}`,'secondary'); } }); socket.on('automation_update',msg=>{ if(msg?.applied?.length) toast(`Automations applied ${msg.applied.length} item(s)`,'secondary'); }); socket.on('torrent_stats_update',msg=>{ if(msg?.stats){ renderTorrentStats(msg.stats); } else if(msg?.error && $('toolTorrentStats') && !$('toolTorrentStats').classList.contains('d-none')){ toast(`Torrent stats: ${msg.error}`,'danger'); } }); socket.on('rtorrent_config_applied',msg=>{ if(msg?.result?.updated?.length) toast(`Startup rTorrent config applied (${msg.result.updated.length})`,'success'); if(msg?.error) toast(`Startup rTorrent config: ${msg.error}`,'danger'); }); socket.on('system_stats',s=>{ const usageAvailable=s.usage_available!==false && s.cpu!==undefined && s.ram!==undefined; $('statCpuBox')?.classList.toggle('d-none',!usageAvailable);$('statRamBox')?.classList.toggle('d-none',!usageAvailable);$('systemChart')?.classList.toggle('d-none',!usageAvailable); if(usageAvailable){$('statCpu').textContent=s.cpu??'-';$('statRam').textContent=s.ram??'-';drawSystemUsage(s.cpu,s.ram);} $('statVersion').textContent=s.version||'-';$('statDl').textContent=s.down_rate_h||'0 B/s';$('statUl').textContent=s.up_rate_h||'0 B/s';if($('mobileSpeedDl')) $('mobileSpeedDl').textContent=s.down_rate_h||'0 B/s';if($('mobileSpeedUl')) $('mobileSpeedUl').textContent=s.up_rate_h||'0 B/s';lastLimits={down:Number(s.down_limit||0),up:Number(s.up_limit||0)};$('statDlLimit').textContent=s.down_limit_h||'∞';$('statUlLimit').textContent=s.up_limit_h||'∞';$('statTotalDl').textContent=compactTransferText(s.total_down_h);$('statTotalUl').textContent=compactTransferText(s.total_up_h);drawTraffic(s.down_rate,s.up_rate);drawDiskUsage(s.disk);updateSocketStatus(s);applyFooterPreferences();}); updateSortHeaders(); applyColumnVisibility(); renderColumnManager(); renderFooterPreferences(); applyFooterPreferences(); updateFooterClock(); setInterval(updateFooterClock,1000); scheduleRender(true); if(!hasActiveProfile) renderNoProfileState(); loadLabels().catch(()=>{}); loadRatios().catch(()=>{}); loadSmartQueue().catch(()=>{}); loadAutomations().catch(()=>{}); if(portCheckEnabled) loadPortCheck(false); else renderPortCheck({status:'disabled',enabled:false}); if(hasActiveProfile) applyDefaultDownloadPath(false).catch(()=>{}); })(); diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index 5a40cc1..72cb13d 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -1327,22 +1327,32 @@ body.mobile-mode .mobile-card { .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; + width: 10.5rem; + white-space: nowrap; +} +.automation-history-table th:nth-child(2), +.automation-history-table td:nth-child(2) { + width: 13rem; + overflow-wrap: anywhere; +} +.automation-history-table th:nth-child(3), +.automation-history-table td:nth-child(3) { + width: 14rem; + overflow-wrap: anywhere; } .automation-history-table th:nth-child(4), .automation-history-table td:nth-child(4) { - width: 42%; + width: auto; + min-width: 0; + overflow-wrap: anywhere; + word-break: break-word; } .automation-history-details { max-width: 100%; diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index eb02036..8f70041 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -148,7 +148,7 @@ - + From e99d19ece0027979a3b3c110b424f5ee0c01271e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 7 May 2026 08:48:59 +0200 Subject: [PATCH 09/13] automatyzacje-comit3 --- pytorrent/services/smart_queue.py | 14 +++++++++----- pytorrent/static/styles.css | 27 +++++++++++++++++++++++---- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/pytorrent/services/smart_queue.py b/pytorrent/services/smart_queue.py index 7a1b6d2..c1558f6 100644 --- a/pytorrent/services/smart_queue.py +++ b/pytorrent/services/smart_queue.py @@ -172,7 +172,9 @@ def _without_smart_queue_label(value: str | None) -> str: def _has_stalled_label(value: str | None) -> bool: - return SMART_QUEUE_STALLED_LABEL in _label_names(value) + # Note: Stalled is treated case-insensitively so manually edited labels still block Smart Queue. + target = SMART_QUEUE_STALLED_LABEL.casefold() + return any(label.casefold() == target for label in _label_names(value)) def _without_queue_technical_labels(value: str | None) -> str: @@ -182,7 +184,7 @@ def _without_queue_technical_labels(value: str | None) -> str: def _ensure_stalled_label(client: Any, torrent_hash: str, current_label: str = '') -> bool: labels = [label for label in _label_names(current_label) if label != SMART_QUEUE_LABEL] changed = False - if SMART_QUEUE_STALLED_LABEL not in labels: + if not any(label.casefold() == SMART_QUEUE_STALLED_LABEL.casefold() for label in labels): labels.append(SMART_QUEUE_STALLED_LABEL) changed = True if SMART_QUEUE_LABEL in _label_names(current_label): @@ -476,7 +478,9 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = return {'ok': True, 'enabled': False, 'paused': [], 'resumed': [], 'labels_restored': restored, 'message': 'Smart Queue disabled'} torrents = rtorrent.list_torrents(profile) - excluded = _excluded_hashes(profile_id, user_id) + # Note: Torrents marked as Stalled are treated as queue-blocked even when there are no other pending downloads. + stalled_label_hashes = {str(t.get('hash') or '') for t in torrents if _has_stalled_label(str(t.get('label') or '')) and t.get('hash')} + excluded = _excluded_hashes(profile_id, user_id) | stalled_label_hashes manage_stopped = bool(settings.get('manage_stopped')) def is_managed_hold(t: dict[str, Any]) -> bool: return _has_smart_queue_label(str(t.get('label') or '')) @@ -622,6 +626,6 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = | {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, 'stalled_label': SMART_QUEUE_STALLED_LABEL, 'stalled_labeled': stalled_labeled, '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} + details = {'excluded': len(excluded), 'excluded_stalled': len(stalled_label_hashes), 'enabled': bool(settings.get('enabled')), 'auto_label': SMART_QUEUE_LABEL, 'stalled_label': SMART_QUEUE_STALLED_LABEL, 'stalled_labeled': stalled_labeled, '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} add_history(profile_id, 'force_check' if force else 'auto_check', paused, resumed, len(torrents), details, user_id) - return {'ok': True, 'enabled': bool(settings.get('enabled')), 'paused': paused, 'resumed': resumed, 'resume_requested': resume_requested, 'waiting_labeled': len(to_label_waiting), 'stalled_labeled': stalled_labeled, 'labels_restored': restored, 'labels_failed': label_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'active_verified': active_verified, 'rtorrent_cap': rtorrent_cap, 'checked': len(torrents), 'excluded': len(excluded), 'settings': settings} + return {'ok': True, 'enabled': bool(settings.get('enabled')), 'paused': paused, 'resumed': resumed, 'resume_requested': resume_requested, 'waiting_labeled': len(to_label_waiting), 'stalled_labeled': stalled_labeled, 'excluded_stalled': len(stalled_label_hashes), 'labels_restored': restored, 'labels_failed': label_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'active_verified': active_verified, 'rtorrent_cap': rtorrent_cap, 'checked': len(torrents), 'excluded': len(excluded), 'settings': settings} diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index 72cb13d..ad8e434 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -1317,49 +1317,67 @@ body.mobile-mode .mobile-card { border-radius: 999px; background: var(--bs-secondary-bg); font-size: 0.78rem; + overflow-wrap: anywhere; white-space: normal; + word-break: break-word; } .automation-history-toolbar { display: flex; justify-content: flex-end; margin-bottom: 0.5rem; } +/* Note: Automation history has fixed compact metadata columns and a flexible Actions column, so long JSON cannot overlap Time/Rule. */ .automation-history-table { width: 100%; table-layout: fixed; + white-space: normal; } .automation-history-table th, .automation-history-table td { + min-width: 0; vertical-align: top; } .automation-history-table th:nth-child(1), .automation-history-table td:nth-child(1) { - width: 10.5rem; + width: 9rem; + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } .automation-history-table th:nth-child(2), .automation-history-table td:nth-child(2) { - width: 13rem; + width: 11rem; + overflow: hidden; overflow-wrap: anywhere; + word-break: break-word; } .automation-history-table th:nth-child(3), .automation-history-table td:nth-child(3) { - width: 14rem; + width: 12rem; + overflow: hidden; overflow-wrap: anywhere; + word-break: break-word; } .automation-history-table th:nth-child(4), .automation-history-table td:nth-child(4) { width: auto; - min-width: 0; + overflow: hidden; overflow-wrap: anywhere; word-break: break-word; } .automation-history-details { + display: block; + min-width: 0; max-width: 100%; } .automation-history-details summary { + display: block; + max-width: 100%; cursor: pointer; list-style-position: inside; + overflow-wrap: anywhere; + white-space: normal; + word-break: break-word; } .automation-history-details pre, .automation-history-raw { @@ -1371,6 +1389,7 @@ body.mobile-mode .mobile-card { border: 1px solid var(--bs-border-color); border-radius: 0.5rem; background: var(--bs-tertiary-bg); + overflow-wrap: anywhere; white-space: pre-wrap; word-break: break-word; } From 7a4bda98a2e4b0ac74f278fd70867dba7952a589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 7 May 2026 09:06:47 +0200 Subject: [PATCH 10/13] automatyzacje-comit4 --- pytorrent/services/automation_rules.py | 123 ++++++++++++++++++++----- pytorrent/services/workers.py | 26 ++++-- 2 files changed, 118 insertions(+), 31 deletions(-) diff --git a/pytorrent/services/automation_rules.py b/pytorrent/services/automation_rules.py index a8c8fed..9a4a279 100644 --- a/pytorrent/services/automation_rules.py +++ b/pytorrent/services/automation_rules.py @@ -5,6 +5,10 @@ import json from ..db import connect, default_user_id, utcnow from . import rtorrent from .preferences import active_profile +from .workers import enqueue + +AUTOMATION_JOB_CHUNK_SIZE = 100 + def _loads(value: str | None, default: Any) -> Any: @@ -149,8 +153,59 @@ def _mark_rule_cooldown(conn, rule: dict[str, Any], profile_id: int, now: str) - conn.execute('INSERT INTO automation_rule_state(rule_id,profile_id,torrent_hash,last_applied_at,updated_at) VALUES(?,?,?,?,?) ON CONFLICT(rule_id,profile_id,torrent_hash) DO UPDATE SET last_applied_at=excluded.last_applied_at, updated_at=excluded.updated_at', (rule['id'], profile_id, '__rule__', now, now)) -def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str, Any]], effects: list[dict[str, Any]]) -> list[dict[str, Any]]: +def _chunk_hashes(hashes: list[str], size: int = AUTOMATION_JOB_CHUNK_SIZE) -> list[list[str]]: + # Note: Automation jobs use the same small-batch idea as manual bulk jobs, so long move/remove/actions remain visible and recoverable. + safe_size = max(1, int(size or AUTOMATION_JOB_CHUNK_SIZE)) + return [hashes[index:index + safe_size] for index in range(0, len(hashes), safe_size)] + + +def _job_context(rule: dict[str, Any], eff_type: str, hashes: list[str], torrents_by_hash: dict[str, dict[str, Any]], extra: dict[str, Any] | None = None) -> dict[str, Any]: + # Note: Job context marks jobs created by automations, making the Jobs log explain what rule queued the work. + ctx = { + 'source': 'automation', + 'rule_id': rule.get('id'), + 'rule_name': str(rule.get('name') or ''), + 'effect': eff_type, + 'bulk': len(hashes) > 1, + 'hash_count': len(hashes), + 'requested_at': utcnow(), + 'items': [ + { + 'hash': h, + 'name': str((torrents_by_hash.get(h) or {}).get('name') or ''), + 'path': str((torrents_by_hash.get(h) or {}).get('path') or ''), + } + for h in hashes + ], + } + if extra: + ctx.update(extra) + return ctx + + +def _enqueue_automation_job(profile: dict[str, Any], rule: dict[str, Any], action_name: str, hashes: list[str], payload: dict[str, Any], torrents_by_hash: dict[str, dict[str, Any]], user_id: int | None = None, context_extra: dict[str, Any] | None = None) -> list[str]: + # Note: Every automation side effect is queued as a normal job instead of running inline, so it appears in Jobs and uses worker retries/ordering. + job_ids: list[str] = [] + chunks = _chunk_hashes(hashes) + for index, chunk in enumerate(chunks, start=1): + part_payload = dict(payload or {}) + part_payload['hashes'] = chunk + part_payload['automation_ordered'] = True + extra = dict(context_extra or {}) + if len(chunks) > 1: + extra.update({'bulk_label': f'automation-{index}', 'bulk_part': index, 'bulk_parts': len(chunks), 'parent_hash_count': len(hashes)}) + if action_name == 'move': + extra.update({'target_path': str(part_payload.get('path') or ''), 'move_data': bool(part_payload.get('move_data'))}) + if action_name == 'remove': + extra.update({'remove_data': bool(part_payload.get('remove_data'))}) + part_payload['job_context'] = _job_context(rule, str(context_extra.get('effect_type') if context_extra else action_name), chunk, torrents_by_hash, extra) + job_ids.append(enqueue(action_name, int(profile['id']), part_payload, user_id=user_id)) + return job_ids + + +def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str, Any]], effects: list[dict[str, Any]], rule: dict[str, Any], user_id: int | None = None) -> list[dict[str, Any]]: hashes = [str(t.get('hash') or '') for t in torrents if str(t.get('hash') or '')] + torrents_by_hash = {str(t.get('hash') or ''): t for t in torrents if str(t.get('hash') or '')} labels_by_hash = {str(t.get('hash') or ''): _label_names(t.get('label')) for t in torrents} applied: list[dict[str, Any]] = [] if not hashes: return applied @@ -164,40 +219,64 @@ def _apply_effects_bulk(c: Any, profile: dict[str, Any], torrents: list[dict[str 'recheck': bool(eff.get('recheck', eff.get('move_data'))), 'keep_seeding': bool(eff.get('keep_seeding')), } - result = rtorrent.action(profile, hashes, 'move', payload) - 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}) + job_ids = _enqueue_automation_job(profile, rule, 'move', hashes, payload, torrents_by_hash, user_id, {'effect_type': 'move'}) + 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'], 'job_ids': job_ids}) elif typ == 'add_label': label = str(eff.get('label') or '').strip() if label: - # 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, []) - labels.append(label); c.call('d.custom1.set', h, _label_value(labels)) + # Note: Add-label automations are idempotent and queue only torrents that need a changed label value. + grouped: dict[str, list[str]] = {} + for h in hashes: + labels = labels_by_hash.get(h, []) + if label in labels: + continue + new_labels = list(labels) + [label] + value = _label_value(new_labels) + labels_by_hash[h] = _label_names(value) + grouped.setdefault(value, []).append(h) + target_hashes = [h for group in grouped.values() for h in group] + job_ids: list[str] = [] + for value, group_hashes in grouped.items(): + job_ids.extend(_enqueue_automation_job(profile, rule, 'set_label', group_hashes, {'label': value}, torrents_by_hash, user_id, {'effect_type': 'add_label', 'label': label})) if target_hashes: - applied.append({'type': 'add_label', 'label': label, 'count': len(target_hashes), 'target_hashes': target_hashes}) + applied.append({'type': 'add_label', 'label': label, 'count': len(target_hashes), 'target_hashes': target_hashes, 'job_ids': job_ids}) elif typ == 'remove_label': label = str(eff.get('label') or '').strip() if label: - # 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)) + # Note: Remove-label automations are queued only for torrents where the requested label exists. + grouped: dict[str, list[str]] = {} + for h in hashes: + labels = labels_by_hash.get(h, []) + if label not in labels: + continue + value = _label_value([x for x in labels if x != label]) + labels_by_hash[h] = _label_names(value) + grouped.setdefault(value, []).append(h) + target_hashes = [h for group in grouped.values() for h in group] + job_ids: list[str] = [] + for value, group_hashes in grouped.items(): + job_ids.extend(_enqueue_automation_job(profile, rule, 'set_label', group_hashes, {'label': value}, torrents_by_hash, user_id, {'effect_type': 'remove_label', 'label': label})) if target_hashes: - applied.append({'type': 'remove_label', 'label': label, 'count': len(target_hashes), 'target_hashes': target_hashes}) + applied.append({'type': 'remove_label', 'label': label, 'count': len(target_hashes), 'target_hashes': target_hashes, 'job_ids': job_ids}) elif typ == 'set_labels': value = _label_value(_label_names(eff.get('labels'))) target_labels = _label_names(value) - # Note: Set-labels skips torrents whose current label list already matches the requested list. + # Note: Set-labels queues a job only if the current labels differ from the requested exact 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) + labels_by_hash[h] = list(target_labels) 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), 'target_hashes': hashes, 'result': result}) + job_ids = _enqueue_automation_job(profile, rule, 'set_label', target_hashes, {'label': value}, torrents_by_hash, user_id, {'effect_type': 'set_labels', 'labels': value}) + applied.append({'type': 'set_labels', 'labels': value, 'count': len(target_hashes), 'target_hashes': target_hashes, 'job_ids': job_ids}) + elif typ in {'pause', 'stop', 'start', 'resume', 'recheck', 'reannounce'}: + # Note: Runtime actions are queued as jobs too, so automation activity is visible in the Jobs panel. + job_ids = _enqueue_automation_job(profile, rule, typ, hashes, {}, torrents_by_hash, user_id, {'effect_type': typ}) + applied.append({'type': typ, 'count': len(hashes), 'target_hashes': hashes, 'job_ids': job_ids}) + elif typ == 'remove': + # Note: Remove is supported for automation payloads and still goes through ordered worker jobs. + payload = {'remove_data': bool(eff.get('remove_data'))} + job_ids = _enqueue_automation_job(profile, rule, 'remove', hashes, payload, torrents_by_hash, user_id, {'effect_type': 'remove'}) + applied.append({'type': 'remove', 'count': len(hashes), 'target_hashes': hashes, 'remove_data': payload['remove_data'], 'job_ids': job_ids}) return applied @@ -220,7 +299,7 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = if not hashes: continue try: - actions = _apply_effects_bulk(c, profile, matched, rule.get('effects') or []) + actions = _apply_effects_bulk(c, profile, matched, rule.get('effects') or [], rule, user_id) except Exception as exc: 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 [])}) diff --git a/pytorrent/services/workers.py b/pytorrent/services/workers.py index a9d0835..d587776 100644 --- a/pytorrent/services/workers.py +++ b/pytorrent/services/workers.py @@ -54,25 +54,33 @@ def _job_row(job_id: str): return conn.execute("SELECT rowid AS _rowid, * FROM jobs WHERE id=?", (job_id,)).fetchone() -def _is_ordered_action(action_name: str) -> bool: - return action_name in {"move", "remove"} +def _job_payload(row) -> dict: + try: + return json.loads((row or {}).get("payload_json") or "{}") + except Exception: + return {} + + +def _is_ordered_job(row) -> bool: + payload = _job_payload(row) + # Note: Move/remove remain ordered, and automation-created jobs can opt in so effect order is visible and predictable. + return str((row or {}).get("action") or "") in {"move", "remove"} or bool(payload.get("automation_ordered")) def _has_prior_ordered_jobs(profile_id: int, rowid: int) -> bool: with connect() as conn: - row = conn.execute( + rows = conn.execute( """ - SELECT 1 + SELECT rowid AS _rowid, action, payload_json FROM jobs WHERE profile_id=? AND rowid bool: @@ -140,7 +148,7 @@ def _run(job_id: str): return profile_id = int(profile["id"]) ordered_lock = None - if _is_ordered_action(str(job["action"])): + if _is_ordered_job(job): if not _wait_for_prior_ordered_jobs(job_id, profile_id, int(job["_rowid"])): return ordered_lock = _get_exclusive_lock(profile_id) From 85e1e6adcdafc53b98e631405167dedb7147cad9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 7 May 2026 09:16:52 +0200 Subject: [PATCH 11/13] automatyzacje-comit5 --- pytorrent/services/automation_rules.py | 38 ++++++++++++++++---------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/pytorrent/services/automation_rules.py b/pytorrent/services/automation_rules.py index 9a4a279..86de7f8 100644 --- a/pytorrent/services/automation_rules.py +++ b/pytorrent/services/automation_rules.py @@ -286,27 +286,36 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = user_id = user_id or default_user_id(); profile_id = int(profile['id']) rules = [r for r in list_rules(profile_id, user_id) if force or int(r.get('enabled') or 0)] if not rules: return {'ok': True, 'checked': 0, 'applied': [], 'batches': [], 'rules': 0} - torrents = rtorrent.list_torrents(profile); c = rtorrent.client_for(profile); applied = []; batches = []; now = utcnow() + torrents = rtorrent.list_torrents(profile); applied = []; batches = []; now = utcnow() + planned: list[dict[str, Any]] = [] with connect() as conn: for rule in rules: - # Note: Automations now execute as one batch per rule, not as one independent action per torrent. + # Note: This pass only matches rules and updates condition timers; job creation is intentionally delayed until after this DB transaction commits. if not force and not _cooldown_ok(conn, rule, profile_id): continue matched = [t for t in torrents if _conditions_match(conn, rule, profile_id, t)] if not matched: continue hashes = [str(t.get('hash') or '') for t in matched if str(t.get('hash') or '')] - if not hashes: - continue - try: - actions = _apply_effects_bulk(c, profile, matched, rule.get('effects') or [], rule, user_id) - except Exception as exc: - 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} + if hashes: + planned.append({'rule': rule, 'matched': matched, 'hashes': hashes}) + for item in planned: + rule = item['rule'] + matched = item['matched'] + hashes = item['hashes'] + # Note: Automation jobs are enqueued outside the rule-state transaction, preventing SQLite self-locks when enqueue() writes to jobs. + try: + actions = _apply_effects_bulk(None, profile, matched, rule.get('effects') or [], rule, user_id) + except Exception as exc: + 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 + history_actions = [{k: v for k, v in a.items() if k != 'target_hashes'} for a in actions] + matched_by_hash = {str(t.get('hash') or ''): t for t in matched} + with connect() as conn: + # Note: State/history writes happen after enqueue succeeds, so failed job creation does not create misleading automation history. 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)) @@ -314,7 +323,6 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = _mark_rule_cooldown(conn, rule, profile_id, now) 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}) + 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} From 8334aa97e2839ee02df26942a4b61676bf2f9a26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 7 May 2026 11:55:29 +0200 Subject: [PATCH 12/13] automatyzacje-comit6 --- pytorrent/routes/api.py | 30 +++++++++++++++++++ pytorrent/services/automation_rules.py | 40 ++++++++++++++++++++++++++ pytorrent/static/app.js | 37 ++++++++++++++++++------ pytorrent/static/styles.css | 30 +++++++++++++++++++ pytorrent/templates/index.html | 4 +-- 5 files changed, 130 insertions(+), 11 deletions(-) diff --git a/pytorrent/routes/api.py b/pytorrent/routes/api.py index cd923aa..907e502 100644 --- a/pytorrent/routes/api.py +++ b/pytorrent/routes/api.py @@ -1047,6 +1047,36 @@ def automations_get(): return jsonify({'ok': False, 'error': str(exc), 'rules': [], 'history': []}), 500 +@bp.get('/automations/export') +def automations_export(): + from ..services import automation_rules + profile = preferences.active_profile() + if not profile: + return jsonify({'ok': False, 'error': 'No profile'}), 400 + try: + # Note: JSON export is profile-scoped and excludes execution history/cooldown state. + data = automation_rules.export_rules(profile['id']) + return ok({'export': data, 'count': len(data.get('rules') or [])}) + except Exception as exc: + return jsonify({'ok': False, 'error': str(exc)}), 400 + + +@bp.post('/automations/import') +def automations_import(): + from ..services import automation_rules + profile = preferences.active_profile() + if not profile: + return jsonify({'ok': False, 'error': 'No profile'}), 400 + try: + payload = request.get_json(silent=True) or {} + replace = str(request.args.get('replace') or '').lower() in {'1', 'true', 'yes'} or bool(payload.get('replace')) if isinstance(payload, dict) else False + # Note: Import appends rules by default, so existing automations remain untouched. + imported = automation_rules.import_rules(profile['id'], payload, replace=replace) + return ok({'imported': len(imported), 'rules': automation_rules.list_rules(profile['id'])}) + except Exception as exc: + return jsonify({'ok': False, 'error': str(exc)}), 400 + + @bp.post('/automations') def automations_save(): from ..services import automation_rules diff --git a/pytorrent/services/automation_rules.py b/pytorrent/services/automation_rules.py index 86de7f8..51d440f 100644 --- a/pytorrent/services/automation_rules.py +++ b/pytorrent/services/automation_rules.py @@ -66,6 +66,44 @@ def get_rule(rule_id: int, profile_id: int, user_id: int | None = None) -> dict[ return _rule_row(row) +def _portable_rule(rule: dict[str, Any]) -> dict[str, Any]: + return { + 'name': str(rule.get('name') or 'Automation rule'), + 'enabled': bool(rule.get('enabled', True)), + 'cooldown_minutes': max(0, int(rule.get('cooldown_minutes') or 0)), + 'conditions': list(rule.get('conditions') or []), + 'effects': list(rule.get('effects') or []), + } + + +def export_rules(profile_id: int, user_id: int | None = None) -> dict[str, Any]: + # Note: Export contains only portable rule definitions, never DB ids or execution history. + rules = [_portable_rule(rule) for rule in list_rules(profile_id, user_id)] + return {'version': 1, 'app': 'pyTorrent', 'exported_at': utcnow(), 'rules': rules} + + +def import_rules(profile_id: int, payload: dict[str, Any] | list[Any], user_id: int | None = None, replace: bool = False) -> list[dict[str, Any]]: + user_id = user_id or default_user_id() + raw_rules = payload if isinstance(payload, list) else payload.get('rules', []) if isinstance(payload, dict) else [] + if not isinstance(raw_rules, list) or not raw_rules: + raise ValueError('Import file does not contain automation rules') + if replace: + with connect() as conn: + # Note: Optional replace is profile-scoped; it does not touch other profiles or history tables. + conn.execute('DELETE FROM automation_rules WHERE user_id=? AND profile_id=?', (user_id, profile_id)) + conn.execute('DELETE FROM automation_rule_state WHERE profile_id=?', (profile_id,)) + imported = [] + for raw in raw_rules: + if not isinstance(raw, dict): + continue + rule = _portable_rule(raw) + rule.pop('id', None) + imported.append(save_rule(profile_id, rule, user_id)) + if not imported: + raise ValueError('No valid automation rules found') + return imported + + def save_rule(profile_id: int, data: dict[str, Any], user_id: int | None = None) -> dict[str, Any]: user_id = user_id or default_user_id() name = str(data.get('name') or 'Automation rule').strip() or 'Automation rule' @@ -111,6 +149,8 @@ def _condition_true(t: dict[str, Any], cond: dict[str, Any]) -> bool: if typ == 'completed': return bool(int(t.get('complete') or 0)) if typ == 'no_seeds': return int(t.get('seeds') or 0) <= int(cond.get('seeds') or 0) if typ == 'ratio_gte': return float(t.get('ratio') or 0) >= float(cond.get('ratio') or 0) + if typ == 'progress_gte': return float(t.get('progress') or 0) >= float(cond.get('progress') or 0) + if typ == 'progress_lte': return float(t.get('progress') or 0) <= float(cond.get('progress') or 0) if typ == 'label_missing': return str(cond.get('label') or '').strip() not in _label_names(t.get('label')) if typ == 'label_has': return str(cond.get('label') or '').strip() in _label_names(t.get('label')) if typ == 'status': return str(t.get('status') or '').lower() == str(cond.get('status') or '').lower() diff --git a/pytorrent/static/app.js b/pytorrent/static/app.js index b4a59ff..f579b94 100644 --- a/pytorrent/static/app.js +++ b/pytorrent/static/app.js @@ -65,6 +65,8 @@ return new Intl.DateTimeFormat('pl-PL', opts).format(parsed.d).replace(',', ''); } function dateCell(value){ const parsed=parseDate(value); if(!parsed) return esc(value||''); return `${esc(formatDate(value))}`; } + // Note: Human-readable date cells keep full timestamps visible without squeezing table columns. + function humanDateCell(value){ const parsed=parseDate(value); if(!parsed) return esc(value||''); const full=formatDate(value,'full'); return `${esc(full)}`; } function compactCell(value, max=120){ const text=String(value||""); if(!text) return ""; const short=text.length>max ? `${text.slice(0, Math.floor(max*0.62))}…${text.slice(-Math.floor(max*0.28))}` : text; return `${esc(short)}`; } function progressBar(value, extraClass=''){ const pct=Math.max(0,Math.min(100,Number(value||0))); const hue=Math.round((pct/100)*120); const light=30+Math.round((pct/100)*5); const bg=pct<=0?'transparent':pct>=100?'var(--torrent-progress-complete)':`hsl(${hue} 52% ${light}%)`; const done=pct>=100?' is-complete':''; const cls=extraClass?` ${extraClass}`:''; return `
${esc(pct)}%
`; } function progress(t){ return progressBar(t.progress); } @@ -236,6 +238,7 @@ async function runAction(action, extra={}){ const hashes=selectedHashes(); if(!hashes.length) return toast('No torrents selected','warning'); let payload={hashes,...extra}; if(action==='move'){ openPathPicker('move'); return; } setBusy(true); try{ const j=await post(`/api/torrents/${action}`,payload); markQueuedJobs(j, hashes, action); if(action==='recheck'){ hashes.forEach(h=>{ const t=torrents.get(h); if(t) torrents.set(h,{...t,status:'Checking',hashing:1,message:'Force recheck queued'}); }); scheduleRender(true); } const parts=Number(j.bulk_parts||1); toast(parts>1?`${action} queued in ${parts} bulk parts`:`${action} queued`,'success'); if(action==='set_label') await loadLabels(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} } function flag(iso){ const code=String(iso||'').toLowerCase(); return code?` ${esc(code.toUpperCase())}`:'-'; } function table(headers,rows){ return `${headers.map(h=>``).join('')}${rows.map(r=>`${r.map(c=>``).join('')}`).join('')}
${esc(h)}
${c}
`; } + function downloadJson(filename, data){ const blob=new Blob([JSON.stringify(data,null,2)],{type:'application/json'}); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(url),500); } function renderGeneral(){ const t=torrents.get(selectedHash); const labels=t?labelNames(t.label).map(l=>` ${esc(l)}`).join(' '):''; $('detailPane').innerHTML=t?`
Name${esc(t.name)}
Hash${esc(t.hash)}
Path${esc(t.path)}
Size${esc(t.size_h)}
Progress${esc(t.progress)}%
Ratio${esc(t.ratio)}
Downloaded${esc(t.down_total_h)}
Uploaded${esc(t.up_total_h)}
Labels${labels||'-'}
Ratio group${esc(t.ratio_group||'')}
`:'Select a torrent.'; } const FILE_PRIORITY_LABELS = {0: "Skip", 1: "Normal", 2: "High"}; function priorityClass(priority){ priority=Number(priority||0); return priority===2?"text-bg-success":priority===0?"text-bg-secondary":"text-bg-primary"; } @@ -356,18 +359,20 @@ esc(r.hash_count||0), details(r), esc(r.attempts||0), - dateCell(r.started_at||r.created_at), - dateCell(r.finished_at), + humanDateCell(r.started_at||r.created_at), + humanDateCell(r.finished_at), compactCell(r.error||'',140), jobActions(r), ]) ); + box.querySelector('table')?.classList.add('jobs-table'); renderJobsPager(); } function renderJobsPager(){ const p=$('jobsPager'); if(!p)return; const pages=Math.max(1,Math.ceil(jobsTotal/jobsLimit)); p.innerHTML=`
Page ${jobsPage+1} / ${pages} · ${jobsTotal} jobs
`; $('jobsPrev')?.addEventListener('click',()=>loadJobs(jobsPage-1)); $('jobsNext')?.addEventListener('click',()=>loadJobs(jobsPage+1)); } - // Note: Job log buttons depend on status: failed gets retry, while emergency cancel is only for pending/running. + // Note: Job log buttons are separated so normal cleanup cannot accidentally trigger emergency cleanup. $('jobsModal')?.addEventListener('show.bs.modal',loadJobs); $('refreshJobsBtn')?.addEventListener('click',loadJobs); $('jobsTable')?.addEventListener('click',async e=>{ const btn=e.target.closest('.job-retry,.job-cancel'); if(!btn)return; const id=btn.dataset.id; if(!id)return; if(btn.classList.contains('job-retry')) await post(`/api/jobs/${id}/retry`,{}).catch(x=>toast(x.message,'danger')); if(btn.classList.contains('job-cancel')){ const st=btn.dataset.status||''; if((st==='pending'||st==='running') && !confirm('Emergency cancel this unfinished job?')) return; await post(`/api/jobs/${id}/cancel`,{}).catch(x=>toast(x.message,'danger')); } loadJobs(); }); - $('clearJobsBtn')?.addEventListener('click',async()=>{ const emergency=confirm('Emergency clear all job logs, including unfinished jobs? OK = emergency clear, Cancel = clear only finished logs.'); if(!emergency && !confirm('Clear finished job logs? Pending and running jobs will stay.')) return; try{ const j=await post(`/api/jobs/clear${emergency?'?force=1':''}`,{}); toast(`${emergency?'Emergency cleared':'Cleared'} ${j.deleted||0} job log(s)`,'success'); jobsPage=0; loadJobs(0); }catch(e){ toast(e.message,'danger'); } }); + $('clearJobsBtn')?.addEventListener('click',async()=>{ if(!confirm('Clear finished job logs? Pending and running jobs will stay.')) return; try{ const j=await post('/api/jobs/clear',{}); toast(`Cleared ${j.deleted||0} finished job log(s)`,'success'); jobsPage=0; loadJobs(0); }catch(e){ toast(e.message,'danger'); } }); + $('emergencyClearJobsBtn')?.addEventListener('click',async()=>{ if(!confirm('Emergency clean ALL job logs, including unfinished jobs?')) return; try{ const j=await post('/api/jobs/clear?force=1',{}); toast(`Emergency cleared ${j.deleted||0} job log(s)`,'success'); jobsPage=0; loadJobs(0); }catch(e){ toast(e.message,'danger'); } }); async function loadLabels(){ const j=await (await fetch('/api/labels')).json(); const labels=j.labels||[]; knownLabels=labels; renderLabelFilters(); renderLabelChooser(); if($('labelsManager')) $('labelsManager').innerHTML=labels.length?labels.map(l=>`
${esc(l.name)}
`).join(''):'No labels.'; } function renderLabelChooser(){ if($('selectedLabelList')) $('selectedLabelList').innerHTML=[...modalLabels].map(l=>``).join('') || 'No labels selected.'; if($('labelList')) $('labelList').innerHTML=knownLabels.map(l=>``).join('') || 'No saved labels.'; } @@ -534,6 +539,8 @@ const cond={type, negate:!!$('autoCondNegate')?.checked}; 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); + // Note: Progress conditions compare the torrent completion percentage stored in the live torrent row. + if(type==='progress_gte'||type==='progress_lte') cond.progress=Number($('autoCondProgress')?.value||0); 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||''; @@ -562,7 +569,7 @@ } function conditionText(c={}){ - const base=c.type==='no_seeds'?`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'; + const base=c.type==='no_seeds'?`seeds <= ${c.seeds||0} for ${c.minutes||0} min`:c.type==='ratio_gte'?`ratio >= ${c.ratio}`:c.type==='progress_gte'?`progress >= ${c.progress||0}%`:c.type==='progress_lte'?`progress <= ${c.progress||0}%`:c.type==='label_missing'?`missing label ${c.label||''}`:c.type==='label_has'?`has label ${c.label||''}`:c.type==='status'?`status = ${c.status||''}`:c.type==='path_contains'?`path contains ${c.text||''}`:'completed'; return c.negate?`NOT (${base})`:base; } function effectText(e={}){ @@ -637,9 +644,9 @@ function renderAutomationHistory(hist=[]){ if(!$('automationHistory')) return; const toolbar='
'; - 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.
'; + const rows=hist.map(h=>[humanDateCell(h.created_at),esc(h.rule_name||''),esc(h.torrent_name||h.torrent_hash||''),automationHistoryActions(h.actions_json||'')]); + // Note: Automation history time is now human-readable and wider, while the table still wraps on small screens. + 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; } @@ -651,6 +658,18 @@ finally{ setBusy(false); } } + async function exportAutomations(){ + try{ const j=await (await fetch('/api/automations/export')).json(); if(!j.ok) throw new Error(j.error||'Automation export failed'); downloadJson(`pytorrent-automation-rules-${new Date().toISOString().slice(0,10)}.json`, j.export||j); toast(`Exported ${j.count||0} automation rule(s)`,'success'); } + catch(e){ toast(e.message,'danger'); } + } + + async function importAutomations(file){ + if(!file) return; + try{ const payload=JSON.parse(await file.text()); const j=await post('/api/automations/import',payload); toast(`Imported ${j.imported||0} automation rule(s)`,'success'); await loadAutomations(); } + catch(e){ toast(e.message||'Automation import failed','danger'); } + finally{ if($('automationImportFile')) $('automationImportFile').value=''; } + } + async function loadAutomations(){ const j=await (await fetch('/api/automations')).json(); const rules=j.rules||[], hist=j.history||[]; @@ -840,7 +859,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 stalled=r.stalled_labeled?.length||0; const tail=noEffect?`, no effect ${noEffect}`:requested?`, requested ${requested}`:''; const waitTail=waiting?`, waiting labeled ${waiting}`:''; const stalledTail=stalled?`, stalled ${stalled}`:''; toast(`Smart Queue: paused ${r.paused?.length||0}, resumed ${r.resumed?.length||0}${tail}${waitTail}${stalledTail}${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 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(); }); + $('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); $('automationExportBtn')?.addEventListener('click',exportAutomations); $('automationImportBtn')?.addEventListener('click',()=>$('automationImportFile')?.click()); $('automationImportFile')?.addEventListener('change',e=>importAutomations(e.target.files?.[0])); $('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 ad8e434..b8c9c54 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -2293,3 +2293,33 @@ body.mobile-mode .mobile-filter-bar { width: 100%; } } + +.date-readable { + display: inline-block; + min-width: 9.5rem; + white-space: nowrap; +} + +.jobs-table { + min-width: 980px; + white-space: normal; +} + +.jobs-table th:nth-child(7), +.jobs-table td:nth-child(7), +.jobs-table th:nth-child(8), +.jobs-table td:nth-child(8) { + min-width: 10.5rem; +} + +.jobs-table td:nth-child(5), +.jobs-table td:nth-child(9) { + max-width: 18rem; + overflow-wrap: anywhere; + white-space: normal; +} + +.automation-history-scroll { + width: 100%; + overflow-x: auto; +} diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index 8f70041..a0361d3 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -140,7 +140,7 @@ - + @@ -148,7 +148,7 @@ - + From 91dcd1c5db9732ab453c80184a1245a133443c54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 7 May 2026 13:30:23 +0200 Subject: [PATCH 13/13] automatyzacje-comit7 --- pytorrent/services/rtorrent.py | 20 +++++++++++------ pytorrent/static/app.js | 39 ++++++++++++++++++++++++++++------ pytorrent/static/styles.css | 23 +++++++++++++++++--- 3 files changed, 66 insertions(+), 16 deletions(-) diff --git a/pytorrent/services/rtorrent.py b/pytorrent/services/rtorrent.py index b853464..4298f95 100644 --- a/pytorrent/services/rtorrent.py +++ b/pytorrent/services/rtorrent.py @@ -926,17 +926,23 @@ def tracker_action(profile: dict, torrent_hash: str, action_name: str, payload: ("d.tracker.insert", (torrent_hash, 0, url)), ("d.tracker.insert", ("", torrent_hash, "", url)), ]) - if action_name == "edit": - url = str(payload.get("url") or "").strip() + if action_name in {"delete", "remove"}: + # Note: Deleting trackers is guarded to keep at least one tracker attached to the torrent. index = int(payload.get("index", -1)) if index < 0: raise ValueError("Invalid tracker index") - if not url: - raise ValueError("Missing tracker URL") - target = _tracker_target(torrent_hash, index) + total = _tracker_int(_safe_tracker_call(c, "d.tracker_size", torrent_hash, 0), 0) or len(torrent_trackers(profile, torrent_hash)) + if total <= 1: + raise ValueError("Cannot delete the last tracker") + if index >= total: + raise ValueError("Invalid tracker index") return _call_first(c, [ - ("t.url.set", (target, url)), - ("t.url.set", ("", target, url)), + ("d.tracker.remove", (torrent_hash, index)), + ("d.tracker.remove", (torrent_hash, "", index)), + ("d.tracker.erase", (torrent_hash, index)), + ("d.tracker.erase", (torrent_hash, "", index)), + ("d.tracker.delete", (torrent_hash, index)), + ("d.tracker.delete", (torrent_hash, "", index)), ]) raise ValueError(f"Unknown tracker action: {action_name}") diff --git a/pytorrent/static/app.js b/pytorrent/static/app.js index f579b94..58acfd7 100644 --- a/pytorrent/static/app.js +++ b/pytorrent/static/app.js @@ -34,7 +34,31 @@ // Note: Keeps live filter tooltips stable while the pointer is over a filter button. const filterTooltipState = new WeakMap(); - function toast(msg, type="secondary") { const h=$('toastHost'); if(!h) return; const el=document.createElement('div'); el.className=`toast-item text-bg-${type}`; el.innerHTML=esc(msg); h.appendChild(el); setTimeout(()=>el.remove(),3500); } + const toastGroups = new Map(); + function toastKey(msg, type){ return `${type}::${String(msg ?? '')}`; } + function toast(msg, type="secondary") { + // Note: Groups identical toasts fired together, so repeated automation/action events do not flood the UI. + const h=$('toastHost'); + if(!h) return; + const text=String(msg ?? ''); + const key=toastKey(text,type); + const existing=toastGroups.get(key); + if(existing){ + existing.count += 1; + const badge=existing.el.querySelector('.toast-count'); + if(badge){ badge.textContent=`×${existing.count}`; badge.classList.remove('d-none'); } + clearTimeout(existing.timer); + existing.timer=setTimeout(()=>{ existing.el.remove(); toastGroups.delete(key); },3500); + return; + } + const el=document.createElement('div'); + el.className=`toast-item text-bg-${type}`; + el.innerHTML=`${esc(text)}×1`; + h.appendChild(el); + const entry={el,count:1,timer:null}; + entry.timer=setTimeout(()=>{ el.remove(); toastGroups.delete(key); },3500); + toastGroups.set(key,entry); + } function setBusy(on){ pendingBusy += on ? 1 : -1; if(pendingBusy<0) pendingBusy=0; $('globalLoader')?.classList.toggle('d-none', pendingBusy===0); $('busyBadge')?.classList.toggle('d-none', pendingBusy===0); } function setInitialLoader(title, text){ if(initialLoaderDone) return; if($('initialLoaderTitle') && title) $('initialLoaderTitle').textContent=title; if($('initialLoaderText') && text) $('initialLoaderText').textContent=text; } function hideInitialLoader(){ if(initialLoaderDone) return; initialLoaderDone=true; $('initialLoader')?.classList.add('is-hidden'); } @@ -274,14 +298,17 @@ function fmtTs(value){ const n=Number(value||0); if(!n) return '-'; try{return new Date(n*1000).toLocaleString();}catch(e){return String(n);} } function trackerSeedsPeers(t){ const hasScrape = t.seeds !== null || t.peers !== null; return hasScrape ? `${t.seeds ?? "-"} / ${t.peers ?? "-"}` : "-"; } function renderTrackers(trackers){ + // Note: Tracker URL editing is intentionally replaced by safe deletion; adding trackers remains unchanged. const pane=$('detailPane'); - const rows=(trackers||[]).map(t=>{ + const list=trackers||[]; + const canDelete=list.length>1; + const rows=list.map(t=>{ const idx=esc(t.index), url=esc(t.url); - return [`#${idx}`, `
${url || '-'}
`, t.enabled?'yes':'no', esc(trackerSeedsPeers(t)), esc(t.downloaded ?? '-'), fmtTs(t.last_announce), `
`]; + const deleteDisabled=canDelete ? '' : ' disabled title="At least one tracker must remain"'; + return [`#${idx}`, `${url || '-'}`, t.enabled?'yes':'no', esc(trackerSeedsPeers(t)), esc(t.downloaded ?? '-'), fmtTs(t.last_announce), `
`]; }); - pane.innerHTML=`
${table(['#','URL','On','Seeds / Peers','Done','Last announce','Actions'], rows.length?rows:[[ '-','No trackers.','','','','','' ]])}`; + pane.innerHTML=`
${table(['#','URL','On','Seeds / Peers','Done','Last announce','Actions'], rows.length?rows:[[ '-','No trackers.','','','','','' ]])}`; } - function setTrackerEdit(index,on){ const sel=String(index); document.querySelector(`.tracker-url-view[data-tracker-index="${CSS.escape(sel)}"]`)?.classList.toggle('d-none', on); document.querySelector(`.tracker-url-edit[data-tracker-index="${CSS.escape(sel)}"]`)?.classList.toggle('d-none', !on); document.querySelector(`.tracker-edit-start[data-index="${CSS.escape(sel)}"]`)?.classList.toggle('d-none', on); document.querySelector(`.tracker-edit-save[data-index="${CSS.escape(sel)}"]`)?.classList.toggle('d-none', !on); document.querySelector(`.tracker-edit-cancel[data-index="${CSS.escape(sel)}"]`)?.classList.toggle('d-none', !on); } async function trackerAction(action,payload={}){ if(!selectedHash) return toast('No torrent selected','warning'); setBusy(true); @@ -870,7 +897,7 @@ function awaitMaybeRun(action){ runAction(action).catch?.(()=>{}); } document.addEventListener('click',e=>{ const ctx=$('ctxMenu'); if(!e.target.closest('#ctxMenu')) ctx.style.display='none'; const mobileFilter=e.target.closest('#mobileFilterBar .mobile-filter'); if(mobileFilter){ document.querySelectorAll('.filter').forEach(x=>x.classList.remove('active')); document.querySelectorAll('.filter').forEach(x=>{ if(x.dataset.filter===mobileFilter.dataset.filter) x.classList.add('active'); }); activeFilter=mobileFilter.dataset.filter; if($('tableWrap'))$('tableWrap').scrollTop=0; if($('mobileList'))$('mobileList').scrollTop=0; scheduleRender(true); return; } const mobileSelectAll=e.target.closest('#mobileSelectAll'); if(mobileSelectAll){ const all=visibleRows.length>0 && visibleRows.every(t=>selected.has(t.hash)); if(all) visibleRows.forEach(t=>selected.delete(t.hash)); else visibleRows.forEach(t=>selected.add(t.hash)); if(selected.size===0){selectedHash=null;lastSelectedHash=null;} else {selectedHash=[...selected][selected.size-1];lastSelectedHash=selectedHash;} scheduleRender(true); return; } const mobileClear=e.target.closest('#mobileClearSelection'); if(mobileClear){ selected.clear(); selectedHash=null; lastSelectedHash=null; scheduleRender(true); return; } const mobileAct=e.target.closest('.mobile-card [data-action]'); if(mobileAct){ const card0=mobileAct.closest('.mobile-card'); selected.clear(); selected.add(card0.dataset.hash); selectedHash=card0.dataset.hash; awaitMaybeRun(mobileAct.dataset.action); scheduleRender(true); return; } const card=e.target.closest('.mobile-card'); const tr=e.target.closest('tr[data-hash]'); const row=tr||card; if(row){ const h=row.dataset.hash; const additive=e.ctrlKey||e.metaKey; if(e.shiftKey){ setSelectionRange(h, additive); } else if(e.target.classList.contains('row-check')){ e.target.checked?selected.add(h):selected.delete(h); lastSelectedHash=h; selectedHash=h; } else { selectedHash=h; if(!additive)selected.clear(); selected.add(h); lastSelectedHash=h; loadDetails(activeTab()); } scheduleRender(true); } const copy=e.target.closest('[data-copy]'); if(copy) copySelected(copy.dataset.copy); const smartEx=e.target.closest('#smartExcludeCtx'); if(smartEx){ selectedHashes().forEach(h=>post('/api/smart-queue/exclusion',{hash:h,excluded:true,reason:'manual'}).catch(()=>{})); toast('Smart Queue exception saved','success'); loadSmartQueue().catch(()=>{}); } const act=e.target.closest('.torrent-action,[data-action]'); if(act&&act.dataset.action&&!act.closest('#detailTabs')&&!act.closest('.mobile-card')) runAction(act.dataset.action); }); document.addEventListener('contextmenu',e=>{ const tr=e.target.closest('tr[data-hash],.mobile-card'); if(!tr)return; e.preventDefault(); selectedHash=tr.dataset.hash; if(!selected.has(selectedHash)){selected.clear();selected.add(selectedHash);scheduleRender(true);} const m=$('ctxMenu'); m.style.left=`${e.pageX}px`; m.style.top=`${e.pageY}px`; m.style.display='block'; }); - document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>th.addEventListener('click',()=>{ const key=th.dataset.sort; if(sortState.key===key) sortState.dir*=-1; else sortState={key,dir:1}; scheduleRender(true); })); $('tableWrap')?.addEventListener('scroll',()=>scheduleRender(false),{passive:true}); $('selectAll')?.addEventListener('change',e=>{selected.clear(); if(e.target.checked)visibleRows.forEach(t=>selected.add(t.hash)); scheduleRender(true);}); $('searchBox')?.addEventListener('input',()=>{if($('tableWrap'))$('tableWrap').scrollTop=0;scheduleRender(true);}); document.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('.filter').forEach(x=>x.classList.remove('active')); b.classList.add('active'); activeFilter=b.dataset.filter; if($('tableWrap'))$('tableWrap').scrollTop=0; scheduleRender(true);})); document.querySelectorAll('#detailTabs .nav-link').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('#detailTabs .nav-link').forEach(x=>x.classList.remove('active')); b.classList.add('active'); loadDetails(b.dataset.tab);})); document.addEventListener('change',e=>{ const sel=e.target.closest('.file-priority'); if(sel){ setFilePriorities([{index:Number(sel.dataset.index),priority:Number(sel.value)}]); return; } if(e.target && e.target.id==='fileSelectAll'){ document.querySelectorAll('#detailPane .file-check').forEach(cb=>cb.checked=e.target.checked); } }); document.addEventListener('click',e=>{ const bulk=e.target.closest('.file-priority-bulk'); if(!bulk) return; const priority=Number(bulk.dataset.priority); const checked=[...document.querySelectorAll('#detailPane .file-check:checked')].map(cb=>({index:Number(cb.dataset.index),priority})); if(!checked.length) return toast('No files selected','warning'); setFilePriorities(checked); }); document.addEventListener('click',e=>{ const add=e.target.closest('#trackerAddBtn'); if(add){ const url=$('trackerAddUrl')?.value||''; trackerAction('add',{url}); return; } const editStart=e.target.closest('.tracker-edit-start'); if(editStart){ setTrackerEdit(editStart.dataset.index,true); return; } const cancel=e.target.closest('.tracker-edit-cancel'); if(cancel){ setTrackerEdit(cancel.dataset.index,false); return; } const save=e.target.closest('.tracker-edit-save'); if(save){ const input=document.querySelector(`.tracker-url[data-tracker-index="${CSS.escape(String(save.dataset.index))}"]`); trackerAction('edit',{index:Number(save.dataset.index),url:input?.value||''}); return; } const rea=e.target.closest('#trackerReannounceBtn'); if(rea) trackerAction('reannounce',{}); }); $('appStatusRefreshBtn')?.addEventListener('click',loadAppStatus); $('portCheckEnabled')?.addEventListener('change',savePortCheckPref); $('portCheckNowBtn')?.addEventListener('click',()=>loadPortCheck(true)); $('bootstrapThemeSelect')?.addEventListener('change',saveAppearancePreferences); $('fontFamilySelect')?.addEventListener('change',saveAppearancePreferences); $('saveFooterPrefsBtn')?.addEventListener('click',saveFooterPreferences); + document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>th.addEventListener('click',()=>{ const key=th.dataset.sort; if(sortState.key===key) sortState.dir*=-1; else sortState={key,dir:1}; scheduleRender(true); })); $('tableWrap')?.addEventListener('scroll',()=>scheduleRender(false),{passive:true}); $('selectAll')?.addEventListener('change',e=>{selected.clear(); if(e.target.checked)visibleRows.forEach(t=>selected.add(t.hash)); scheduleRender(true);}); $('searchBox')?.addEventListener('input',()=>{if($('tableWrap'))$('tableWrap').scrollTop=0;scheduleRender(true);}); document.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('.filter').forEach(x=>x.classList.remove('active')); b.classList.add('active'); activeFilter=b.dataset.filter; if($('tableWrap'))$('tableWrap').scrollTop=0; scheduleRender(true);})); document.querySelectorAll('#detailTabs .nav-link').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('#detailTabs .nav-link').forEach(x=>x.classList.remove('active')); b.classList.add('active'); loadDetails(b.dataset.tab);})); document.addEventListener('change',e=>{ const sel=e.target.closest('.file-priority'); if(sel){ setFilePriorities([{index:Number(sel.dataset.index),priority:Number(sel.value)}]); return; } if(e.target && e.target.id==='fileSelectAll'){ document.querySelectorAll('#detailPane .file-check').forEach(cb=>cb.checked=e.target.checked); } }); document.addEventListener('click',e=>{ const bulk=e.target.closest('.file-priority-bulk'); if(!bulk) return; const priority=Number(bulk.dataset.priority); const checked=[...document.querySelectorAll('#detailPane .file-check:checked')].map(cb=>({index:Number(cb.dataset.index),priority})); if(!checked.length) return toast('No files selected','warning'); setFilePriorities(checked); }); document.addEventListener('click',e=>{ const add=e.target.closest('#trackerAddBtn'); if(add){ const url=$('trackerAddUrl')?.value||''; trackerAction('add',{url}); return; } const del=e.target.closest('.tracker-delete'); if(del && !del.disabled){ trackerAction('delete',{index:Number(del.dataset.index)}); return; } const rea=e.target.closest('#trackerReannounceBtn'); if(rea) trackerAction('reannounce',{}); }); $('appStatusRefreshBtn')?.addEventListener('click',loadAppStatus); $('portCheckEnabled')?.addEventListener('change',savePortCheckPref); $('portCheckNowBtn')?.addEventListener('click',()=>loadPortCheck(true)); $('bootstrapThemeSelect')?.addEventListener('change',saveAppearancePreferences); $('fontFamilySelect')?.addEventListener('change',saveAppearancePreferences); $('saveFooterPrefsBtn')?.addEventListener('click',saveFooterPreferences); document.addEventListener('keydown',e=>{ const tag=(e.target?.tagName||'').toLowerCase(); const editable=tag==='input'||tag==='textarea'||tag==='select'||e.target?.isContentEditable; if(editable){ if(e.key==='Enter' && e.target?.id==='labelInput'){ e.preventDefault(); $('addLabelToSelectionBtn')?.click(); } return; } if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='a'){e.preventDefault();selected.clear();visibleRows.forEach(t=>selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='i'){e.preventDefault();visibleRows.forEach(t=>selected.has(t.hash)?selected.delete(t.hash):selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='o'){e.preventDefault();new bootstrap.Modal($('addModal')).show();} if(e.key==='Escape'){selected.clear();scheduleRender(true);} if(e.key==='Delete') new bootstrap.Modal($('removeModal')).show(); if(e.key===' ') {e.preventDefault();runAction('start');} if(e.key.toLowerCase()==='p')runAction('pause'); if(e.key.toLowerCase()==='s')runAction('stop'); if(e.key.toLowerCase()==='r')runAction('resume'); if(e.key.toLowerCase()==='m')runAction('move'); }); $('removeModal')?.addEventListener('show.bs.modal',()=>{$('removeCount').textContent=selected.size;$('removeData').checked=true;}); $('confirmRemoveBtn')?.addEventListener('click',async()=>{await runAction('remove',{remove_data:$('removeData').checked});bootstrap.Modal.getInstance($('removeModal'))?.hide();}); $('addModal')?.addEventListener('show.bs.modal',()=>applyDefaultDownloadPath(true)); diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index b8c9c54..2fb5889 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -564,10 +564,27 @@ body { gap: 0.4rem; } .toast-item { + display: flex; + align-items: center; + gap: 0.45rem; + max-width: 360px; padding: 0.45rem 0.65rem; border-radius: 0.55rem; box-shadow: 0 8px 25px rgba(0, 0, 0, 0.28); - max-width: 360px; +} + +.toast-message { + min-width: 0; + overflow-wrap: anywhere; +} + +.toast-count { + flex: 0 0 auto; + padding: 0.05rem 0.35rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.22); + font-size: 0.78rem; + font-weight: 700; } @media (max-width: 1100px) { :root { @@ -1630,7 +1647,7 @@ body.mobile-mode .mobile-filter-bar { margin-bottom: 0.55rem; } -.tracker-url { +.tracker-add-input { min-width: 240px; max-width: 520px; } @@ -1742,7 +1759,7 @@ body.mobile-mode .mobile-filter-bar { display: none !important; } - .tracker-url { + .tracker-add-input { min-width: 160px; max-width: 230px; }