diff --git a/pytorrent/services/rtorrent/torrents.py b/pytorrent/services/rtorrent/torrents.py index 077e10f..609206d 100644 --- a/pytorrent/services/rtorrent/torrents.py +++ b/pytorrent/services/rtorrent/torrents.py @@ -472,8 +472,8 @@ def torrent_peers(profile: dict, torrent_hash: str, retry_when_active: bool = Tr if not should_retry: return [] # Note: rTorrent can expose transfer counters before p.multicall catches up; short retries avoid a misleading empty peer table. - for _attempt in range(3): - time.sleep(0.2) + for _attempt in range(10): + time.sleep(0.3) rows = _peer_rows(c, torrent_hash) if rows: return _normalize_peer_rows(rows) diff --git a/pytorrent/static/js/peerRefresh.js b/pytorrent/static/js/peerRefresh.js index 4c3ad0d..abe29fc 100644 --- a/pytorrent/static/js/peerRefresh.js +++ b/pytorrent/static/js/peerRefresh.js @@ -1 +1 @@ -export const peerRefreshSource = " function setupPeersRefresh(tab=activeTab()){ clearInterval(peersRefreshTimer); peersRefreshTimer=null; if($('peersRefreshSelect')) $('peersRefreshSelect').value=String(peersRefreshSeconds||0); if(tab==='peers' && peersRefreshSeconds>0){ peersRefreshTimer=setInterval(()=>{ if(activeTab()==='peers' && selectedHash) loadDetails('peers',{silent:true}); }, peersRefreshSeconds*1000); } }\n function clearPeerEmptyRetry(){\n clearTimeout(peerEmptyRetryTimer);\n peerEmptyRetryTimer=null;\n peerEmptyRetryHash=null;\n peerEmptyRetryAttempts=0;\n }\n function peerActivityLooksActive(activity){\n return !!(activity && (Number(activity.peers_connected||0)>0 || Number(activity.up_rate||0)>0 || Number(activity.down_rate||0)>0));\n }\n function schedulePeerEmptyRetry(peers, activity){\n if((peers||[]).length || !peerActivityLooksActive(activity)){ clearPeerEmptyRetry(); return; }\n if(activeTab()!=='peers' || !selectedHash) return;\n const hash=selectedHash;\n if(peerEmptyRetryHash!==hash){ peerEmptyRetryHash=hash; peerEmptyRetryAttempts=0; }\n if(peerEmptyRetryTimer || peerEmptyRetryAttempts>=PEER_EMPTY_RETRY_MAX_ATTEMPTS) return;\n peerEmptyRetryAttempts+=1;\n peerEmptyRetryTimer=setTimeout(async()=>{\n peerEmptyRetryTimer=null;\n if(activeTab()!=='peers' || selectedHash!==hash) return;\n await loadDetails('peers',{silent:true});\n }, PEER_EMPTY_RETRY_SECONDS*1000);\n }\n function refreshPeersOnceForReverseDns(){\n // Note: Enabling reverse DNS immediately refreshes peers; pending hostnames then use their own follow-up loop.\n if(activeTab()==='peers' && selectedHash) loadDetails('peers');\n const modal=$('mobileDetailsModal');\n if(modal?.classList.contains('show') && selectedHash) openMobileDetails(selectedHash);\n }\n function syncMobileMode(){ const auto=window.matchMedia&&window.matchMedia(\"(max-width: 900px)\").matches; document.body.classList.toggle(\"mobile-mode\", auto || document.body.classList.contains(\"mobile-mode-manual\")); scheduleRender(true); }\n\n\n let automationRulesCache=[];\n let automationConditions=[];\n let automationEffects=[];\n"; +export const peerRefreshSource = " function setupPeersRefresh(tab=activeTab()){ clearInterval(peersRefreshTimer); peersRefreshTimer=null; if($('peersRefreshSelect')) $('peersRefreshSelect').value=String(peersRefreshSeconds||0); if(tab==='peers' && peersRefreshSeconds>0){ peersRefreshTimer=setInterval(()=>{ if(activeTab()==='peers' && selectedHash) loadDetails('peers',{silent:true}); }, peersRefreshSeconds*1000); } }\n function setPeerRefreshNote(message=''){\n const note=$('peerRefreshNote');\n if(!note) return;\n const text=String(message||'').trim();\n note.innerHTML = text ? ` ${esc(text)}` : '';\n note.classList.toggle('d-none', !text);\n }\n function clearPeerEmptyRetry(){\n clearTimeout(peerEmptyRetryTimer);\n peerEmptyRetryTimer=null;\n peerEmptyRetryHash=null;\n peerEmptyRetryAttempts=0;\n }\n function peerActivityLooksActive(activity){\n return !!(activity && (Number(activity.peers_connected||0)>0 || Number(activity.up_rate||0)>0 || Number(activity.down_rate||0)>0));\n }\n function schedulePeerEmptyRetry(peers, activity){\n if((peers||[]).length || !peerActivityLooksActive(activity)){ clearPeerEmptyRetry(); setPeerRefreshNote(); return; }\n if(activeTab()!=='peers' || !selectedHash) return;\n const hash=selectedHash;\n if(peerEmptyRetryHash!==hash){ peerEmptyRetryHash=hash; peerEmptyRetryAttempts=0; }\n if(peerEmptyRetryTimer || peerEmptyRetryAttempts>=PEER_EMPTY_RETRY_MAX_ATTEMPTS) return;\n peerEmptyRetryAttempts+=1;\n peerEmptyRetryTimer=setTimeout(async()=>{\n peerEmptyRetryTimer=null;\n if(activeTab()!=='peers' || selectedHash!==hash) return;\n await loadDetails('peers',{silent:true, backgroundRetry:true});\n }, PEER_EMPTY_RETRY_SECONDS*1000);\n }\n function refreshPeersOnceForReverseDns(){\n // Note: Enabling reverse DNS immediately refreshes peers; pending hostnames then use their own follow-up loop.\n if(activeTab()==='peers' && selectedHash) loadDetails('peers');\n const modal=$('mobileDetailsModal');\n if(modal?.classList.contains('show') && selectedHash) openMobileDetails(selectedHash);\n }\n function syncMobileMode(){ const auto=window.matchMedia&&window.matchMedia(\"(max-width: 900px)\").matches; document.body.classList.toggle(\"mobile-mode\", auto || document.body.classList.contains(\"mobile-mode-manual\")); scheduleRender(true); }\n\n\n let automationRulesCache=[];\n let automationConditions=[];\n let automationEffects=[];\n"; diff --git a/pytorrent/static/js/runtimeState.js b/pytorrent/static/js/runtimeState.js index 7611c3a..bf5b8d4 100644 --- a/pytorrent/static/js/runtimeState.js +++ b/pytorrent/static/js/runtimeState.js @@ -1 +1 @@ -export const runtimeStateSource = " 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: Empty active peer snapshots get a short background retry loop because rTorrent can expose traffic before peer rows.\n const PEER_EMPTY_RETRY_SECONDS = 2;\n const PEER_EMPTY_RETRY_MAX_ATTEMPTS = 6;\n let peerEmptyRetryTimer = null;\n let peerEmptyRetryHash = null;\n let peerEmptyRetryAttempts = 0;\n // Note: Reverse DNS follow-up refreshes are independent from the user-selected peers auto-refresh interval.\n const REVERSE_DNS_REFRESH_SECONDS = 2;\n const REVERSE_DNS_REFRESH_MAX_ATTEMPTS = 8;\n let reverseDnsRefreshTimer = null;\n let reverseDnsRefreshInFlight = false;\n let reverseDnsRefreshAttempts = 0;\n let reverseDnsRefreshHash = null;\n let mobileReverseDnsRefreshTimer = null;\n let mobileReverseDnsRefreshAttempts = 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 easterEggEnabled = Number(window.PYTORRENT?.easterEggEnabled || 0) !== 0;\n let easterEggLoadingImageUrl = String(window.PYTORRENT?.easterEggLoadingImageUrl || \"\").trim();\n let easterEggClickImageUrl = String(window.PYTORRENT?.easterEggClickImageUrl || \"\").trim();\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 diskMonitorOwnerLabel = String(window.PYTORRENT?.diskMonitorOwnerLabel || \"\").trim();\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_PREFIX = \"pytorrent.footerStatus.v2\";\n function isActiveProfilePayload(payload={}){\n const payloadProfile = Number(payload?.profile_id || 0);\n const currentProfile = Number(activeProfileId || window.PYTORRENT?.activeProfile || 0);\n return !payloadProfile || !currentProfile || payloadProfile === currentProfile;\n }\n function currentProfileStorageId(){\n return String(activeProfileId || window.PYTORRENT?.activeProfile || \"none\");\n }\n function footerStatusStorageKey(profileId=currentProfileStorageId()){\n return `${FOOTER_STATUS_STORAGE_PREFIX}.${profileId || \"none\"}`;\n }\n function clearProfileScopedFooterState(){\n // Note: Profile changes clear footer-only values immediately so old rTorrent data is never mixed with the new profile.\n [\"statSockets\", \"statRtDownloads\", \"statRtUploads\", \"statRtHttp\", \"statRtFiles\", \"statRtPort\", \"statDl\", \"statUl\", \"mobileSpeedDl\", \"mobileSpeedUl\", \"statPeakSession\", \"statPeakAllTime\"].forEach(id => { const el = $(id); if(el) el.textContent = \"-\"; });\n [\"statCpu\", \"statRam\", \"statVersion\", \"statTotalDl\", \"statTotalUl\"].forEach(id => { const el = $(id); if(el) el.textContent = \"-\"; });\n lastBrowserSpeed.down = \"0 B/s\";\n lastBrowserSpeed.up = \"0 B/s\";\n updateBrowserSpeedTitle(\"0 B/s\", \"0 B/s\", 0, 0);\n }\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"; +export const runtimeStateSource = " 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: Empty active peer snapshots get a short background retry loop because rTorrent can expose traffic before peer rows.\n const PEER_EMPTY_RETRY_SECONDS = 1;\n const PEER_EMPTY_RETRY_MAX_ATTEMPTS = 30;\n let peerEmptyRetryTimer = null;\n let peerEmptyRetryHash = null;\n let peerEmptyRetryAttempts = 0;\n let peerDetailsRequestSeq = 0;\n let peerDetailsAppliedSeq = 0;\n // Note: Reverse DNS follow-up refreshes are independent from the user-selected peers auto-refresh interval.\n const REVERSE_DNS_REFRESH_SECONDS = 2;\n const REVERSE_DNS_REFRESH_MAX_ATTEMPTS = 8;\n let reverseDnsRefreshTimer = null;\n let reverseDnsRefreshInFlight = false;\n let reverseDnsRefreshAttempts = 0;\n let reverseDnsRefreshHash = null;\n let mobileReverseDnsRefreshTimer = null;\n let mobileReverseDnsRefreshAttempts = 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 easterEggEnabled = Number(window.PYTORRENT?.easterEggEnabled || 0) !== 0;\n let easterEggLoadingImageUrl = String(window.PYTORRENT?.easterEggLoadingImageUrl || \"\").trim();\n let easterEggClickImageUrl = String(window.PYTORRENT?.easterEggClickImageUrl || \"\").trim();\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 diskMonitorOwnerLabel = String(window.PYTORRENT?.diskMonitorOwnerLabel || \"\").trim();\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_PREFIX = \"pytorrent.footerStatus.v2\";\n function isActiveProfilePayload(payload={}){\n const payloadProfile = Number(payload?.profile_id || 0);\n const currentProfile = Number(activeProfileId || window.PYTORRENT?.activeProfile || 0);\n return !payloadProfile || !currentProfile || payloadProfile === currentProfile;\n }\n function currentProfileStorageId(){\n return String(activeProfileId || window.PYTORRENT?.activeProfile || \"none\");\n }\n function footerStatusStorageKey(profileId=currentProfileStorageId()){\n return `${FOOTER_STATUS_STORAGE_PREFIX}.${profileId || \"none\"}`;\n }\n function clearProfileScopedFooterState(){\n // Note: Profile changes clear footer-only values immediately so old rTorrent data is never mixed with the new profile.\n [\"statSockets\", \"statRtDownloads\", \"statRtUploads\", \"statRtHttp\", \"statRtFiles\", \"statRtPort\", \"statDl\", \"statUl\", \"mobileSpeedDl\", \"mobileSpeedUl\", \"statPeakSession\", \"statPeakAllTime\"].forEach(id => { const el = $(id); if(el) el.textContent = \"-\"; });\n [\"statCpu\", \"statRam\", \"statVersion\", \"statTotalDl\", \"statTotalUl\"].forEach(id => { const el = $(id); if(el) el.textContent = \"-\"; });\n lastBrowserSpeed.down = \"0 B/s\";\n lastBrowserSpeed.up = \"0 B/s\";\n updateBrowserSpeedTitle(\"0 B/s\", \"0 B/s\", 0, 0);\n }\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"; diff --git a/pytorrent/static/js/torrentDetailsLoader.js b/pytorrent/static/js/torrentDetailsLoader.js index f90ed59..3d9ff15 100644 --- a/pytorrent/static/js/torrentDetailsLoader.js +++ b/pytorrent/static/js/torrentDetailsLoader.js @@ -1 +1 @@ -export const torrentDetailsLoaderSource = " async function loadDetails(tab, options={}){\n const t=torrents.get(selectedHash);\n const silent = !!options.silent;\n if(tab !== 'files') clearFilesAutoRefresh();\n if(tab !== 'peers'){ clearReverseDnsPeerRefresh(); clearPeerEmptyRetry(); }\n if($('peersRefreshBox')) $('peersRefreshBox').classList.toggle('d-none', tab!=='peers');\n setupPeersRefresh(tab);\n if(!t) return;\n if(tab==='general') return renderGeneral();\n if(tab==='log'){\n $('detailPane').innerHTML=`
`;\n return;\n }\n const pane=$('detailPane');\n const samePeerHash = tab==='peers' && pane?.dataset?.peersHash===selectedHash;\n const hasPeerContent = samePeerHash && pane?.dataset?.peersLoaded==='1';\n if(!silent && !hasPeerContent) pane.innerHTML=`