diff --git a/auth.md b/auth.md index 6c16db4..be89c46 100644 --- a/auth.md +++ b/auth.md @@ -143,7 +143,9 @@ Behavior: - requests with `Host: 10.11.1.11:8090` or `Host: 10.11.1.11` use the built-in default admin user; - requests through the reverse proxy still require the configured auth provider; -- profile permissions are ignored for bypassed direct-IP requests because they run as the default admin user. +- profile permissions are ignored for bypassed direct-IP requests because they run as the default admin user; +- when no active profile is saved for the bypass user, pyTorrent opens the profile picker instead of silently selecting the first profile; +- after selecting a profile, the choice is saved in the default user's preferences and reused on the next direct-IP visit. Do not add public domains to this list. diff --git a/pytorrent/services/preferences.py b/pytorrent/services/preferences.py index 8162eb5..569c12d 100644 --- a/pytorrent/services/preferences.py +++ b/pytorrent/services/preferences.py @@ -118,6 +118,10 @@ def active_profile(user_id: int | None = None): if row: return row profiles = list_profiles(user_id) + # Note: Trusted auth-bypass access must choose a profile explicitly on first entry, + # instead of silently reusing the first configured profile. + if auth.auth_bypassed_request() and profiles: + return None return profiles[0] if profiles else None diff --git a/pytorrent/static/js/profiles.js b/pytorrent/static/js/profiles.js index 623cb5a..13d93b0 100644 --- a/pytorrent/static/js/profiles.js +++ b/pytorrent/static/js/profiles.js @@ -1 +1 @@ -export const profilesSource = " async function activeProfileForSettings(){\n const j=await (await fetch('/api/profiles')).json();\n return j.active || (j.profiles||[])[0] || null;\n }\n function fillJobSettings(profile){\n if(!profile) return;\n if($('jobHeavyParallel')) $('jobHeavyParallel').value=profile.max_parallel_jobs||5;\n if($('jobLightParallel')) $('jobLightParallel').value=profile.light_parallel_jobs||4;\n if($('jobLightTimeout')) $('jobLightTimeout').value=profile.light_job_timeout_seconds||300;\n if($('jobHeavyTimeout')) $('jobHeavyTimeout').value=profile.heavy_job_timeout_seconds||7200;\n if($('jobPendingTimeout')) $('jobPendingTimeout').value=profile.pending_job_timeout_seconds||900;\n if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent=profile.name?`Active profile: ${profile.name}`:'';\n }\n async function loadJobSettings(){\n try{\n const profile=await activeProfileForSettings();\n if(!profile){ if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent='No active profile.'; return; }\n fillJobSettings(profile);\n }catch(e){ if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent=e.message; }\n }\n function jobSettingsPayload(profile){\n return {\n name:profile.name,\n scgi_url:profile.scgi_url,\n timeout_seconds:profile.timeout_seconds||5,\n max_parallel_jobs:$('jobHeavyParallel')?.value||5,\n light_parallel_jobs:$('jobLightParallel')?.value||4,\n light_job_timeout_seconds:$('jobLightTimeout')?.value||300,\n heavy_job_timeout_seconds:$('jobHeavyTimeout')?.value||7200,\n pending_job_timeout_seconds:$('jobPendingTimeout')?.value||900,\n is_remote:!!profile.is_remote,\n is_default:!!profile.is_default\n };\n }\n async function saveJobSettings(){\n const btn=$('saveJobSettingsBtn');\n buttonBusy(btn,true);\n try{\n const profile=await activeProfileForSettings();\n if(!profile) throw new Error('No active profile');\n const j=await post(`/api/profiles/${profile.id}`,jobSettingsPayload(profile),'PUT');\n fillJobSettings(j.profile||profile);\n await refreshProfiles();\n toast('Job settings saved','success');\n }catch(e){ toast(e.message,'danger'); }\n finally{ buttonBusy(btn,false); }\n }\n function markActiveProfileRow(id){\n // Note: Keeps the active rTorrent profile frame in sync immediately after switching, before diagnostics refresh finishes.\n const activeId=String(id||'');\n document.querySelectorAll('#profileList .profile-row').forEach(row=>{\n const isActive=String(row.dataset.profileId||'')===activeId;\n row.classList.toggle('active', isActive);\n row.setAttribute('aria-current', isActive ? 'true' : 'false');\n const badge=row.querySelector('[data-active-profile-badge]');\n if(badge) badge.classList.toggle('d-none', !isActive);\n });\n }\n async function refreshProfiles(){ const j=await (await fetch('/api/profiles')).json(); profileCache=new Map((j.profiles||[]).map(p=>[String(p.id),p])); const active=String(j.active?.id ?? activeProfileId ?? ''); const rows=j.profiles||[]; const statusMap=new Map(); try{ const d=await (await fetch('/api/profiles/diagnostics')).json(); (d.diagnostics||[]).forEach(x=>statusMap.set(String(x.profile_id), x)); }catch(e){} $('profileList').innerHTML=rows.map(p=>{ const d=statusMap.get(String(p.id))||{}; const st=d.status || 'unknown'; const cls=st==='online'?'success':st==='slow'?'warning':st==='error'?'danger':'secondary'; const isActive=String(p.id)===active; return `
${esc(p.name)} active ${p.is_remote?\"remote\":''} ${esc(st)}${esc(p.scgi_url)} \u00b7 heavy ${esc(p.max_parallel_jobs||5)} \u00b7 light ${esc(p.light_parallel_jobs||4)} \u00b7 API ${esc(p.api_limit_per_minute||'-')}/min \u00b7 poll ${esc(p.polling_min_interval_seconds||'-')}s${d.response_time_ms?` \u00b7 ${esc(d.response_time_ms)} ms`:''}
`; }).join('')||'No profiles.'; }\n function profileFormPayload(){ return {id:$('profileId')?.value||null,name:$('profileName')?.value||'',scgi_url:$('profileUrl')?.value||'',timeout_seconds:$('profileTimeout')?.value||5,max_parallel_jobs:$('profileParallel')?.value||5,light_parallel_jobs:$('jobLightParallel')?.value||4,light_job_timeout_seconds:$('jobLightTimeout')?.value||300,heavy_job_timeout_seconds:$('jobHeavyTimeout')?.value||7200,pending_job_timeout_seconds:$('jobPendingTimeout')?.value||900,is_remote:$('profileRemote')?.checked}; }\n function renderProfileDiagnostics(d={}){ const box=$('profileDiagnosticsResult'); if(!box) return; const status=d.status || (d.ok?'online':'error'); const paths=d.base_paths||{}; const wp=d.write_permissions||{}; const disk=d.free_disk||{}; const firstDisk=Object.values(disk)[0]||{}; const cards=[['Status',status],['rTorrent',d.version||'-'],['Library',d.library_version||'-'],['Response',d.response_time_ms!=null?`${d.response_time_ms} ms`:'-'],['Default path',paths.default_directory||'-'],['CWD',paths.cwd||'-'],['Write',Object.values(wp)[0]||'-'],['Free disk',firstDisk.free_h||firstDisk.error||'-']]; box.innerHTML=`
${cards.map(([k,v])=>`
${esc(k)}${esc(v)}
`).join('')}
${d.error?`
${esc(d.error)}
`:''}`; }\n async function testProfilePayload(payload=null){ const p=payload||profileFormPayload(); const res=await post('/api/profiles/test', p); renderProfileDiagnostics(res.diagnostics||{}); return res.diagnostics||{}; }\n\n function resetProfileForm(){ if($('profileId')) $('profileId').value=''; if($('profileName')) $('profileName').value=''; if($('profileUrl')) $('profileUrl').value=''; if($('profileTimeout')) $('profileTimeout').value='5'; if($('profileParallel')) $('profileParallel').value='5'; if($('profileRemote')) $('profileRemote').checked=false; if($('profileFormTitle')) $('profileFormTitle').textContent='Add rTorrent profile'; if($('saveProfileBtn')) $('saveProfileBtn').innerHTML=' Add profile'; $('cancelProfileEditBtn')?.classList.add('d-none'); }\n function editProfileForm(profile){ if(!profile) return; if($('profileId')) $('profileId').value=profile.id; if($('profileName')) $('profileName').value=profile.name||''; if($('profileUrl')) $('profileUrl').value=profile.scgi_url||''; if($('profileTimeout')) $('profileTimeout').value=profile.timeout_seconds||5; if($('profileParallel')) $('profileParallel').value=profile.max_parallel_jobs||5; if($('profileRemote')) $('profileRemote').checked=!!profile.is_remote; fillJobSettings(profile); if($('profileFormTitle')) $('profileFormTitle').textContent='Edit rTorrent profile'; if($('saveProfileBtn')) $('saveProfileBtn').innerHTML=' Save profile'; $('cancelProfileEditBtn')?.classList.remove('d-none'); $('profileName')?.focus(); }\n async function activateProfileAndRefresh(id, label=''){\n // Note: Profile activation now refreshes all profile-scoped client state without requiring a browser reload.\n if(!id) return;\n setBusy(true, 'Switching profile...');\n try{\n await post(`/api/profiles/${id}/activate`,{});\n activeProfileId=id;\n window.PYTORRENT.activeProfile=Number(id);\n markActiveProfileRow(id);\n if($('activeProfileName') && label) $('activeProfileName').textContent=label;\n bootstrap.Modal.getInstance($('profilePickerModal'))?.hide();\n defaultDownloadPath=null;\n lastUserDiskFetchAt=0;\n userDiskFetchSeq += 1;\n userDiskFetchInFlight=false;\n clearRtorrentStartingState();\n hasTorrentSnapshot=false;\n torrentSummary=null;\n trackerSummary={hashes:{}, trackers:[], scanned:0, errors:[]};\n trackerSummaryStatus='idle';\n trackerSummarySignature='';\n torrents.clear();\n selected.clear();\n selectedHash=null;\n scheduleRender(true);\n await loadPreferences().catch(()=>{});\n await Promise.allSettled([\n refreshProfiles(),\n applyDefaultDownloadPath(true),\n refreshUserDiskUsage(true),\n loadSmartQueue(),\n loadDownloadPlanner(),\n loadPollerSettings(),\n ]);\n socket.emit('select_profile',{profile_id:Number(id)});\n toast('Profile switched','success');\n }catch(e){\n toast(e.message||'Profile switch failed','danger');\n }finally{\n setBusy(false);\n }\n }\n\n // Note: The rTorrent list lives in Tools modal; refresh it when that modal is shown instead of referencing a missing modal id.\n $('profilePickerModal')?.addEventListener('show.bs.modal',async()=>{\n try{\n const j=await (await fetch('/api/profiles')).json();\n const select=$('profileSelect');\n if(select) select.innerHTML=(j.profiles||[]).map(p=>``).join('') || '';\n }catch(e){}\n }); $('profileList')?.addEventListener('click',async e=>{const btn=e.target.closest('[data-del-profile],[data-use-profile],[data-edit-profile],[data-test-saved-profile]'); const del=btn?.dataset.delProfile,use=btn?.dataset.useProfile,edit=btn?.dataset.editProfile,test=btn?.dataset.testSavedProfile;if(test){ const oldHtml=btn.innerHTML; btn.disabled=true; btn.innerHTML=' testing'; const box=$('profileDiagnosticsResult'); if(box) box.innerHTML='
Testing saved profile...
'; try{ const r=await (await fetch(`/api/profiles/${test}/diagnostics`)).json(); renderProfileDiagnostics(r.diagnostics||{}); }catch(e){ if(box) box.innerHTML=`
${esc(e.message)}
`; toast(e.message,'danger'); } finally{ btn.disabled=false; btn.innerHTML=oldHtml; } return; } if(edit){editProfileForm(profileCache.get(String(edit)));return;} if(del){setBusy(true);await fetch(`/api/profiles/${del}`,{method:'DELETE'});setBusy(false);refreshProfiles();location.reload();} if(use){await activateProfileAndRefresh(use, profileCache.get(String(use))?.name || 'rTorrent');}}); $('cancelProfileEditBtn')?.addEventListener('click',resetProfileForm); $('testProfileBtn')?.addEventListener('click',async()=>{ const btn=$('testProfileBtn'); const oldHtml=btn?.innerHTML; if(btn){ btn.disabled=true; btn.innerHTML=' Testing SCGI...'; } const box=$('profileDiagnosticsResult'); if(box) box.innerHTML='
Testing SCGI connection...
'; setBusy(true); try{ const d=await testProfilePayload(); toast(d.ok?'SCGI test OK':'SCGI test failed', d.ok?'success':'danger'); }catch(e){ toast(e.message,'danger'); if(box) box.innerHTML=`
${esc(e.message)}
`; } finally{setBusy(false); if(btn){ btn.disabled=false; btn.innerHTML=oldHtml||' Test SCGI'; }} }); $('profileExportBtn')?.addEventListener('click',async()=>{ const j=await (await fetch('/api/profiles/export')).json(); const blob=new Blob([JSON.stringify(j,null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='pytorrent-profiles.json'; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); }); $('profileImportBtn')?.addEventListener('click',()=>$('profileImportFile')?.click()); $('profileImportFile')?.addEventListener('change',async e=>{ const file=e.target.files?.[0]; if(!file) return; try{ const payload=JSON.parse(await file.text()); await post('/api/profiles/import',payload); toast('Profiles imported','success'); refreshProfiles(); }catch(err){ toast(err.message,'danger'); } e.target.value=''; }); $('saveProfileBtn')?.addEventListener('click',async()=>{setBusy(true);const id=$('profileId')?.value;const payload=profileFormPayload();const j=await post(id?`/api/profiles/${id}`:'/api/profiles',payload,id?'PUT':'POST').catch(e=>toast(e.message,'danger'));setBusy(false);if(j?.profile)location.reload();}); $('saveJobSettingsBtn')?.addEventListener('click',saveJobSettings); $('reloadJobSettingsBtn')?.addEventListener('click',loadJobSettings); $('profileSelect')?.addEventListener('change',async e=>{const id=e.target.value;if(!id)return;const opt=e.target.selectedOptions?.[0];await activateProfileAndRefresh(id, opt?.textContent || 'rTorrent');});\n // Note: Opens the existing rTorrent form directly from the empty first-run state.\n document.addEventListener('click',e=>{ if(!e.target.closest('#setupProfileBtn')) return; activateToolTab('rtorrents'); new bootstrap.Modal($('toolsModal')).show(); setTimeout(()=>$('profileName')?.focus(),150); });\n // Note: On a fresh install there is no rTorrent snapshot to wait for, so open the app and show setup immediately.\n function showFirstRunSetup(){\n if(hasActiveProfile || firstRunSetupShown) return;\n firstRunSetupShown = true;\n $('connBadge').className='badge text-bg-warning';\n $('connBadge').textContent='setup required';\n setInitialLoader('Configure rTorrent','Add the first rTorrent profile to start loading torrents.');\n renderNoProfileState();\n hideInitialLoader();\n setTimeout(()=>{ activateToolTab('rtorrents'); new bootstrap.Modal($('toolsModal')).show(); }, 120);\n }\n"; +export const profilesSource = " async function activeProfileForSettings(){\n const j=await (await fetch('/api/profiles')).json();\n return j.active || (j.profiles||[])[0] || null;\n }\n function fillJobSettings(profile){\n if(!profile) return;\n if($('jobHeavyParallel')) $('jobHeavyParallel').value=profile.max_parallel_jobs||5;\n if($('jobLightParallel')) $('jobLightParallel').value=profile.light_parallel_jobs||4;\n if($('jobLightTimeout')) $('jobLightTimeout').value=profile.light_job_timeout_seconds||300;\n if($('jobHeavyTimeout')) $('jobHeavyTimeout').value=profile.heavy_job_timeout_seconds||7200;\n if($('jobPendingTimeout')) $('jobPendingTimeout').value=profile.pending_job_timeout_seconds||900;\n if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent=profile.name?`Active profile: ${profile.name}`:'';\n }\n async function loadJobSettings(){\n try{\n const profile=await activeProfileForSettings();\n if(!profile){ if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent='No active profile.'; return; }\n fillJobSettings(profile);\n }catch(e){ if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent=e.message; }\n }\n function jobSettingsPayload(profile){\n return {\n name:profile.name,\n scgi_url:profile.scgi_url,\n timeout_seconds:profile.timeout_seconds||5,\n max_parallel_jobs:$('jobHeavyParallel')?.value||5,\n light_parallel_jobs:$('jobLightParallel')?.value||4,\n light_job_timeout_seconds:$('jobLightTimeout')?.value||300,\n heavy_job_timeout_seconds:$('jobHeavyTimeout')?.value||7200,\n pending_job_timeout_seconds:$('jobPendingTimeout')?.value||900,\n is_remote:!!profile.is_remote,\n is_default:!!profile.is_default\n };\n }\n async function saveJobSettings(){\n const btn=$('saveJobSettingsBtn');\n buttonBusy(btn,true);\n try{\n const profile=await activeProfileForSettings();\n if(!profile) throw new Error('No active profile');\n const j=await post(`/api/profiles/${profile.id}`,jobSettingsPayload(profile),'PUT');\n fillJobSettings(j.profile||profile);\n await refreshProfiles();\n toast('Job settings saved','success');\n }catch(e){ toast(e.message,'danger'); }\n finally{ buttonBusy(btn,false); }\n }\n function markActiveProfileRow(id){\n // Note: Keeps the active rTorrent profile frame in sync immediately after switching, before diagnostics refresh finishes.\n const activeId=String(id||'');\n document.querySelectorAll('#profileList .profile-row').forEach(row=>{\n const isActive=String(row.dataset.profileId||'')===activeId;\n row.classList.toggle('active', isActive);\n row.setAttribute('aria-current', isActive ? 'true' : 'false');\n const badge=row.querySelector('[data-active-profile-badge]');\n if(badge) badge.classList.toggle('d-none', !isActive);\n });\n }\n async function refreshProfiles(){ const j=await (await fetch('/api/profiles')).json(); profileCache=new Map((j.profiles||[]).map(p=>[String(p.id),p])); const active=String(j.active?.id ?? activeProfileId ?? ''); const rows=j.profiles||[]; const statusMap=new Map(); try{ const d=await (await fetch('/api/profiles/diagnostics')).json(); (d.diagnostics||[]).forEach(x=>statusMap.set(String(x.profile_id), x)); }catch(e){} $('profileList').innerHTML=rows.map(p=>{ const d=statusMap.get(String(p.id))||{}; const st=d.status || 'unknown'; const cls=st==='online'?'success':st==='slow'?'warning':st==='error'?'danger':'secondary'; const isActive=String(p.id)===active; return `
${esc(p.name)} active ${p.is_remote?\"remote\":''} ${esc(st)}${esc(p.scgi_url)} · heavy ${esc(p.max_parallel_jobs||5)} · light ${esc(p.light_parallel_jobs||4)} · API ${esc(p.api_limit_per_minute||'-')}/min · poll ${esc(p.polling_min_interval_seconds||'-')}s${d.response_time_ms?` · ${esc(d.response_time_ms)} ms`:''}
`; }).join('')||'No profiles.'; }\n function profileFormPayload(){ return {id:$('profileId')?.value||null,name:$('profileName')?.value||'',scgi_url:$('profileUrl')?.value||'',timeout_seconds:$('profileTimeout')?.value||5,max_parallel_jobs:$('profileParallel')?.value||5,light_parallel_jobs:$('jobLightParallel')?.value||4,light_job_timeout_seconds:$('jobLightTimeout')?.value||300,heavy_job_timeout_seconds:$('jobHeavyTimeout')?.value||7200,pending_job_timeout_seconds:$('jobPendingTimeout')?.value||900,is_remote:$('profileRemote')?.checked}; }\n function renderProfileDiagnostics(d={}){ const box=$('profileDiagnosticsResult'); if(!box) return; const status=d.status || (d.ok?'online':'error'); const paths=d.base_paths||{}; const wp=d.write_permissions||{}; const disk=d.free_disk||{}; const firstDisk=Object.values(disk)[0]||{}; const cards=[['Status',status],['rTorrent',d.version||'-'],['Library',d.library_version||'-'],['Response',d.response_time_ms!=null?`${d.response_time_ms} ms`:'-'],['Default path',paths.default_directory||'-'],['CWD',paths.cwd||'-'],['Write',Object.values(wp)[0]||'-'],['Free disk',firstDisk.free_h||firstDisk.error||'-']]; box.innerHTML=`
${cards.map(([k,v])=>`
${esc(k)}${esc(v)}
`).join('')}
${d.error?`
${esc(d.error)}
`:''}`; }\n async function testProfilePayload(payload=null){ const p=payload||profileFormPayload(); const res=await post('/api/profiles/test', p); renderProfileDiagnostics(res.diagnostics||{}); return res.diagnostics||{}; }\n\n function resetProfileForm(){ if($('profileId')) $('profileId').value=''; if($('profileName')) $('profileName').value=''; if($('profileUrl')) $('profileUrl').value=''; if($('profileTimeout')) $('profileTimeout').value='5'; if($('profileParallel')) $('profileParallel').value='5'; if($('profileRemote')) $('profileRemote').checked=false; if($('profileFormTitle')) $('profileFormTitle').textContent='Add rTorrent profile'; if($('saveProfileBtn')) $('saveProfileBtn').innerHTML=' Add profile'; $('cancelProfileEditBtn')?.classList.add('d-none'); }\n function editProfileForm(profile){ if(!profile) return; if($('profileId')) $('profileId').value=profile.id; if($('profileName')) $('profileName').value=profile.name||''; if($('profileUrl')) $('profileUrl').value=profile.scgi_url||''; if($('profileTimeout')) $('profileTimeout').value=profile.timeout_seconds||5; if($('profileParallel')) $('profileParallel').value=profile.max_parallel_jobs||5; if($('profileRemote')) $('profileRemote').checked=!!profile.is_remote; fillJobSettings(profile); if($('profileFormTitle')) $('profileFormTitle').textContent='Edit rTorrent profile'; if($('saveProfileBtn')) $('saveProfileBtn').innerHTML=' Save profile'; $('cancelProfileEditBtn')?.classList.remove('d-none'); $('profileName')?.focus(); }\n async function activateProfileAndRefresh(id, label=''){\n // Note: Profile activation now refreshes all profile-scoped client state without requiring a browser reload.\n if(!id) return;\n setBusy(true, 'Switching profile...');\n try{\n await post(`/api/profiles/${id}/activate`,{});\n activeProfileId=id;\n hasActiveProfile=true;\n window.PYTORRENT.activeProfile=Number(id);\n markActiveProfileRow(id);\n if($('activeProfileName') && label) $('activeProfileName').textContent=label;\n bootstrap.Modal.getInstance($('profilePickerModal'))?.hide();\n defaultDownloadPath=null;\n lastUserDiskFetchAt=0;\n userDiskFetchSeq += 1;\n userDiskFetchInFlight=false;\n clearRtorrentStartingState();\n hasTorrentSnapshot=false;\n torrentSummary=null;\n trackerSummary={hashes:{}, trackers:[], scanned:0, errors:[]};\n trackerSummaryStatus='idle';\n trackerSummarySignature='';\n torrents.clear();\n selected.clear();\n selectedHash=null;\n scheduleRender(true);\n await loadPreferences().catch(()=>{});\n await Promise.allSettled([\n refreshProfiles(),\n applyDefaultDownloadPath(true),\n refreshUserDiskUsage(true),\n loadSmartQueue(),\n loadDownloadPlanner(),\n loadPollerSettings(),\n ]);\n socket.emit('select_profile',{profile_id:Number(id)});\n toast('Profile switched','success');\n }catch(e){\n toast(e.message||'Profile switch failed','danger');\n }finally{\n setBusy(false);\n }\n }\n\n // Note: The rTorrent list lives in Tools modal; refresh it when that modal is shown instead of referencing a missing modal id.\n $('profilePickerModal')?.addEventListener('show.bs.modal',async()=>{\n try{\n const j=await (await fetch('/api/profiles')).json();\n const select=$('profileSelect');\n if(select) select.innerHTML=(j.profiles||[]).map(p=>``).join('') || '';\n }catch(e){}\n }); $('profileList')?.addEventListener('click',async e=>{const btn=e.target.closest('[data-del-profile],[data-use-profile],[data-edit-profile],[data-test-saved-profile]'); const del=btn?.dataset.delProfile,use=btn?.dataset.useProfile,edit=btn?.dataset.editProfile,test=btn?.dataset.testSavedProfile;if(test){ const oldHtml=btn.innerHTML; btn.disabled=true; btn.innerHTML=' testing'; const box=$('profileDiagnosticsResult'); if(box) box.innerHTML='
Testing saved profile...
'; try{ const r=await (await fetch(`/api/profiles/${test}/diagnostics`)).json(); renderProfileDiagnostics(r.diagnostics||{}); }catch(e){ if(box) box.innerHTML=`
${esc(e.message)}
`; toast(e.message,'danger'); } finally{ btn.disabled=false; btn.innerHTML=oldHtml; } return; } if(edit){editProfileForm(profileCache.get(String(edit)));return;} if(del){setBusy(true);await fetch(`/api/profiles/${del}`,{method:'DELETE'});setBusy(false);refreshProfiles();location.reload();} if(use){await activateProfileAndRefresh(use, profileCache.get(String(use))?.name || 'rTorrent');}}); $('cancelProfileEditBtn')?.addEventListener('click',resetProfileForm); $('testProfileBtn')?.addEventListener('click',async()=>{ const btn=$('testProfileBtn'); const oldHtml=btn?.innerHTML; if(btn){ btn.disabled=true; btn.innerHTML=' Testing SCGI...'; } const box=$('profileDiagnosticsResult'); if(box) box.innerHTML='
Testing SCGI connection...
'; setBusy(true); try{ const d=await testProfilePayload(); toast(d.ok?'SCGI test OK':'SCGI test failed', d.ok?'success':'danger'); }catch(e){ toast(e.message,'danger'); if(box) box.innerHTML=`
${esc(e.message)}
`; } finally{setBusy(false); if(btn){ btn.disabled=false; btn.innerHTML=oldHtml||' Test SCGI'; }} }); $('profileExportBtn')?.addEventListener('click',async()=>{ const j=await (await fetch('/api/profiles/export')).json(); const blob=new Blob([JSON.stringify(j,null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='pytorrent-profiles.json'; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); }); $('profileImportBtn')?.addEventListener('click',()=>$('profileImportFile')?.click()); $('profileImportFile')?.addEventListener('change',async e=>{ const file=e.target.files?.[0]; if(!file) return; try{ const payload=JSON.parse(await file.text()); await post('/api/profiles/import',payload); toast('Profiles imported','success'); refreshProfiles(); }catch(err){ toast(err.message,'danger'); } e.target.value=''; }); $('saveProfileBtn')?.addEventListener('click',async()=>{setBusy(true);const id=$('profileId')?.value;const payload=profileFormPayload();const j=await post(id?`/api/profiles/${id}`:'/api/profiles',payload,id?'PUT':'POST').catch(e=>toast(e.message,'danger'));setBusy(false);if(j?.profile)location.reload();}); $('saveJobSettingsBtn')?.addEventListener('click',saveJobSettings); $('reloadJobSettingsBtn')?.addEventListener('click',loadJobSettings); $('profileSelect')?.addEventListener('change',async e=>{const id=e.target.value;if(!id)return;const opt=e.target.selectedOptions?.[0];await activateProfileAndRefresh(id, opt?.textContent || 'rTorrent');}); $('profilePickerUseBtn')?.addEventListener('click',async()=>{const select=$('profileSelect');const id=select?.value;if(!id)return;const opt=select.selectedOptions?.[0];await activateProfileAndRefresh(id, opt?.textContent || 'rTorrent');});\n // Note: Opens the existing rTorrent form directly from the empty first-run state.\n document.addEventListener('click',e=>{ if(e.target.closest('#setupProfileBtn')){ activateToolTab('rtorrents'); new bootstrap.Modal($('toolsModal')).show(); setTimeout(()=>$('profileName')?.focus(),150); return; } if(e.target.closest('#chooseProfileBtn')){ openProfilePicker(); } });\n function renderProfileSelectionState(count=0){\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body = $('torrentBody');\n if(body){\n body.innerHTML = `
Select an rTorrent profile.${esc(count)} profile(s) are configured for this trusted bypass session. Choose which one to open.
`;\n }\n const list = $('mobileList');\n if(list) list.innerHTML = `
Select an rTorrent profile.Choose a profile to load torrents.
`;\n if($('detailPane')) $('detailPane').innerHTML = 'Choose an rTorrent profile to load details.';\n }\n\n async function openProfilePicker(){\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n const select=$('profileSelect');\n if(select) select.innerHTML=(j.profiles||[]).map(p=>``).join('') || '';\n }catch(e){}\n new bootstrap.Modal($('profilePickerModal')).show();\n }\n\n // Note: On trusted auth-bypass entry, existing profiles are not auto-selected; the visitor must choose the target profile.\n async function showFirstRunSetup(){\n if(hasActiveProfile || firstRunSetupShown) return;\n firstRunSetupShown = true;\n let profiles=[];\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n if(j.active?.id){\n activeProfileId=j.active.id;\n hasActiveProfile=true;\n window.PYTORRENT.activeProfile=Number(j.active.id);\n return;\n }\n profiles=j.profiles||[];\n }catch(e){}\n $('connBadge').className='badge text-bg-warning';\n if(profiles.length){\n $('connBadge').textContent='select profile';\n setInitialLoader('Select rTorrent profile','Choose which configured rTorrent profile to open.');\n renderProfileSelectionState(profiles.length);\n hideInitialLoader();\n setTimeout(()=>openProfilePicker(), 120);\n return;\n }\n $('connBadge').textContent='setup required';\n setInitialLoader('Configure rTorrent','Add the first rTorrent profile to start loading torrents.');\n renderNoProfileState();\n hideInitialLoader();\n setTimeout(()=>{ activateToolTab('rtorrents'); new bootstrap.Modal($('toolsModal')).show(); }, 120);\n }\n"; diff --git a/pytorrent/static/js/state.js b/pytorrent/static/js/state.js index 50bc95f..aa13c44 100644 --- a/pytorrent/static/js/state.js +++ b/pytorrent/static/js/state.js @@ -1 +1 @@ -export const stateSource = " const $ = (id) => document.getElementById(id);\n const esc = (s) => String(s ?? \"\").replace(/[&<>'\"]/g, c => ({\"&\":\"&\",\"<\":\"<\",\">\":\">\",\"'\":\"'\",'\"':\""\"}[c]));\n // Note: Footer transfer totals can arrive as already formatted strings, so keep this helper tolerant and side-effect free.\n function compactTransferText(value){\n const text = String(value ?? \"\").trim();\n if(!text) return \"-\";\n return text.replace(/\\\\s+/g, \" \");\n }\n const ROW_HEIGHT = 32, COMPACT_ROW_HEIGHT = 24, OVERSCAN = 14;\n const torrents = new Map();\n const browserViewPrefs = (()=>{ try{return JSON.parse(localStorage.getItem('pyTorrent.mobileViewPrefs')||'{}')||{};}catch(e){return {};} })();\n const savedFilter = String(browserViewPrefs.activeFilter || window.PYTORRENT?.activeFilter || \"all\");\n // Note: Mobile has both \"All\" and \"All trackers\" options, so keep the exact selected option separate from the shared filter state.\n let mobileActiveFilterKey = String(browserViewPrefs.mobileFilterKey || savedFilter || \"all\");\n let visibleRows = [], selected = new Set(), selectedHash = null, lastSelectedHash = null, activeFilter = savedFilter.startsWith(\"tracker:\") ? \"all\" : (savedFilter || \"all\");\n let activeTrackerFilter = savedFilter.startsWith(\"tracker:\") ? savedFilter.slice(8) : \"\";\n const SORT_KEYS = new Set([\"name\", \"status\", \"size\", \"progress\", \"down_rate\", \"up_rate\", \"eta\", \"seeds\", \"peers\", \"ratio\", \"path\", \"label\", \"ratio_group\", \"down_total\", \"to_download\", \"up_total\", \"created\", \"priority\", \"state\", \"active\", \"complete\", \"hashing\", \"message\", \"hash\"]);\n const savedSort = browserViewPrefs.sortState || window.PYTORRENT?.torrentSort || {};\n let sortState = {key: SORT_KEYS.has(savedSort.key) ? savedSort.key : \"name\", dir: Number(savedSort.dir) < 0 ? -1 : 1}, renderPending = false, renderVersion = 0, lastRenderSignature = \"\";\n let compactTorrentListEnabled = Number(window.PYTORRENT?.compactTorrentListEnabled || 0) !== 0;\n // Note: Mobile sort filters are configurable because the full sortable list is too large for quick phone use.\n const DEFAULT_MOBILE_SORT_FILTER_IDS = new Set([\"seeds:-1\", \"up_rate:-1\", \"down_rate:-1\", \"progress:-1\"]);\n const MOBILE_SORT_STEPS = [\n {key:\"down_rate\", dir:-1, label:\"DL\"},\n {key:\"down_rate\", dir:1, label:\"DL\"},\n {key:\"up_rate\", dir:-1, label:\"UL\"},\n {key:\"up_rate\", dir:1, label:\"UL\"},\n {key:\"progress\", dir:-1, label:\"Progress\"},\n {key:\"progress\", dir:1, label:\"Progress\"},\n {key:\"eta\", dir:-1, label:\"ETA\"},\n {key:\"eta\", dir:1, label:\"ETA\"},\n {key:\"ratio\", dir:-1, label:\"Ratio\"},\n {key:\"ratio\", dir:1, label:\"Ratio\"},\n {key:\"size\", dir:-1, label:\"Size\"},\n {key:\"size\", dir:1, label:\"Size\"},\n {key:\"seeds\", dir:-1, label:\"Seeds\"},\n {key:\"seeds\", dir:1, label:\"Seeds\"},\n {key:\"peers\", dir:-1, label:\"Peers\"},\n {key:\"peers\", dir:1, label:\"Peers\"},\n {key:\"status\", dir:1, label:\"Status\"},\n {key:\"status\", dir:-1, label:\"Status\"},\n {key:\"label\", dir:1, label:\"Label\"},\n {key:\"label\", dir:-1, label:\"Label\"},\n {key:\"ratio_group\", dir:1, label:\"Ratio group\"},\n {key:\"ratio_group\", dir:-1, label:\"Ratio group\"},\n {key:\"down_total\", dir:-1, label:\"Downloaded\"},\n {key:\"down_total\", dir:1, label:\"Downloaded\"},\n {key:\"to_download\", dir:-1, label:\"To download\"},\n {key:\"to_download\", dir:1, label:\"To download\"},\n {key:\"up_total\", dir:-1, label:\"Uploaded\"},\n {key:\"up_total\", dir:1, label:\"Uploaded\"},\n {key:\"created\", dir:-1, label:\"Added\"},\n {key:\"created\", dir:1, label:\"Added\"},\n {key:\"priority\", dir:-1, label:\"Priority\"},\n {key:\"priority\", dir:1, label:\"Priority\"},\n {key:\"state\", dir:-1, label:\"State\"},\n {key:\"state\", dir:1, label:\"State\"},\n {key:\"active\", dir:-1, label:\"Active\"},\n {key:\"active\", dir:1, label:\"Active\"},\n {key:\"complete\", dir:-1, label:\"Complete\"},\n {key:\"complete\", dir:1, label:\"Complete\"},\n {key:\"hashing\", dir:-1, label:\"Hashing\"},\n {key:\"hashing\", dir:1, label:\"Hashing\"},\n {key:\"message\", dir:1, label:\"Message\"},\n {key:\"message\", dir:-1, label:\"Message\"},\n {key:\"path\", dir:1, label:\"Path\"},\n {key:\"path\", dir:-1, label:\"Path\"},\n {key:\"hash\", dir:1, label:\"Hash\"},\n {key:\"hash\", dir:-1, label:\"Hash\"},\n {key:\"name\", dir:1, label:\"Name\"},\n {key:\"name\", dir:-1, label:\"Name\"}\n ];\n let lastLimits = {down: 0, up: 0}, pendingBusy = 0, pathTarget = null, lastPathParent = \"/\";\n const traffic = [], systemUsage = [];\n const socket = (typeof io === \"function\") ? io({transports:[\"polling\"], reconnection:true, reconnectionAttempts:Infinity, reconnectionDelay:700, reconnectionDelayMax:5000, timeout:8000}) : {connected:false,on(){},emit(){},io:{on(){}}};\n const COLUMN_DEFS = [[\"status\",\"Status\",false],[\"size\",\"Size\",false],[\"progress\",\"Progressbar\",false],[\"down_rate\",\"DL\",false],[\"up_rate\",\"UL\",false],[\"eta\",\"ETA\",false],[\"seeds\",\"Seeds\",false],[\"peers\",\"Peers\",false],[\"ratio\",\"Ratio\",false],[\"path\",\"Path\",false],[\"label\",\"Label\",false],[\"ratio_group\",\"Ratio group\",false],[\"down_total\",\"Downloaded\",true],[\"to_download\",\"To download\",true],[\"up_total\",\"Uploaded\",true],[\"created\",\"Added\",true],[\"priority\",\"Priority\",true],[\"state\",\"State\",true],[\"active\",\"Active\",true],[\"complete\",\"Complete\",true],[\"hashing\",\"Hashing\",true],[\"message\",\"Message\",true],[\"hash\",\"Hash\",true]];\n const DEFAULT_HIDDEN_COLUMNS = new Set(COLUMN_DEFS.filter(([, , hiddenByDefault]) => hiddenByDefault).map(([key]) => key));\n const savedColumns = window.PYTORRENT?.tableColumns || {};\n const DEFAULT_COLUMN_WIDTHS = {\n select: 34, name: 360, status: 110, size: 90, progress: 120,\n down_rate: 86, up_rate: 86, eta: 92, seeds: 70, peers: 70,\n ratio: 72, path: 300, label: 140, ratio_group: 130,\n down_total: 120, to_download: 120, up_total: 120, created: 150,\n priority: 80, state: 70, active: 70, complete: 82, hashing: 82,\n message: 220, hash: 280\n };\n const COLUMN_WIDTH_MIN = 44;\n const COLUMN_WIDTH_MAX = 720;\n const explicitlyShownColumns = new Set(savedColumns.shown || []);\n let hiddenColumns = new Set([...(savedColumns.hidden || []), ...[...DEFAULT_HIDDEN_COLUMNS].filter(key => !explicitlyShownColumns.has(key))]);\n // Note: Column widths are persisted with the existing column preferences payload, so no database migration is needed.\n function normalizeColumnWidths(value={}){\n const allowed = new Set(['select', ...COLUMN_DEFS.map(([key]) => key)]);\n const normalized = {...DEFAULT_COLUMN_WIDTHS};\n Object.entries(value || {}).forEach(([key, width])=>{\n if(allowed.has(key)) normalized[key] = clampNumber(width, COLUMN_WIDTH_MIN, COLUMN_WIDTH_MAX, DEFAULT_COLUMN_WIDTHS[key] || 120);\n });\n return normalized;\n }\n let columnWidths = normalizeColumnWidths(savedColumns.widths || {});\n if(browserViewPrefs.columnWidths) columnWidths = normalizeColumnWidths({...columnWidths, ...browserViewPrefs.columnWidths});\n function mobileSortStepId(step){ return `${step.key}:${step.dir}`; }\n function normalizeMobileSortFilters(value={}){\n const normalized = Object.fromEntries(MOBILE_SORT_STEPS.map(step => {\n const id = mobileSortStepId(step);\n return [id, DEFAULT_MOBILE_SORT_FILTER_IDS.has(id)];\n }));\n Object.entries(value || {}).forEach(([id, enabled]) => { if(id in normalized) normalized[id] = !!enabled; });\n return normalized;\n }\n let mobileSortFilters = normalizeMobileSortFilters(savedColumns.mobileSortFilters || {});\n if(browserViewPrefs.mobileSortFilters) mobileSortFilters = normalizeMobileSortFilters({...mobileSortFilters, ...browserViewPrefs.mobileSortFilters});\n const DEFAULT_MOBILE_COLUMNS = new Set([\"status\",\"progress\",\"down_rate\",\"up_rate\",\"eta\",\"seeds\",\"peers\",\"ratio\",\"path\"]);\n const MOBILE_COLUMN_DEFS = COLUMN_DEFS.map(([key,label]) => [key, label, DEFAULT_MOBILE_COLUMNS.has(key)]);\n function normalizeMobileColumns(value={}){\n const normalized = {...Object.fromEntries(MOBILE_COLUMN_DEFS.map(([key,,shown])=>[key, shown]))};\n Object.entries(value || {}).forEach(([key, shown])=>{\n if(key === \"speed\"){ normalized.down_rate = !!shown; normalized.up_rate = !!shown; }\n else if(key === \"seed_peer\"){ normalized.seeds = !!shown; normalized.peers = !!shown; }\n else if(key in normalized) normalized[key] = !!shown;\n });\n return normalized;\n }\n let mobileColumns = normalizeMobileColumns(savedColumns.mobile || {});\n if(browserViewPrefs.mobileColumns) mobileColumns = normalizeMobileColumns({...mobileColumns, ...browserViewPrefs.mobileColumns});\n let mobileSmartFiltersEnabled = browserViewPrefs.mobileSmartFiltersEnabled ?? savedColumns.mobileSmartFiltersEnabled ?? true;\n let knownLabels = [];\n let jobsPage = 0, jobsLimit = 25, jobsTotal = 0, smartHistoryExpanded = false, plannerHistoryExpanded = false;\n let automationSmartQueueStats = null;\n let peersRefreshTimer = null;\n let peersRefreshSeconds = Number(window.PYTORRENT?.peersRefreshSeconds || 0);\n // Note: Files tab auto-refresh is independent from the peers refresh setting and stops when files are complete.\n const FILES_AUTO_REFRESH_SECONDS = 5;\n let filesRefreshTimer = null;\n let filesRefreshInFlight = false;\n let filesAutoRefreshHash = null;\n let portCheckEnabled = !!Number(window.PYTORRENT?.portCheckEnabled || 0);\n let bootstrapTheme = window.PYTORRENT?.bootstrapTheme || \"default\";\n let fontFamily = window.PYTORRENT?.fontFamily || \"default\";\n let interfaceScale = Number(window.PYTORRENT?.interfaceScale || 100);\n let titleSpeedEnabled = !!Number(window.PYTORRENT?.titleSpeedEnabled || 0);\n let trackerFaviconsEnabled = !!Number(window.PYTORRENT?.trackerFaviconsEnabled || 0);\n // Note: Reverse DNS is opt-in because PTR lookups can be slower than normal peer refreshes.\n let reverseDnsEnabled = !!Number(window.PYTORRENT?.reverseDnsEnabled || 0);\n let automationToastsEnabled = window.PYTORRENT?.automationToastsEnabled !== false && Number(window.PYTORRENT?.automationToastsEnabled ?? 1) !== 0;\n let smartQueueToastsEnabled = window.PYTORRENT?.smartQueueToastsEnabled !== false && Number(window.PYTORRENT?.smartQueueToastsEnabled ?? 1) !== 0;\n let diskMonitorPaths = Array.isArray(window.PYTORRENT?.diskMonitorPaths) ? [...window.PYTORRENT.diskMonitorPaths] : [];\n let diskMonitorMode = window.PYTORRENT?.diskMonitorMode || \"default\";\n let diskMonitorSelectedPath = window.PYTORRENT?.diskMonitorSelectedPath || \"\";\n let lastUserDiskFetchAt = 0;\n let userDiskFetchInFlight = false;\n let userDiskFetchSeq = 0;\n let activeProfileId = window.PYTORRENT?.activeProfile || null;\n let trackerSummary = {hashes:{}, trackers:[], scanned:0, errors:[]};\n let trackerSummaryStatus = 'idle';\n let trackerSummarySignature = \"\";\n let trackerSummaryTimer = null;\n let lastLabelFiltersSignature = \"\";\n let lastTrackerFiltersSignature = \"\";\n let lastMobileFiltersSignature = \"\";\n const BASE_TITLE = document.title || \"pyTorrent\";\n const lastBrowserSpeed = {down: \"0 B/s\", up: \"0 B/s\"};\n const FOOTER_STATUS_STORAGE_KEY = \"pytorrent.footerStatus.v1\";\n const FOOTER_RT_METRIC_KEYS = new Set([\"sockets\", \"rt_downloads\", \"rt_uploads\", \"rt_http\", \"rt_files\", \"rt_port\"]);\n const FOOTER_ITEM_DEFS = [\n [\"cpu\", \"CPU\"], [\"ram\", \"RAM\"], [\"usage_chart\", \"CPU/RAM chart\"], [\"disk\", \"Disk\"],\n [\"version\", \"rTorrent version\"], [\"speed_down\", \"Download speed\"], [\"speed_up\", \"Upload speed\"],\n [\"speed_peaks\", \"Peak speeds\"], [\"limits\", \"Speed limits\"], [\"totals\", \"Total transfer\"], [\"port_check\", \"Port check\"],\n [\"clock\", \"Clock\"], [\"sockets\", \"Open sockets\"], [\"rt_downloads\", \"Downloads (D)\"], [\"rt_uploads\", \"Uploads (U)\"], [\"rt_http\", \"HTTP (H)\"], [\"rt_files\", \"Files (F)\"], [\"rt_port\", \"Incoming port\"], [\"shown\", \"Shown torrents\"], [\"selected\", \"Selected torrents\"], [\"docs\", \"API docs\"]\n ];\n const DEFAULT_FOOTER_ITEMS = Object.fromEntries(FOOTER_ITEM_DEFS.map(([key]) => [key, !FOOTER_RT_METRIC_KEYS.has(key)]));\n let footerItems = {...DEFAULT_FOOTER_ITEMS, ...(window.PYTORRENT?.footerItems || {})};\n let modalLabels = new Set(), defaultDownloadPath = null;\n let hasTorrentSnapshot = false, initialLoaderDone = false, rtConfigOriginal = new Map(), rtConfigFieldTypes = new Map(), rtConfigOriginalApplyOnStart = false;\n let rtorrentStartingMessage = '';\n let rtorrentStartingTimer = null, rtorrentStartingSince = 0;\n const RTORRENT_STALE_GRACE_MS = 30000;\n let torrentSummary = null;\n let profileCache = new Map();\n const hasActiveProfile = !!window.PYTORRENT?.activeProfile;\n let firstRunSetupShown = false;\n const activeOperations = new Map();\n // Note: Keeps live filter tooltips stable while the pointer is over a filter button.\n const filterTooltipState = new WeakMap();\n\n const toastGroups = new Map();\n const preferenceSaveTimers = new Map();\n function clampNumber(value, min, max, fallback){\n const num = Number(value);\n if(!Number.isFinite(num)) return fallback;\n return Math.max(min, Math.min(max, Math.round(num)));\n }\n function debounce(fn, delay=250){\n let timer = null;\n return (...args) => {\n clearTimeout(timer);\n timer = setTimeout(() => fn(...args), delay);\n };\n }\n function savePreferencePatch(payload, delay=350){\n const key = Object.keys(payload).sort().join('|');\n clearTimeout(preferenceSaveTimers.get(key));\n preferenceSaveTimers.set(key, setTimeout(async()=>{\n try{ await post('/api/preferences', payload); }catch(e){ console.warn('Preference save failed', e); }\n finally{ preferenceSaveTimers.delete(key); }\n }, delay));\n }\n function currentActiveFilterPreference(){\n return activeTrackerFilter ? `tracker:${activeTrackerFilter}` : activeFilter;\n }\n function saveTorrentSortPreference(){\n // Note: Sorting is persisted together with the current filter so mobile tracker scope cannot fall back to All trackers after a quick sort change.\n saveBrowserViewPrefs();\n savePreferencePatch({torrent_sort_json:{key:sortState.key, dir:sortState.dir}, active_filter:currentActiveFilterPreference()}, 200);\n }\n function saveBrowserViewPrefs(extra={}){\n try{\n const prev=JSON.parse(localStorage.getItem('pyTorrent.mobileViewPrefs')||'{}')||{};\n localStorage.setItem('pyTorrent.mobileViewPrefs', JSON.stringify({...prev, activeFilter:currentActiveFilterPreference(), mobileFilterKey:mobileActiveFilterKey, sortState, mobileColumns, columnWidths, ...extra}));\n }catch(e){}\n }\n function saveActiveFilterPreference(){\n saveBrowserViewPrefs();\n savePreferencePatch({active_filter:currentActiveFilterPreference()}, 250);\n }\n function cleanColumnPrefsHidden(values){ return [...values].filter(key => key !== \"progressbar\"); }\n async function resetViewPreferences(){\n activeFilter = \"all\";\n activeTrackerFilter = \"\";\n mobileActiveFilterKey = \"all\";\n sortState = {key:\"name\", dir:1};\n mobileColumns = normalizeMobileColumns();\n hiddenColumns = new Set(DEFAULT_HIDDEN_COLUMNS);\n columnWidths = normalizeColumnWidths();\n const height = applyDetailPanelHeight(255);\n renderColumnManager();\n document.querySelectorAll('.filter').forEach(x=>x.classList.toggle('active', x.dataset.filter === 'all'));\n if($('tableWrap')) $('tableWrap').scrollTop = 0;\n if($('mobileList')) $('mobileList').scrollTop = 0;\n try{\n await post('/api/preferences', {active_filter:\"all\", torrent_sort_json:{key:\"name\", dir:1}, detail_panel_height:height, table_columns_json:JSON.stringify({hidden:cleanColumnPrefsHidden(DEFAULT_HIDDEN_COLUMNS), shown:[], mobile:mobileColumns, mobileSmartFiltersEnabled:true, widths:columnWidths})});\n toast('View preferences reset','success');\n }catch(e){ toast(e.message,'danger'); }\n scheduleRender(true);\n }\n function applyDetailPanelHeight(height){\n const safeHeight = clampNumber(height, 160, 720, 255);\n document.documentElement.style.setProperty('--detail-panel-height', `${safeHeight}px`);\n const handle = $('detailResizeHandle');\n if(handle) handle.setAttribute('aria-valuenow', String(safeHeight));\n return safeHeight;\n }\n function saveDetailPanelHeight(height){\n const safeHeight = applyDetailPanelHeight(height);\n savePreferencePatch({detail_panel_height:safeHeight}, 250);\n }\n function setupDetailResizer(){\n const handle = $('detailResizeHandle');\n const content = document.querySelector('.content');\n if(!handle || !content) return;\n applyDetailPanelHeight(window.PYTORRENT?.detailPanelHeight || 255);\n let startY = 0, startHeight = 0;\n const onMove = (event) => {\n const pointerY = event.clientY ?? event.touches?.[0]?.clientY ?? startY;\n applyDetailPanelHeight(startHeight - (pointerY - startY));\n scheduleRender(false);\n };\n const onUp = () => {\n document.body.classList.remove('resizing-details');\n document.removeEventListener('pointermove', onMove);\n document.removeEventListener('pointerup', onUp);\n const value = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--detail-panel-height'), 10);\n saveDetailPanelHeight(value);\n };\n handle.addEventListener('pointerdown', (event) => {\n event.preventDefault();\n startY = event.clientY;\n startHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--detail-panel-height'), 10) || 255;\n document.body.classList.add('resizing-details');\n document.addEventListener('pointermove', onMove);\n document.addEventListener('pointerup', onUp);\n });\n }\n function toastKey(msg, type){ return `${type}::${String(msg ?? '')}`; }\n function isAutomationEvent(msg){ return msg?.automation === true || msg?.source === 'automation'; }\n function shouldShowOperationToast(msg){\n // Note: Automation-created operation toasts follow the Automation toasts preference.\n return !isAutomationEvent(msg) || automationToastsEnabled;\n }\n function toast(msg, type=\"secondary\") {\n // Note: Groups identical toasts fired together, so repeated automation/action events do not flood the UI.\n const h=$('toastHost');\n if(!h) return;\n const text=String(msg ?? '');\n const key=toastKey(text,type);\n const existing=toastGroups.get(key);\n if(existing){\n existing.count += 1;\n const badge=existing.el.querySelector('.toast-count');\n if(badge){ badge.textContent=`\u00d7${existing.count}`; badge.classList.remove('d-none'); }\n clearTimeout(existing.timer);\n existing.timer=setTimeout(()=>{ existing.el.remove(); toastGroups.delete(key); },3500);\n return;\n }\n const el=document.createElement('div');\n el.className=`toast-item text-bg-${type}`;\n el.innerHTML=`${esc(text)}\u00d71`;\n h.appendChild(el);\n const entry={el,count:1,timer:null};\n entry.timer=setTimeout(()=>{ el.remove(); toastGroups.delete(key); },3500);\n toastGroups.set(key,entry);\n }\n function setBusy(on, label='Working...'){ pendingBusy += on ? 1 : -1; if(pendingBusy<0) pendingBusy=0; const loader=$('globalLoader'); if(loader){ loader.classList.toggle('d-none', pendingBusy===0); const span=loader.querySelector('span:last-child'); if(span) span.textContent=label; } $('busyBadge')?.classList.toggle('d-none', pendingBusy===0); }\n function setInitialLoader(title, text){ if(initialLoaderDone) return; if($('initialLoaderTitle') && title) $('initialLoaderTitle').textContent=title; if($('initialLoaderText') && text) $('initialLoaderText').textContent=text; }\n function hideInitialLoader(){ if(initialLoaderDone) return; initialLoaderDone=true; $('initialLoader')?.classList.add('is-hidden'); }\n function buttonBusy(btn,on){ if(!btn)return; btn.disabled=on; const label=btn.querySelector('.btn-label'); if(label){ if(!label.dataset.orig) label.dataset.orig=label.innerHTML; label.innerHTML=on?`Working...`:label.dataset.orig; }}\n function activeTab(){ return document.querySelector('#detailTabs .nav-link.active')?.dataset.tab || 'general'; }\n function loadingMarkup(label='Loading data...'){ return `
${esc(label)}
`; }\n // Note: Keeps empty-state colspans aligned with the desktop torrent table column count.\n function torrentColumnSpan(){ return 25; }\n function loadingTableRow(label='Loading torrents...'){ return `${loadingMarkup(label)}`; }\n // Note: Handles fresh installations with no configured rTorrent profile, so the UI does not wait forever for a snapshot.\n function renderNoProfileState(){\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body = $('torrentBody');\n if(body){\n body.innerHTML = `
No rTorrent profile configured.Add the first rTorrent profile to start loading torrents.
`;\n }\n if($('detailPane')) $('detailPane').innerHTML = 'Add rTorrent profile first.';\n }\n function clearRtorrentStartingState(){\n rtorrentStartingMessage='';\n rtorrentStartingSince=0;\n if(rtorrentStartingTimer){ clearTimeout(rtorrentStartingTimer); rtorrentStartingTimer=null; }\n }\n function rtorrentStartingHtml(error=''){\n const details=error ? `${esc(error)}` : 'Port can already be open while XML-RPC/SCGI is still warming up. The list will load automatically after rTorrent answers.';\n return `
rTorrent is starting or not responding yet.Waiting for torrent data from the active profile.${details}
`;\n }\n function scheduleRtorrentStartingState(error=''){\n rtorrentStartingMessage = String(error || 'rTorrent is starting or not responding yet.');\n if(!(hasTorrentSnapshot && torrents.size)){\n renderRtorrentStartingState(rtorrentStartingMessage, true);\n return;\n }\n if(!rtorrentStartingSince) rtorrentStartingSince = Date.now();\n if(rtorrentStartingTimer) return;\n rtorrentStartingTimer = setTimeout(() => {\n rtorrentStartingTimer = null;\n if(rtorrentStartingMessage) renderRtorrentStartingState(rtorrentStartingMessage, true);\n }, RTORRENT_STALE_GRACE_MS);\n }\n function renderRtorrentStartingState(error='', force=false){\n rtorrentStartingMessage = String(error || 'rTorrent is starting or not responding yet.');\n if(hasTorrentSnapshot && torrents.size && !force) return;\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body=$('torrentBody');\n if(body) body.innerHTML = `${rtorrentStartingHtml(rtorrentStartingMessage)}`;\n const list=$('mobileList');\n if(list) list.innerHTML = `
${rtorrentStartingHtml(rtorrentStartingMessage)}
`;\n if($('detailPane')) $('detailPane').innerHTML = 'rTorrent is starting. Details will appear after the first successful response.';\n }\n function parseDate(value){ const raw=String(value||'').trim(); if(!raw) return null; const d=new Date(raw); return Number.isNaN(d.getTime()) ? null : {raw,d}; }\n function formatDate(value, mode='short'){\n const parsed=parseDate(value);\n if(!parsed) return String(value||'');\n const opts=mode==='full'\n ? {year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit'}\n : {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'};\n return new Intl.DateTimeFormat('pl-PL', opts).format(parsed.d).replace(',', '');\n }\n function dateCell(value){ const parsed=parseDate(value); if(!parsed) return esc(value||''); return `${esc(formatDate(value))}`; }\n // Note: Human-readable date cells keep full timestamps visible without squeezing table columns.\n function humanDateCell(value){ const parsed=parseDate(value); if(!parsed) return esc(value||''); const full=formatDate(value,'full'); return `${esc(full)}`; }\n 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))}\u2026${text.slice(-Math.floor(max*0.28))}` : text; return `${esc(short)}`; }\n 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)}%
`; }\n function progress(t){ return progressBar(t.progress); }\n"; +export const stateSource = " const $ = (id) => document.getElementById(id);\n const esc = (s) => String(s ?? \"\").replace(/[&<>'\"]/g, c => ({\"&\":\"&\",\"<\":\"<\",\">\":\">\",\"'\":\"'\",'\"':\""\"}[c]));\n // Note: Footer transfer totals can arrive as already formatted strings, so keep this helper tolerant and side-effect free.\n function compactTransferText(value){\n const text = String(value ?? \"\").trim();\n if(!text) return \"-\";\n return text.replace(/\\\\s+/g, \" \");\n }\n const ROW_HEIGHT = 32, COMPACT_ROW_HEIGHT = 24, OVERSCAN = 14;\n const torrents = new Map();\n const browserViewPrefs = (()=>{ try{return JSON.parse(localStorage.getItem('pyTorrent.mobileViewPrefs')||'{}')||{};}catch(e){return {};} })();\n const savedFilter = String(browserViewPrefs.activeFilter || window.PYTORRENT?.activeFilter || \"all\");\n // Note: Mobile has both \"All\" and \"All trackers\" options, so keep the exact selected option separate from the shared filter state.\n let mobileActiveFilterKey = String(browserViewPrefs.mobileFilterKey || savedFilter || \"all\");\n let visibleRows = [], selected = new Set(), selectedHash = null, lastSelectedHash = null, activeFilter = savedFilter.startsWith(\"tracker:\") ? \"all\" : (savedFilter || \"all\");\n let activeTrackerFilter = savedFilter.startsWith(\"tracker:\") ? savedFilter.slice(8) : \"\";\n const SORT_KEYS = new Set([\"name\", \"status\", \"size\", \"progress\", \"down_rate\", \"up_rate\", \"eta\", \"seeds\", \"peers\", \"ratio\", \"path\", \"label\", \"ratio_group\", \"down_total\", \"to_download\", \"up_total\", \"created\", \"priority\", \"state\", \"active\", \"complete\", \"hashing\", \"message\", \"hash\"]);\n const savedSort = browserViewPrefs.sortState || window.PYTORRENT?.torrentSort || {};\n let sortState = {key: SORT_KEYS.has(savedSort.key) ? savedSort.key : \"name\", dir: Number(savedSort.dir) < 0 ? -1 : 1}, renderPending = false, renderVersion = 0, lastRenderSignature = \"\";\n let compactTorrentListEnabled = Number(window.PYTORRENT?.compactTorrentListEnabled || 0) !== 0;\n // Note: Mobile sort filters are configurable because the full sortable list is too large for quick phone use.\n const DEFAULT_MOBILE_SORT_FILTER_IDS = new Set([\"seeds:-1\", \"up_rate:-1\", \"down_rate:-1\", \"progress:-1\"]);\n const MOBILE_SORT_STEPS = [\n {key:\"down_rate\", dir:-1, label:\"DL\"},\n {key:\"down_rate\", dir:1, label:\"DL\"},\n {key:\"up_rate\", dir:-1, label:\"UL\"},\n {key:\"up_rate\", dir:1, label:\"UL\"},\n {key:\"progress\", dir:-1, label:\"Progress\"},\n {key:\"progress\", dir:1, label:\"Progress\"},\n {key:\"eta\", dir:-1, label:\"ETA\"},\n {key:\"eta\", dir:1, label:\"ETA\"},\n {key:\"ratio\", dir:-1, label:\"Ratio\"},\n {key:\"ratio\", dir:1, label:\"Ratio\"},\n {key:\"size\", dir:-1, label:\"Size\"},\n {key:\"size\", dir:1, label:\"Size\"},\n {key:\"seeds\", dir:-1, label:\"Seeds\"},\n {key:\"seeds\", dir:1, label:\"Seeds\"},\n {key:\"peers\", dir:-1, label:\"Peers\"},\n {key:\"peers\", dir:1, label:\"Peers\"},\n {key:\"status\", dir:1, label:\"Status\"},\n {key:\"status\", dir:-1, label:\"Status\"},\n {key:\"label\", dir:1, label:\"Label\"},\n {key:\"label\", dir:-1, label:\"Label\"},\n {key:\"ratio_group\", dir:1, label:\"Ratio group\"},\n {key:\"ratio_group\", dir:-1, label:\"Ratio group\"},\n {key:\"down_total\", dir:-1, label:\"Downloaded\"},\n {key:\"down_total\", dir:1, label:\"Downloaded\"},\n {key:\"to_download\", dir:-1, label:\"To download\"},\n {key:\"to_download\", dir:1, label:\"To download\"},\n {key:\"up_total\", dir:-1, label:\"Uploaded\"},\n {key:\"up_total\", dir:1, label:\"Uploaded\"},\n {key:\"created\", dir:-1, label:\"Added\"},\n {key:\"created\", dir:1, label:\"Added\"},\n {key:\"priority\", dir:-1, label:\"Priority\"},\n {key:\"priority\", dir:1, label:\"Priority\"},\n {key:\"state\", dir:-1, label:\"State\"},\n {key:\"state\", dir:1, label:\"State\"},\n {key:\"active\", dir:-1, label:\"Active\"},\n {key:\"active\", dir:1, label:\"Active\"},\n {key:\"complete\", dir:-1, label:\"Complete\"},\n {key:\"complete\", dir:1, label:\"Complete\"},\n {key:\"hashing\", dir:-1, label:\"Hashing\"},\n {key:\"hashing\", dir:1, label:\"Hashing\"},\n {key:\"message\", dir:1, label:\"Message\"},\n {key:\"message\", dir:-1, label:\"Message\"},\n {key:\"path\", dir:1, label:\"Path\"},\n {key:\"path\", dir:-1, label:\"Path\"},\n {key:\"hash\", dir:1, label:\"Hash\"},\n {key:\"hash\", dir:-1, label:\"Hash\"},\n {key:\"name\", dir:1, label:\"Name\"},\n {key:\"name\", dir:-1, label:\"Name\"}\n ];\n let lastLimits = {down: 0, up: 0}, pendingBusy = 0, pathTarget = null, lastPathParent = \"/\";\n const traffic = [], systemUsage = [];\n const socket = (typeof io === \"function\") ? io({transports:[\"polling\"], reconnection:true, reconnectionAttempts:Infinity, reconnectionDelay:700, reconnectionDelayMax:5000, timeout:8000}) : {connected:false,on(){},emit(){},io:{on(){}}};\n const COLUMN_DEFS = [[\"status\",\"Status\",false],[\"size\",\"Size\",false],[\"progress\",\"Progressbar\",false],[\"down_rate\",\"DL\",false],[\"up_rate\",\"UL\",false],[\"eta\",\"ETA\",false],[\"seeds\",\"Seeds\",false],[\"peers\",\"Peers\",false],[\"ratio\",\"Ratio\",false],[\"path\",\"Path\",false],[\"label\",\"Label\",false],[\"ratio_group\",\"Ratio group\",false],[\"down_total\",\"Downloaded\",true],[\"to_download\",\"To download\",true],[\"up_total\",\"Uploaded\",true],[\"created\",\"Added\",true],[\"priority\",\"Priority\",true],[\"state\",\"State\",true],[\"active\",\"Active\",true],[\"complete\",\"Complete\",true],[\"hashing\",\"Hashing\",true],[\"message\",\"Message\",true],[\"hash\",\"Hash\",true]];\n const DEFAULT_HIDDEN_COLUMNS = new Set(COLUMN_DEFS.filter(([, , hiddenByDefault]) => hiddenByDefault).map(([key]) => key));\n const savedColumns = window.PYTORRENT?.tableColumns || {};\n const DEFAULT_COLUMN_WIDTHS = {\n select: 34, name: 360, status: 110, size: 90, progress: 120,\n down_rate: 86, up_rate: 86, eta: 92, seeds: 70, peers: 70,\n ratio: 72, path: 300, label: 140, ratio_group: 130,\n down_total: 120, to_download: 120, up_total: 120, created: 150,\n priority: 80, state: 70, active: 70, complete: 82, hashing: 82,\n message: 220, hash: 280\n };\n const COLUMN_WIDTH_MIN = 44;\n const COLUMN_WIDTH_MAX = 720;\n const explicitlyShownColumns = new Set(savedColumns.shown || []);\n let hiddenColumns = new Set([...(savedColumns.hidden || []), ...[...DEFAULT_HIDDEN_COLUMNS].filter(key => !explicitlyShownColumns.has(key))]);\n // Note: Column widths are persisted with the existing column preferences payload, so no database migration is needed.\n function normalizeColumnWidths(value={}){\n const allowed = new Set(['select', ...COLUMN_DEFS.map(([key]) => key)]);\n const normalized = {...DEFAULT_COLUMN_WIDTHS};\n Object.entries(value || {}).forEach(([key, width])=>{\n if(allowed.has(key)) normalized[key] = clampNumber(width, COLUMN_WIDTH_MIN, COLUMN_WIDTH_MAX, DEFAULT_COLUMN_WIDTHS[key] || 120);\n });\n return normalized;\n }\n let columnWidths = normalizeColumnWidths(savedColumns.widths || {});\n if(browserViewPrefs.columnWidths) columnWidths = normalizeColumnWidths({...columnWidths, ...browserViewPrefs.columnWidths});\n function mobileSortStepId(step){ return `${step.key}:${step.dir}`; }\n function normalizeMobileSortFilters(value={}){\n const normalized = Object.fromEntries(MOBILE_SORT_STEPS.map(step => {\n const id = mobileSortStepId(step);\n return [id, DEFAULT_MOBILE_SORT_FILTER_IDS.has(id)];\n }));\n Object.entries(value || {}).forEach(([id, enabled]) => { if(id in normalized) normalized[id] = !!enabled; });\n return normalized;\n }\n let mobileSortFilters = normalizeMobileSortFilters(savedColumns.mobileSortFilters || {});\n if(browserViewPrefs.mobileSortFilters) mobileSortFilters = normalizeMobileSortFilters({...mobileSortFilters, ...browserViewPrefs.mobileSortFilters});\n const DEFAULT_MOBILE_COLUMNS = new Set([\"status\",\"progress\",\"down_rate\",\"up_rate\",\"eta\",\"seeds\",\"peers\",\"ratio\",\"path\"]);\n const MOBILE_COLUMN_DEFS = COLUMN_DEFS.map(([key,label]) => [key, label, DEFAULT_MOBILE_COLUMNS.has(key)]);\n function normalizeMobileColumns(value={}){\n const normalized = {...Object.fromEntries(MOBILE_COLUMN_DEFS.map(([key,,shown])=>[key, shown]))};\n Object.entries(value || {}).forEach(([key, shown])=>{\n if(key === \"speed\"){ normalized.down_rate = !!shown; normalized.up_rate = !!shown; }\n else if(key === \"seed_peer\"){ normalized.seeds = !!shown; normalized.peers = !!shown; }\n else if(key in normalized) normalized[key] = !!shown;\n });\n return normalized;\n }\n let mobileColumns = normalizeMobileColumns(savedColumns.mobile || {});\n if(browserViewPrefs.mobileColumns) mobileColumns = normalizeMobileColumns({...mobileColumns, ...browserViewPrefs.mobileColumns});\n let mobileSmartFiltersEnabled = browserViewPrefs.mobileSmartFiltersEnabled ?? savedColumns.mobileSmartFiltersEnabled ?? true;\n let knownLabels = [];\n let jobsPage = 0, jobsLimit = 25, jobsTotal = 0, smartHistoryExpanded = false, plannerHistoryExpanded = false;\n let automationSmartQueueStats = null;\n let peersRefreshTimer = null;\n let peersRefreshSeconds = Number(window.PYTORRENT?.peersRefreshSeconds || 0);\n // Note: Files tab auto-refresh is independent from the peers refresh setting and stops when files are complete.\n const FILES_AUTO_REFRESH_SECONDS = 5;\n let filesRefreshTimer = null;\n let filesRefreshInFlight = false;\n let filesAutoRefreshHash = null;\n let portCheckEnabled = !!Number(window.PYTORRENT?.portCheckEnabled || 0);\n let bootstrapTheme = window.PYTORRENT?.bootstrapTheme || \"default\";\n let fontFamily = window.PYTORRENT?.fontFamily || \"default\";\n let interfaceScale = Number(window.PYTORRENT?.interfaceScale || 100);\n let titleSpeedEnabled = !!Number(window.PYTORRENT?.titleSpeedEnabled || 0);\n let trackerFaviconsEnabled = !!Number(window.PYTORRENT?.trackerFaviconsEnabled || 0);\n // Note: Reverse DNS is opt-in because PTR lookups can be slower than normal peer refreshes.\n let reverseDnsEnabled = !!Number(window.PYTORRENT?.reverseDnsEnabled || 0);\n let automationToastsEnabled = window.PYTORRENT?.automationToastsEnabled !== false && Number(window.PYTORRENT?.automationToastsEnabled ?? 1) !== 0;\n let smartQueueToastsEnabled = window.PYTORRENT?.smartQueueToastsEnabled !== false && Number(window.PYTORRENT?.smartQueueToastsEnabled ?? 1) !== 0;\n let diskMonitorPaths = Array.isArray(window.PYTORRENT?.diskMonitorPaths) ? [...window.PYTORRENT.diskMonitorPaths] : [];\n let diskMonitorMode = window.PYTORRENT?.diskMonitorMode || \"default\";\n let diskMonitorSelectedPath = window.PYTORRENT?.diskMonitorSelectedPath || \"\";\n let lastUserDiskFetchAt = 0;\n let userDiskFetchInFlight = false;\n let userDiskFetchSeq = 0;\n let activeProfileId = window.PYTORRENT?.activeProfile || null;\n let trackerSummary = {hashes:{}, trackers:[], scanned:0, errors:[]};\n let trackerSummaryStatus = 'idle';\n let trackerSummarySignature = \"\";\n let trackerSummaryTimer = null;\n let lastLabelFiltersSignature = \"\";\n let lastTrackerFiltersSignature = \"\";\n let lastMobileFiltersSignature = \"\";\n const BASE_TITLE = document.title || \"pyTorrent\";\n const lastBrowserSpeed = {down: \"0 B/s\", up: \"0 B/s\"};\n const FOOTER_STATUS_STORAGE_KEY = \"pytorrent.footerStatus.v1\";\n const FOOTER_RT_METRIC_KEYS = new Set([\"sockets\", \"rt_downloads\", \"rt_uploads\", \"rt_http\", \"rt_files\", \"rt_port\"]);\n const FOOTER_ITEM_DEFS = [\n [\"cpu\", \"CPU\"], [\"ram\", \"RAM\"], [\"usage_chart\", \"CPU/RAM chart\"], [\"disk\", \"Disk\"],\n [\"version\", \"rTorrent version\"], [\"speed_down\", \"Download speed\"], [\"speed_up\", \"Upload speed\"],\n [\"speed_peaks\", \"Peak speeds\"], [\"limits\", \"Speed limits\"], [\"totals\", \"Total transfer\"], [\"port_check\", \"Port check\"],\n [\"clock\", \"Clock\"], [\"sockets\", \"Open sockets\"], [\"rt_downloads\", \"Downloads (D)\"], [\"rt_uploads\", \"Uploads (U)\"], [\"rt_http\", \"HTTP (H)\"], [\"rt_files\", \"Files (F)\"], [\"rt_port\", \"Incoming port\"], [\"shown\", \"Shown torrents\"], [\"selected\", \"Selected torrents\"], [\"docs\", \"API docs\"]\n ];\n const DEFAULT_FOOTER_ITEMS = Object.fromEntries(FOOTER_ITEM_DEFS.map(([key]) => [key, !FOOTER_RT_METRIC_KEYS.has(key)]));\n let footerItems = {...DEFAULT_FOOTER_ITEMS, ...(window.PYTORRENT?.footerItems || {})};\n let modalLabels = new Set(), defaultDownloadPath = null;\n let hasTorrentSnapshot = false, initialLoaderDone = false, rtConfigOriginal = new Map(), rtConfigFieldTypes = new Map(), rtConfigOriginalApplyOnStart = false;\n let rtorrentStartingMessage = '';\n let rtorrentStartingTimer = null, rtorrentStartingSince = 0;\n const RTORRENT_STALE_GRACE_MS = 30000;\n let torrentSummary = null;\n let profileCache = new Map();\n let hasActiveProfile = !!window.PYTORRENT?.activeProfile;\n let firstRunSetupShown = false;\n const activeOperations = new Map();\n // Note: Keeps live filter tooltips stable while the pointer is over a filter button.\n const filterTooltipState = new WeakMap();\n\n const toastGroups = new Map();\n const preferenceSaveTimers = new Map();\n function clampNumber(value, min, max, fallback){\n const num = Number(value);\n if(!Number.isFinite(num)) return fallback;\n return Math.max(min, Math.min(max, Math.round(num)));\n }\n function debounce(fn, delay=250){\n let timer = null;\n return (...args) => {\n clearTimeout(timer);\n timer = setTimeout(() => fn(...args), delay);\n };\n }\n function savePreferencePatch(payload, delay=350){\n const key = Object.keys(payload).sort().join('|');\n clearTimeout(preferenceSaveTimers.get(key));\n preferenceSaveTimers.set(key, setTimeout(async()=>{\n try{ await post('/api/preferences', payload); }catch(e){ console.warn('Preference save failed', e); }\n finally{ preferenceSaveTimers.delete(key); }\n }, delay));\n }\n function currentActiveFilterPreference(){\n return activeTrackerFilter ? `tracker:${activeTrackerFilter}` : activeFilter;\n }\n function saveTorrentSortPreference(){\n // Note: Sorting is persisted together with the current filter so mobile tracker scope cannot fall back to All trackers after a quick sort change.\n saveBrowserViewPrefs();\n savePreferencePatch({torrent_sort_json:{key:sortState.key, dir:sortState.dir}, active_filter:currentActiveFilterPreference()}, 200);\n }\n function saveBrowserViewPrefs(extra={}){\n try{\n const prev=JSON.parse(localStorage.getItem('pyTorrent.mobileViewPrefs')||'{}')||{};\n localStorage.setItem('pyTorrent.mobileViewPrefs', JSON.stringify({...prev, activeFilter:currentActiveFilterPreference(), mobileFilterKey:mobileActiveFilterKey, sortState, mobileColumns, columnWidths, ...extra}));\n }catch(e){}\n }\n function saveActiveFilterPreference(){\n saveBrowserViewPrefs();\n savePreferencePatch({active_filter:currentActiveFilterPreference()}, 250);\n }\n function cleanColumnPrefsHidden(values){ return [...values].filter(key => key !== \"progressbar\"); }\n async function resetViewPreferences(){\n activeFilter = \"all\";\n activeTrackerFilter = \"\";\n mobileActiveFilterKey = \"all\";\n sortState = {key:\"name\", dir:1};\n mobileColumns = normalizeMobileColumns();\n hiddenColumns = new Set(DEFAULT_HIDDEN_COLUMNS);\n columnWidths = normalizeColumnWidths();\n const height = applyDetailPanelHeight(255);\n renderColumnManager();\n document.querySelectorAll('.filter').forEach(x=>x.classList.toggle('active', x.dataset.filter === 'all'));\n if($('tableWrap')) $('tableWrap').scrollTop = 0;\n if($('mobileList')) $('mobileList').scrollTop = 0;\n try{\n await post('/api/preferences', {active_filter:\"all\", torrent_sort_json:{key:\"name\", dir:1}, detail_panel_height:height, table_columns_json:JSON.stringify({hidden:cleanColumnPrefsHidden(DEFAULT_HIDDEN_COLUMNS), shown:[], mobile:mobileColumns, mobileSmartFiltersEnabled:true, widths:columnWidths})});\n toast('View preferences reset','success');\n }catch(e){ toast(e.message,'danger'); }\n scheduleRender(true);\n }\n function applyDetailPanelHeight(height){\n const safeHeight = clampNumber(height, 160, 720, 255);\n document.documentElement.style.setProperty('--detail-panel-height', `${safeHeight}px`);\n const handle = $('detailResizeHandle');\n if(handle) handle.setAttribute('aria-valuenow', String(safeHeight));\n return safeHeight;\n }\n function saveDetailPanelHeight(height){\n const safeHeight = applyDetailPanelHeight(height);\n savePreferencePatch({detail_panel_height:safeHeight}, 250);\n }\n function setupDetailResizer(){\n const handle = $('detailResizeHandle');\n const content = document.querySelector('.content');\n if(!handle || !content) return;\n applyDetailPanelHeight(window.PYTORRENT?.detailPanelHeight || 255);\n let startY = 0, startHeight = 0;\n const onMove = (event) => {\n const pointerY = event.clientY ?? event.touches?.[0]?.clientY ?? startY;\n applyDetailPanelHeight(startHeight - (pointerY - startY));\n scheduleRender(false);\n };\n const onUp = () => {\n document.body.classList.remove('resizing-details');\n document.removeEventListener('pointermove', onMove);\n document.removeEventListener('pointerup', onUp);\n const value = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--detail-panel-height'), 10);\n saveDetailPanelHeight(value);\n };\n handle.addEventListener('pointerdown', (event) => {\n event.preventDefault();\n startY = event.clientY;\n startHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--detail-panel-height'), 10) || 255;\n document.body.classList.add('resizing-details');\n document.addEventListener('pointermove', onMove);\n document.addEventListener('pointerup', onUp);\n });\n }\n function toastKey(msg, type){ return `${type}::${String(msg ?? '')}`; }\n function isAutomationEvent(msg){ return msg?.automation === true || msg?.source === 'automation'; }\n function shouldShowOperationToast(msg){\n // Note: Automation-created operation toasts follow the Automation toasts preference.\n return !isAutomationEvent(msg) || automationToastsEnabled;\n }\n function toast(msg, type=\"secondary\") {\n // Note: Groups identical toasts fired together, so repeated automation/action events do not flood the UI.\n const h=$('toastHost');\n if(!h) return;\n const text=String(msg ?? '');\n const key=toastKey(text,type);\n const existing=toastGroups.get(key);\n if(existing){\n existing.count += 1;\n const badge=existing.el.querySelector('.toast-count');\n if(badge){ badge.textContent=`×${existing.count}`; badge.classList.remove('d-none'); }\n clearTimeout(existing.timer);\n existing.timer=setTimeout(()=>{ existing.el.remove(); toastGroups.delete(key); },3500);\n return;\n }\n const el=document.createElement('div');\n el.className=`toast-item text-bg-${type}`;\n el.innerHTML=`${esc(text)}×1`;\n h.appendChild(el);\n const entry={el,count:1,timer:null};\n entry.timer=setTimeout(()=>{ el.remove(); toastGroups.delete(key); },3500);\n toastGroups.set(key,entry);\n }\n function setBusy(on, label='Working...'){ pendingBusy += on ? 1 : -1; if(pendingBusy<0) pendingBusy=0; const loader=$('globalLoader'); if(loader){ loader.classList.toggle('d-none', pendingBusy===0); const span=loader.querySelector('span:last-child'); if(span) span.textContent=label; } $('busyBadge')?.classList.toggle('d-none', pendingBusy===0); }\n function setInitialLoader(title, text){ if(initialLoaderDone) return; if($('initialLoaderTitle') && title) $('initialLoaderTitle').textContent=title; if($('initialLoaderText') && text) $('initialLoaderText').textContent=text; }\n function hideInitialLoader(){ if(initialLoaderDone) return; initialLoaderDone=true; $('initialLoader')?.classList.add('is-hidden'); }\n function buttonBusy(btn,on){ if(!btn)return; btn.disabled=on; const label=btn.querySelector('.btn-label'); if(label){ if(!label.dataset.orig) label.dataset.orig=label.innerHTML; label.innerHTML=on?`Working...`:label.dataset.orig; }}\n function activeTab(){ return document.querySelector('#detailTabs .nav-link.active')?.dataset.tab || 'general'; }\n function loadingMarkup(label='Loading data...'){ return `
${esc(label)}
`; }\n // Note: Keeps empty-state colspans aligned with the desktop torrent table column count.\n function torrentColumnSpan(){ return 25; }\n function loadingTableRow(label='Loading torrents...'){ return `${loadingMarkup(label)}`; }\n // Note: Handles fresh installations with no configured rTorrent profile, so the UI does not wait forever for a snapshot.\n function renderNoProfileState(){\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body = $('torrentBody');\n if(body){\n body.innerHTML = `
No rTorrent profile configured.Add the first rTorrent profile to start loading torrents.
`;\n }\n if($('detailPane')) $('detailPane').innerHTML = 'Add rTorrent profile first.';\n }\n function clearRtorrentStartingState(){\n rtorrentStartingMessage='';\n rtorrentStartingSince=0;\n if(rtorrentStartingTimer){ clearTimeout(rtorrentStartingTimer); rtorrentStartingTimer=null; }\n }\n function rtorrentStartingHtml(error=''){\n const details=error ? `${esc(error)}` : 'Port can already be open while XML-RPC/SCGI is still warming up. The list will load automatically after rTorrent answers.';\n return `
rTorrent is starting or not responding yet.Waiting for torrent data from the active profile.${details}
`;\n }\n function scheduleRtorrentStartingState(error=''){\n rtorrentStartingMessage = String(error || 'rTorrent is starting or not responding yet.');\n if(!(hasTorrentSnapshot && torrents.size)){\n renderRtorrentStartingState(rtorrentStartingMessage, true);\n return;\n }\n if(!rtorrentStartingSince) rtorrentStartingSince = Date.now();\n if(rtorrentStartingTimer) return;\n rtorrentStartingTimer = setTimeout(() => {\n rtorrentStartingTimer = null;\n if(rtorrentStartingMessage) renderRtorrentStartingState(rtorrentStartingMessage, true);\n }, RTORRENT_STALE_GRACE_MS);\n }\n function renderRtorrentStartingState(error='', force=false){\n rtorrentStartingMessage = String(error || 'rTorrent is starting or not responding yet.');\n if(hasTorrentSnapshot && torrents.size && !force) return;\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body=$('torrentBody');\n if(body) body.innerHTML = `${rtorrentStartingHtml(rtorrentStartingMessage)}`;\n const list=$('mobileList');\n if(list) list.innerHTML = `
${rtorrentStartingHtml(rtorrentStartingMessage)}
`;\n if($('detailPane')) $('detailPane').innerHTML = 'rTorrent is starting. Details will appear after the first successful response.';\n }\n function parseDate(value){ const raw=String(value||'').trim(); if(!raw) return null; const d=new Date(raw); return Number.isNaN(d.getTime()) ? null : {raw,d}; }\n function formatDate(value, mode='short'){\n const parsed=parseDate(value);\n if(!parsed) return String(value||'');\n const opts=mode==='full'\n ? {year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit'}\n : {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'};\n return new Intl.DateTimeFormat('pl-PL', opts).format(parsed.d).replace(',', '');\n }\n function dateCell(value){ const parsed=parseDate(value); if(!parsed) return esc(value||''); return `${esc(formatDate(value))}`; }\n // Note: Human-readable date cells keep full timestamps visible without squeezing table columns.\n function humanDateCell(value){ const parsed=parseDate(value); if(!parsed) return esc(value||''); const full=formatDate(value,'full'); return `${esc(full)}`; }\n 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)}`; }\n 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)}%
`; }\n function progress(t){ return progressBar(t.progress); }\n"; diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index 040ae88..61a034d 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -145,6 +145,10 @@
Changing rTorrent reloads the live torrent snapshot.
+