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] 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 @@ - +