status paused

This commit is contained in:
Mateusz Gruszczyński
2026-06-15 11:00:20 +02:00
parent f173cc0a62
commit 337259a099
15 changed files with 119 additions and 55 deletions
+1 -1
View File
@@ -354,7 +354,7 @@ def _active_downloading_hashes(profile: dict) -> list[str]:
for row in rows:
if int(row.get("complete") or 0):
continue
if int(row.get("state") or 0) and not row.get("paused"):
if int(row.get("state") or 0) and not row.get("paused") and str(row.get("status") or "") != "Queued":
h = str(row.get("hash") or "")
if h:
hashes.append(h)
+1 -1
View File
@@ -448,7 +448,7 @@ def save_profile_preferences(user_id: int, profile_id: int | None, data: dict) -
value = str(data.get("active_filter") or "all").strip()
if not value or len(value) > 180:
value = "all"
allowed_static_filters = {"all", "downloading", "seeding", "paused", "checking", "error", "post_check", "stopped", "moving"}
allowed_static_filters = {"all", "downloading", "queued", "seeding", "paused", "checking", "error", "post_check", "stopped", "moving"}
if value not in allowed_static_filters and not value.startswith("label:") and not value.startswith("tracker:"):
value = "all"
updates["active_filter"] = value
+1
View File
@@ -95,6 +95,7 @@ _REMOTE_USAGE_CACHE: dict[int, tuple[float, dict]] = {}
_REMOTE_USAGE_TTL_SECONDS = 60.0
_REMOTE_PUBLIC_IP_CACHE: dict[int, tuple[float, str]] = {}
_REMOTE_PUBLIC_IP_TTL_SECONDS = 6 * 60 * 60.0
PY_MANUAL_PAUSE_FIELD = "py_manual_pause"
POST_CHECK_DOWNLOAD_LABEL = "To download after check"
_POST_CHECK_WATCH_TTL_SECONDS = 48 * 60 * 60
_POST_CHECK_WATCH_MIN_SECONDS = 2.0
+93 -35
View File
@@ -212,7 +212,7 @@ TORRENT_FIELDS = [
"d.hash=", "d.name=", "d.state=", "d.complete=", "d.size_bytes=", "d.completed_bytes=",
"d.ratio=", "d.up.rate=", "d.down.rate=", "d.up.total=", "d.down.total=", "d.peers_connected=",
"d.peers_complete=", "d.priority=", "d.directory=", "d.base_path=", "d.creation_date=", "d.custom1=",
"d.custom=py_ratio_group", "d.message=", "d.hashing=", "d.is_active=", "d.is_open=", "d.is_multi_file=",
"d.custom=py_ratio_group", f"d.custom={PY_MANUAL_PAUSE_FIELD}", "d.message=", "d.hashing=", "d.is_active=", "d.is_open=", "d.is_multi_file=",
]
TORRENT_OPTIONAL_FIELDS = [
@@ -224,7 +224,7 @@ LIVE_TORRENT_FIELDS = [
"d.hash=", "d.state=", "d.complete=", "d.size_bytes=", "d.completed_bytes=",
"d.ratio=", "d.up.rate=", "d.down.rate=", "d.up.total=", "d.down.total=",
"d.peers_connected=", "d.peers_complete=", "d.message=", "d.hashing=", "d.is_active=",
"d.is_open=", "d.custom1=",
"d.is_open=", "d.custom1=", f"d.custom={PY_MANUAL_PAUSE_FIELD}",
]
@@ -256,15 +256,8 @@ def normalize_row(row: list) -> dict:
base_path = str(row[15] or "")
state = int(row[2] or 0)
complete = int(row[3] or 0)
is_active = int(row[21] or 0) if len(row) > 21 else int(state)
is_open = int(row[22] or 0) if len(row) > 22 else int(is_active or state)
is_multi_file = int(row[23] or 0) if len(row) > 23 else 0
# Note: Last activity is optional because older rTorrent builds may not expose this timestamp.
last_activity = int(row[24] or 0) if len(row) > 24 else 0
if not last_activity and (down_rate > 0 or up_rate > 0):
# Note: rTorrent builds without d.timestamp.last_active still expose live rates, so active rows get a safe current timestamp.
last_activity = int(time.time())
completed_at = int(row[25] or 0) if len(row) > 25 else 0
# Note: is_multi_file is needed before status calculation because the display path hides the torrent root for multi-file payloads.
is_multi_file = int(row[24] or 0) if len(row) > 24 else 0
# Show the selected download location only. Hide the torrent root
# directory for multi-file torrents and the filename for single-file
@@ -279,17 +272,26 @@ def normalize_row(row: list) -> dict:
display_path = directory.rstrip("/") + "/" if directory != "/" else directory
else:
display_path = ""
msg = str(row[19] or "")
manual_pause = str(row[19] or "").strip() == "1"
msg = str(row[20] or "")
msg_l = msg.lower()
hashing = int(row[20] or 0) if len(row) > 20 else 0
hashing = int(row[21] or 0) if len(row) > 21 else 0
is_active = int(row[22] or 0) if len(row) > 22 else int(state)
is_open = int(row[23] or 0) if len(row) > 23 else int(is_active or state)
last_activity = int(row[25] or 0) if len(row) > 25 else 0
if not last_activity and (down_rate > 0 or up_rate > 0):
# Note: rTorrent builds without d.timestamp.last_active still expose live rates, so active rows get a safe current timestamp.
last_activity = int(time.time())
completed_at = int(row[26] or 0) if len(row) > 26 else 0
# Note: d.hashing is authoritative; stale "hash check complete" messages must not keep the UI in Checking forever.
is_checking = bool(hashing) or _message_indicates_active_check(msg_l)
post_check = POST_CHECK_DOWNLOAD_LABEL in _label_names(str(row[17] or "")) and not is_checking and not bool(is_active)
# Note: rTorrent's visible pause shape is state=1, open=1 and active=0.
# Manual Start handles this shape with a stop/start cycle because d.resume may leave it stuck.
is_paused = bool(state) and bool(is_open) and not bool(is_active) and not is_checking and not post_check
# Note: Post-check is an application-level state that separates torrents waiting after a recheck from manually stopped torrents.
status = "Checking" if is_checking else "Post-check" if post_check else "Paused" if is_paused else "Seeding" if complete and state else "Downloading" if state else "Stopped"
# Note: rTorrent exposes queued/inactive torrents with the same runtime flags that older UI code called paused.
# The app marks only explicit user Pause requests with py_manual_pause so queued rows stay separate.
is_paused = manual_pause and not is_checking and not post_check
is_queued = bool(state) and bool(is_open) and not bool(is_active) and not bool(complete) and not is_paused and not is_checking and not post_check
# Note: Post-check and Queued are application-level UI statuses; rTorrent itself mainly exposes flags.
status = "Checking" if is_checking else "Post-check" if post_check else "Paused" if is_paused else "Queued" if is_queued else "Seeding" if complete and state else "Downloading" if state else "Stopped"
to_download_bytes = remaining_bytes if not complete else 0
# Note: The To download column is only meaningful for incomplete torrents; complete rows expose an empty display value.
return {
@@ -299,6 +301,7 @@ def normalize_row(row: list) -> dict:
"active": is_active,
"open": is_open,
"paused": is_paused,
"queued": is_queued,
"complete": complete,
"size": size,
"size_h": human_size(size),
@@ -350,11 +353,13 @@ def normalize_live_row(row: list) -> dict:
is_active = int(row[14] or 0)
is_open = int(row[15] or 0) if len(row) > 15 else int(is_active or state)
labels = str(row[16] or "") if len(row) > 16 else ""
manual_pause = str(row[17] or "").strip() == "1" if len(row) > 17 else False
is_checking = bool(hashing) or _message_indicates_active_check(msg.lower())
post_check = POST_CHECK_DOWNLOAD_LABEL in _label_names(labels) and not is_checking and not bool(is_active)
# Note: Live patches keep the same pause classification as the full torrent snapshot.
is_paused = bool(state) and bool(is_open) and not bool(is_active) and not is_checking and not post_check
status = "Checking" if is_checking else "Post-check" if post_check else "Paused" if is_paused else "Seeding" if complete and state else "Downloading" if state else "Stopped"
# Note: Live patches keep Queued separate from explicit user Paused using the same app marker as full snapshots.
is_paused = manual_pause and not is_checking and not post_check
is_queued = bool(state) and bool(is_open) and not bool(is_active) and not bool(complete) and not is_paused and not is_checking and not post_check
status = "Checking" if is_checking else "Post-check" if post_check else "Paused" if is_paused else "Queued" if is_queued else "Seeding" if complete and state else "Downloading" if state else "Stopped"
progress = 100.0 if size <= 0 and complete else round((completed / size) * 100, 2) if size else 0.0
to_download_bytes = remaining_bytes if not complete else 0
return {
@@ -363,6 +368,7 @@ def normalize_live_row(row: list) -> dict:
"active": is_active,
"open": is_open,
"paused": is_paused,
"queued": is_queued,
"complete": complete,
"completed_bytes": completed,
"progress": progress,
@@ -625,47 +631,77 @@ def _str_rpc(c: ScgiRtorrentClient, method: str, h: str, default: str = '') -> s
return default
def _set_manual_pause(c: ScgiRtorrentClient, torrent_hash: str, enabled: bool) -> None:
"""Persist the user Pause intent without touching the visible label field."""
# Note: rTorrent has no reliable queued-vs-user-paused flag, so pyTorrent stores that intent in d.custom.
c.call('d.custom.set', str(torrent_hash or ''), PY_MANUAL_PAUSE_FIELD, '1' if enabled else '')
def _manual_pause_enabled(c: ScgiRtorrentClient, torrent_hash: str) -> bool:
h = str(torrent_hash or '')
for method, args in (
(f'd.custom={PY_MANUAL_PAUSE_FIELD}', (h,)),
('d.custom', (h, PY_MANUAL_PAUSE_FIELD)),
):
try:
if str(c.call(method, *args) or '').strip() == '1':
return True
except Exception:
continue
return False
def _download_runtime_state(c: ScgiRtorrentClient, h: str) -> dict:
"""Read rTorrent state using the native pause model: stopped, paused or active."""
state = _int_rpc(c, 'd.state', h)
active = _int_rpc(c, 'd.is_active', h)
opened = _int_rpc(c, 'd.is_open', h)
# Note: In rTorrent, pause does not change d.state. Paused means state=1, open=1, active=0.
label = _str_rpc(c, 'd.custom1', h)
manual_pause = _manual_pause_enabled(c, h)
post_check = POST_CHECK_DOWNLOAD_LABEL in _label_names(label) and not bool(active)
paused = bool(manual_pause and not post_check)
queued = bool(state and opened and not active and not paused and not post_check)
return {
'state': state,
'open': opened,
'active': active,
'paused': bool(state and opened and not active and not post_check),
'paused': paused,
'queued': queued,
'stopped': not bool(state),
'post_check': post_check,
'label': label,
'manual_pause': manual_pause,
'message': _str_rpc(c, 'd.message', h),
}
def pause_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
"""Pause an active rTorrent item without stopping or closing it."""
"""Mark a torrent as user-paused and ask rTorrent to pause it."""
h = str(torrent_hash or '')
if not h:
return {'hash': h, 'ok': False, 'error': 'missing hash'}
before = _download_runtime_state(c, h)
result = {'hash': h, 'before': before, 'commands': []}
try:
_set_manual_pause(c, h, True)
result['commands'].append('set_py_manual_pause')
if before.get('stopped'):
# Note: rTorrent does not turn a stopped item into a paused one with d.pause alone.
# First move it out of STOP, then pause it, which matches the expected START -> PAUSE flow.
# Note: A stopped torrent has no native paused flag; opening it first lets the UI and later Resume follow the same path.
try:
c.call('d.open', h)
result['commands'].append('d.open')
except Exception as exc:
result.setdefault('ignored_errors', []).append(f'd.open: {exc}')
try:
c.call('d.start', h)
result['commands'].append('d.start')
# Note: Smart Queue frees a slot with d.pause, not d.stop, so later d.resume behaves like ruTorrent.
except Exception as exc:
result.setdefault('ignored_errors', []).append(f'd.start: {exc}')
try:
c.call('d.pause', h)
result['commands'].append('d.pause')
except Exception as exc:
result.setdefault('ignored_errors', []).append(f'd.pause: {exc}')
result['after'] = _download_runtime_state(c, h)
result['ok'] = True
except Exception as exc:
@@ -681,9 +717,16 @@ def stop_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
before = _download_runtime_state(c, h)
result = {'hash': h, 'before': before, 'commands': []}
if before.get('stopped') and not before.get('post_check'):
if before.get('manual_pause'):
_set_manual_pause(c, h, False)
result['commands'].append('clear_py_manual_pause')
before = _download_runtime_state(c, h)
result.update({'ok': True, 'skipped': 'already_stopped', 'after': before})
return result
try:
if before.get('manual_pause'):
_set_manual_pause(c, h, False)
result['commands'].append('clear_py_manual_pause')
# Note: User Stop converts the app-level Post-check state into a regular stopped torrent.
if before.get('post_check'):
clear_post_check_download_label(c, h, before.get('label'))
@@ -699,23 +742,34 @@ def stop_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
def resume_paused_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
"""Resume only a paused rTorrent item using native rTorrent resume semantics."""
"""Resume a user-paused torrent and clear pyTorrent's pause marker."""
h = str(torrent_hash or '')
if not h:
return {'hash': h, 'ok': False, 'error': 'missing hash'}
before = _download_runtime_state(c, h)
result: dict = {'hash': h, 'before': before, 'commands': []}
if before.get('stopped'):
result.update({'ok': False, 'skipped': 'stopped_not_paused', 'after': before})
return result
if before.get('active'):
if before.get('active') and not before.get('manual_pause'):
result.update({'ok': True, 'skipped': 'already_active', 'after': before})
return result
try:
# Note: ruTorrent unpauses with the equivalent of d.resume. Do not add d.start/d.open,
# because those commands belong to Stopped/Open state, not a clean Paused state.
if before.get('manual_pause'):
_set_manual_pause(c, h, False)
result['commands'].append('clear_py_manual_pause')
try:
c.call('d.resume', h)
result['commands'].append('d.resume')
except Exception as exc:
result.setdefault('ignored_errors', []).append(f'd.resume: {exc}')
try:
c.call('d.open', h)
result['commands'].append('d.open')
except Exception as exc:
result.setdefault('ignored_errors', []).append(f'd.open: {exc}')
try:
c.call('d.start', h)
result['commands'].append('d.start')
except Exception as exc:
result.setdefault('ignored_errors', []).append(f'd.start: {exc}')
result['after'] = _download_runtime_state(c, h)
result['ok'] = True
except Exception as exc:
@@ -735,6 +789,10 @@ def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str, prefer_start:
return {'hash': h, 'ok': False, 'error': 'missing hash'}
before = _download_runtime_state(c, h)
result: dict = {'hash': h, 'before': before, 'commands': []}
if before.get('manual_pause'):
_set_manual_pause(c, h, False)
result['commands'].append('clear_py_manual_pause')
before = _download_runtime_state(c, h)
if before.get('active'):
if before.get('post_check'):
@@ -743,7 +801,7 @@ def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str, prefer_start:
result.update({'ok': True, 'skipped': 'already_active', 'after': before})
return result
if (before.get('paused') and not prefer_start) or before.get('post_check'):
if (before.get('paused') and not prefer_start) or before.get('queued') or before.get('post_check'):
try:
# Note: Start intentionally normalizes open/inactive torrents through Stop -> Start because d.resume can leave them stuck.
c.call('d.stop', h)
+1 -1
View File
@@ -834,7 +834,7 @@ def _is_running_download_slot(t: dict[str, Any]) -> bool:
"""Return True for incomplete torrents that already occupy a Smart Queue slot."""
# Note: Do not exclude Smart Queue/Stalled labels here. Manual Start can leave old labels,
# and those torrents still must count toward the global Smart Queue limit.
return _is_started_download_slot(t)
return _is_started_download_slot(t) and not _is_user_paused(t)
def _has_recent_transfer_activity(t: dict[str, Any], stalled_seconds: int) -> bool:
+4 -2
View File
@@ -19,7 +19,7 @@ _ERROR_PATTERNS = (
"unreachable",
"denied",
)
_SUMMARY_TYPES = ("all", "downloading", "seeding", "paused", "checking", "error", "post_check", "stopped")
_SUMMARY_TYPES = ("all", "downloading", "queued", "seeding", "paused", "checking", "error", "post_check", "stopped")
_summary_cache: dict[int, dict] = {}
_summary_lock = RLock()
@@ -46,7 +46,9 @@ def _matches(row: dict, summary_type: str) -> bool:
if summary_type == "all":
return True
if summary_type == "downloading":
return not checking and not bool(row.get("complete")) and bool(row.get("state")) and not bool(row.get("paused"))
return not checking and not bool(row.get("complete")) and bool(row.get("state")) and not bool(row.get("paused")) and str(row.get("status") or "") != "Queued"
if summary_type == "queued":
return not checking and (bool(row.get("queued")) or str(row.get("status") or "") == "Queued")
if summary_type == "seeding":
return not checking and bool(row.get("complete")) and bool(row.get("state")) and not bool(row.get("paused"))
if summary_type == "paused":
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
export const profileSelectionSource = " 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 = `<tr><td colspan=\"${torrentColumnSpan()}\" class=\"empty\"><div class=\"empty-state\"><b>Select an rTorrent profile.</b><span>${esc(count)} profile(s) are configured for this trusted bypass session. Choose which one to open.</span><button id=\"chooseProfileBtn\" class=\"btn btn-sm btn-primary\" type=\"button\"><i class=\"fa-solid fa-server\"></i> Choose profile</button></div></td></tr>`;\n }\n const list = $('mobileList');\n if(list) list.innerHTML = `<div class=\"empty\"><div class=\"empty-state\"><b>Select an rTorrent profile.</b><span>Choose a profile to load torrents.</span></div></div>`;\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=>`<option value=\"${esc(p.id)}\" ${j.active?.id===p.id?'selected':''}>${esc(p.name)}</option>`).join('') || '<option value=\"\">No profiles configured</option>';\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";
export const profileSelectionSource = " function renderProfileSelectionState(count=0){\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},queued:{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 = `<tr><td colspan=\"${torrentColumnSpan()}\" class=\"empty\"><div class=\"empty-state\"><b>Select an rTorrent profile.</b><span>${esc(count)} profile(s) are configured for this trusted bypass session. Choose which one to open.</span><button id=\"chooseProfileBtn\" class=\"btn btn-sm btn-primary\" type=\"button\"><i class=\"fa-solid fa-server\"></i> Choose profile</button></div></td></tr>`;\n }\n const list = $('mobileList');\n if(list) list.innerHTML = `<div class=\"empty\"><div class=\"empty-state\"><b>Select an rTorrent profile.</b><span>Choose a profile to load torrents.</span></div></div>`;\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=>`<option value=\"${esc(p.id)}\" ${j.active?.id===p.id?'selected':''}>${esc(p.name)}</option>`).join('') || '<option value=\"\">No profiles configured</option>';\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";
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
export const torrentFilterHelpersSource = " // Note: Displays status filter summaries calculated and cached by the backend API.\n const FILTER_COUNT_IDS = {all:'countAll', downloading:'countDownloading', seeding:'countSeeding', paused:'countPaused', checking:'countChecking', error:'countError', post_check:'countPostCheck', stopped:'countStopped', moving:'countMoving'};\n function formatFilterBytes(value){ return fmtBytes(value).replace(/\\.0 (?=GiB|TiB)/, ' '); }\n function filterMetaLine(bucket){\n if(!bucket || !Number(bucket.count||0)) return '';\n const disk=Number(bucket.disk_bytes ?? bucket.completed_bytes ?? 0);\n return `Data ${formatFilterBytes(disk)}`;\n }\n function filterNeedsDownloadDetails(type, bucket){\n if(!bucket || !Number(bucket.count||0)) return false;\n if(type==='downloading' || type==='post_check') return true;\n if(type!=='paused' && type!=='stopped') return false;\n const size=Number(bucket.size||0);\n const completed=Number(bucket.completed_bytes ?? bucket.disk_bytes ?? 0);\n const remaining=Number(bucket.remaining_bytes ?? Math.max(0, size-completed));\n const progress=Number(bucket.progress_percent ?? (size ? (completed / size) * 100 : 0));\n return size > 0 && remaining > 0 && progress < 100;\n }\n function filterTooltipLine(bucket, type){\n if(!bucket || !Number(bucket.count||0)) return '';\n const size=Number(bucket.size||0);\n const disk=Number(bucket.disk_bytes ?? bucket.completed_bytes ?? 0);\n const completed=Number(bucket.completed_bytes ?? disk);\n const remaining=Number(bucket.remaining_bytes ?? Math.max(0, size-completed));\n const progress=Number(bucket.progress_percent ?? (size ? (completed / size) * 100 : 0));\n const left=Number(bucket.remaining_percent ?? Math.max(0, 100-progress));\n const lines=[`Data: ${formatFilterBytes(disk)}`];\n if(filterNeedsDownloadDetails(type, bucket)){\n lines.push(`Total to download: ${formatFilterBytes(size)}`);\n lines.push(`Downloaded: ${formatFilterBytes(completed)} (${progress.toFixed(1)}%)`);\n lines.push(`Left: ${formatFilterBytes(remaining)} (${left.toFixed(1)}%)`);\n }\n return lines.join('\\n');\n }\n function applyFilterTooltip(button, tooltip, ariaLabel){\n if(tooltip){\n button.title = tooltip;\n button.setAttribute('aria-label', ariaLabel);\n } else {\n button.removeAttribute('title');\n button.removeAttribute('aria-label');\n }\n }\n function ensureStableFilterTooltip(button){\n if(filterTooltipState.has(button)) return filterTooltipState.get(button);\n const state = {hovering:false, pending:null};\n filterTooltipState.set(button, state);\n button.addEventListener('mouseenter', () => {\n state.hovering = true;\n state.pending = null;\n });\n button.addEventListener('mouseleave', () => {\n state.hovering = false;\n if(state.pending){\n applyFilterTooltip(button, state.pending.tooltip, state.pending.ariaLabel);\n state.pending = null;\n }\n });\n return state;\n }\n // Note: Freezes tooltip content during hover; the next hover receives the newest live summary.\n function setStableFilterTooltip(button, tooltip, ariaLabel){\n const state = ensureStableFilterTooltip(button);\n if(state.hovering){\n state.pending = {tooltip, ariaLabel};\n return;\n }\n applyFilterTooltip(button, tooltip, ariaLabel);\n }\n";
export const torrentFilterHelpersSource = " // Note: Displays status filter summaries calculated and cached by the backend API.\n const FILTER_COUNT_IDS = {all:'countAll', downloading:'countDownloading', queued:'countQueued', seeding:'countSeeding', paused:'countPaused', checking:'countChecking', error:'countError', post_check:'countPostCheck', stopped:'countStopped', moving:'countMoving'};\n function formatFilterBytes(value){ return fmtBytes(value).replace(/\\.0 (?=GiB|TiB)/, ' '); }\n function filterMetaLine(bucket){\n if(!bucket || !Number(bucket.count||0)) return '';\n const disk=Number(bucket.disk_bytes ?? bucket.completed_bytes ?? 0);\n return `Data ${formatFilterBytes(disk)}`;\n }\n function filterNeedsDownloadDetails(type, bucket){\n if(!bucket || !Number(bucket.count||0)) return false;\n if(type==='downloading' || type==='queued' || type==='post_check') return true;\n if(type!=='paused' && type!=='stopped') return false;\n const size=Number(bucket.size||0);\n const completed=Number(bucket.completed_bytes ?? bucket.disk_bytes ?? 0);\n const remaining=Number(bucket.remaining_bytes ?? Math.max(0, size-completed));\n const progress=Number(bucket.progress_percent ?? (size ? (completed / size) * 100 : 0));\n return size > 0 && remaining > 0 && progress < 100;\n }\n function filterTooltipLine(bucket, type){\n if(!bucket || !Number(bucket.count||0)) return '';\n const size=Number(bucket.size||0);\n const disk=Number(bucket.disk_bytes ?? bucket.completed_bytes ?? 0);\n const completed=Number(bucket.completed_bytes ?? disk);\n const remaining=Number(bucket.remaining_bytes ?? Math.max(0, size-completed));\n const progress=Number(bucket.progress_percent ?? (size ? (completed / size) * 100 : 0));\n const left=Number(bucket.remaining_percent ?? Math.max(0, 100-progress));\n const lines=[`Data: ${formatFilterBytes(disk)}`];\n if(filterNeedsDownloadDetails(type, bucket)){\n lines.push(`Total to download: ${formatFilterBytes(size)}`);\n lines.push(`Downloaded: ${formatFilterBytes(completed)} (${progress.toFixed(1)}%)`);\n lines.push(`Left: ${formatFilterBytes(remaining)} (${left.toFixed(1)}%)`);\n }\n return lines.join('\\n');\n }\n function applyFilterTooltip(button, tooltip, ariaLabel){\n if(tooltip){\n button.title = tooltip;\n button.setAttribute('aria-label', ariaLabel);\n } else {\n button.removeAttribute('title');\n button.removeAttribute('aria-label');\n }\n }\n function ensureStableFilterTooltip(button){\n if(filterTooltipState.has(button)) return filterTooltipState.get(button);\n const state = {hovering:false, pending:null};\n filterTooltipState.set(button, state);\n button.addEventListener('mouseenter', () => {\n state.hovering = true;\n state.pending = null;\n });\n button.addEventListener('mouseleave', () => {\n state.hovering = false;\n if(state.pending){\n applyFilterTooltip(button, state.pending.tooltip, state.pending.ariaLabel);\n state.pending = null;\n }\n });\n return state;\n }\n // Note: Freezes tooltip content during hover; the next hover receives the newest live summary.\n function setStableFilterTooltip(button, tooltip, ariaLabel){\n const state = ensureStableFilterTooltip(button);\n if(state.hovering){\n state.pending = {tooltip, ariaLabel};\n return;\n }\n applyFilterTooltip(button, tooltip, ariaLabel);\n }\n";
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long