status paused
This commit is contained in:
@@ -354,7 +354,7 @@ def _active_downloading_hashes(profile: dict) -> list[str]:
|
|||||||
for row in rows:
|
for row in rows:
|
||||||
if int(row.get("complete") or 0):
|
if int(row.get("complete") or 0):
|
||||||
continue
|
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 "")
|
h = str(row.get("hash") or "")
|
||||||
if h:
|
if h:
|
||||||
hashes.append(h)
|
hashes.append(h)
|
||||||
|
|||||||
@@ -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()
|
value = str(data.get("active_filter") or "all").strip()
|
||||||
if not value or len(value) > 180:
|
if not value or len(value) > 180:
|
||||||
value = "all"
|
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:"):
|
if value not in allowed_static_filters and not value.startswith("label:") and not value.startswith("tracker:"):
|
||||||
value = "all"
|
value = "all"
|
||||||
updates["active_filter"] = value
|
updates["active_filter"] = value
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ _REMOTE_USAGE_CACHE: dict[int, tuple[float, dict]] = {}
|
|||||||
_REMOTE_USAGE_TTL_SECONDS = 60.0
|
_REMOTE_USAGE_TTL_SECONDS = 60.0
|
||||||
_REMOTE_PUBLIC_IP_CACHE: dict[int, tuple[float, str]] = {}
|
_REMOTE_PUBLIC_IP_CACHE: dict[int, tuple[float, str]] = {}
|
||||||
_REMOTE_PUBLIC_IP_TTL_SECONDS = 6 * 60 * 60.0
|
_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_DOWNLOAD_LABEL = "To download after check"
|
||||||
_POST_CHECK_WATCH_TTL_SECONDS = 48 * 60 * 60
|
_POST_CHECK_WATCH_TTL_SECONDS = 48 * 60 * 60
|
||||||
_POST_CHECK_WATCH_MIN_SECONDS = 2.0
|
_POST_CHECK_WATCH_MIN_SECONDS = 2.0
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ TORRENT_FIELDS = [
|
|||||||
"d.hash=", "d.name=", "d.state=", "d.complete=", "d.size_bytes=", "d.completed_bytes=",
|
"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.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.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 = [
|
TORRENT_OPTIONAL_FIELDS = [
|
||||||
@@ -224,7 +224,7 @@ LIVE_TORRENT_FIELDS = [
|
|||||||
"d.hash=", "d.state=", "d.complete=", "d.size_bytes=", "d.completed_bytes=",
|
"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.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.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 "")
|
base_path = str(row[15] or "")
|
||||||
state = int(row[2] or 0)
|
state = int(row[2] or 0)
|
||||||
complete = int(row[3] or 0)
|
complete = int(row[3] or 0)
|
||||||
is_active = int(row[21] or 0) if len(row) > 21 else int(state)
|
# Note: is_multi_file is needed before status calculation because the display path hides the torrent root for multi-file payloads.
|
||||||
is_open = int(row[22] or 0) if len(row) > 22 else int(is_active or state)
|
is_multi_file = int(row[24] or 0) if len(row) > 24 else 0
|
||||||
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
|
|
||||||
|
|
||||||
# Show the selected download location only. Hide the torrent root
|
# Show the selected download location only. Hide the torrent root
|
||||||
# directory for multi-file torrents and the filename for single-file
|
# 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
|
display_path = directory.rstrip("/") + "/" if directory != "/" else directory
|
||||||
else:
|
else:
|
||||||
display_path = ""
|
display_path = ""
|
||||||
msg = str(row[19] or "")
|
manual_pause = str(row[19] or "").strip() == "1"
|
||||||
|
msg = str(row[20] or "")
|
||||||
msg_l = msg.lower()
|
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.
|
# 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)
|
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)
|
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.
|
# Note: rTorrent exposes queued/inactive torrents with the same runtime flags that older UI code called paused.
|
||||||
# Manual Start handles this shape with a stop/start cycle because d.resume may leave it stuck.
|
# The app marks only explicit user Pause requests with py_manual_pause so queued rows stay separate.
|
||||||
is_paused = bool(state) and bool(is_open) and not bool(is_active) and not is_checking and not post_check
|
is_paused = manual_pause 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.
|
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 "Seeding" if complete and state else "Downloading" if state else "Stopped"
|
# 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
|
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.
|
# Note: The To download column is only meaningful for incomplete torrents; complete rows expose an empty display value.
|
||||||
return {
|
return {
|
||||||
@@ -299,6 +301,7 @@ def normalize_row(row: list) -> dict:
|
|||||||
"active": is_active,
|
"active": is_active,
|
||||||
"open": is_open,
|
"open": is_open,
|
||||||
"paused": is_paused,
|
"paused": is_paused,
|
||||||
|
"queued": is_queued,
|
||||||
"complete": complete,
|
"complete": complete,
|
||||||
"size": size,
|
"size": size,
|
||||||
"size_h": human_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_active = int(row[14] or 0)
|
||||||
is_open = int(row[15] or 0) if len(row) > 15 else int(is_active or state)
|
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 ""
|
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())
|
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)
|
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.
|
# Note: Live patches keep Queued separate from explicit user Paused using the same app marker as full snapshots.
|
||||||
is_paused = bool(state) and bool(is_open) and not bool(is_active) and not is_checking and not post_check
|
is_paused = manual_pause 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"
|
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
|
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
|
to_download_bytes = remaining_bytes if not complete else 0
|
||||||
return {
|
return {
|
||||||
@@ -363,6 +368,7 @@ def normalize_live_row(row: list) -> dict:
|
|||||||
"active": is_active,
|
"active": is_active,
|
||||||
"open": is_open,
|
"open": is_open,
|
||||||
"paused": is_paused,
|
"paused": is_paused,
|
||||||
|
"queued": is_queued,
|
||||||
"complete": complete,
|
"complete": complete,
|
||||||
"completed_bytes": completed,
|
"completed_bytes": completed,
|
||||||
"progress": progress,
|
"progress": progress,
|
||||||
@@ -625,47 +631,77 @@ def _str_rpc(c: ScgiRtorrentClient, method: str, h: str, default: str = '') -> s
|
|||||||
return default
|
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:
|
def _download_runtime_state(c: ScgiRtorrentClient, h: str) -> dict:
|
||||||
"""Read rTorrent state using the native pause model: stopped, paused or active."""
|
"""Read rTorrent state using the native pause model: stopped, paused or active."""
|
||||||
state = _int_rpc(c, 'd.state', h)
|
state = _int_rpc(c, 'd.state', h)
|
||||||
active = _int_rpc(c, 'd.is_active', h)
|
active = _int_rpc(c, 'd.is_active', h)
|
||||||
opened = _int_rpc(c, 'd.is_open', 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)
|
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)
|
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 {
|
return {
|
||||||
'state': state,
|
'state': state,
|
||||||
'open': opened,
|
'open': opened,
|
||||||
'active': active,
|
'active': active,
|
||||||
'paused': bool(state and opened and not active and not post_check),
|
'paused': paused,
|
||||||
|
'queued': queued,
|
||||||
'stopped': not bool(state),
|
'stopped': not bool(state),
|
||||||
'post_check': post_check,
|
'post_check': post_check,
|
||||||
'label': label,
|
'label': label,
|
||||||
|
'manual_pause': manual_pause,
|
||||||
'message': _str_rpc(c, 'd.message', h),
|
'message': _str_rpc(c, 'd.message', h),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def pause_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
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 '')
|
h = str(torrent_hash or '')
|
||||||
if not h:
|
if not h:
|
||||||
return {'hash': h, 'ok': False, 'error': 'missing hash'}
|
return {'hash': h, 'ok': False, 'error': 'missing hash'}
|
||||||
before = _download_runtime_state(c, h)
|
before = _download_runtime_state(c, h)
|
||||||
result = {'hash': h, 'before': before, 'commands': []}
|
result = {'hash': h, 'before': before, 'commands': []}
|
||||||
try:
|
try:
|
||||||
|
_set_manual_pause(c, h, True)
|
||||||
|
result['commands'].append('set_py_manual_pause')
|
||||||
if before.get('stopped'):
|
if before.get('stopped'):
|
||||||
# Note: rTorrent does not turn a stopped item into a paused one with d.pause alone.
|
# Note: A stopped torrent has no native paused flag; opening it first lets the UI and later Resume follow the same path.
|
||||||
# First move it out of STOP, then pause it, which matches the expected START -> PAUSE flow.
|
|
||||||
try:
|
try:
|
||||||
c.call('d.open', h)
|
c.call('d.open', h)
|
||||||
result['commands'].append('d.open')
|
result['commands'].append('d.open')
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
result.setdefault('ignored_errors', []).append(f'd.open: {exc}')
|
result.setdefault('ignored_errors', []).append(f'd.open: {exc}')
|
||||||
|
try:
|
||||||
c.call('d.start', h)
|
c.call('d.start', h)
|
||||||
result['commands'].append('d.start')
|
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)
|
c.call('d.pause', h)
|
||||||
result['commands'].append('d.pause')
|
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['after'] = _download_runtime_state(c, h)
|
||||||
result['ok'] = True
|
result['ok'] = True
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -681,9 +717,16 @@ def stop_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
|
|||||||
before = _download_runtime_state(c, h)
|
before = _download_runtime_state(c, h)
|
||||||
result = {'hash': h, 'before': before, 'commands': []}
|
result = {'hash': h, 'before': before, 'commands': []}
|
||||||
if before.get('stopped') and not before.get('post_check'):
|
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})
|
result.update({'ok': True, 'skipped': 'already_stopped', 'after': before})
|
||||||
return result
|
return result
|
||||||
try:
|
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.
|
# Note: User Stop converts the app-level Post-check state into a regular stopped torrent.
|
||||||
if before.get('post_check'):
|
if before.get('post_check'):
|
||||||
clear_post_check_download_label(c, h, before.get('label'))
|
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:
|
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 '')
|
h = str(torrent_hash or '')
|
||||||
if not h:
|
if not h:
|
||||||
return {'hash': h, 'ok': False, 'error': 'missing hash'}
|
return {'hash': h, 'ok': False, 'error': 'missing hash'}
|
||||||
before = _download_runtime_state(c, h)
|
before = _download_runtime_state(c, h)
|
||||||
result: dict = {'hash': h, 'before': before, 'commands': []}
|
result: dict = {'hash': h, 'before': before, 'commands': []}
|
||||||
if before.get('stopped'):
|
if before.get('active') and not before.get('manual_pause'):
|
||||||
result.update({'ok': False, 'skipped': 'stopped_not_paused', 'after': before})
|
|
||||||
return result
|
|
||||||
if before.get('active'):
|
|
||||||
result.update({'ok': True, 'skipped': 'already_active', 'after': before})
|
result.update({'ok': True, 'skipped': 'already_active', 'after': before})
|
||||||
return result
|
return result
|
||||||
try:
|
try:
|
||||||
# Note: ruTorrent unpauses with the equivalent of d.resume. Do not add d.start/d.open,
|
if before.get('manual_pause'):
|
||||||
# because those commands belong to Stopped/Open state, not a clean Paused state.
|
_set_manual_pause(c, h, False)
|
||||||
|
result['commands'].append('clear_py_manual_pause')
|
||||||
|
try:
|
||||||
c.call('d.resume', h)
|
c.call('d.resume', h)
|
||||||
result['commands'].append('d.resume')
|
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['after'] = _download_runtime_state(c, h)
|
||||||
result['ok'] = True
|
result['ok'] = True
|
||||||
except Exception as exc:
|
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'}
|
return {'hash': h, 'ok': False, 'error': 'missing hash'}
|
||||||
before = _download_runtime_state(c, h)
|
before = _download_runtime_state(c, h)
|
||||||
result: dict = {'hash': h, 'before': before, 'commands': []}
|
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('active'):
|
||||||
if before.get('post_check'):
|
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})
|
result.update({'ok': True, 'skipped': 'already_active', 'after': before})
|
||||||
return result
|
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:
|
try:
|
||||||
# Note: Start intentionally normalizes open/inactive torrents through Stop -> Start because d.resume can leave them stuck.
|
# Note: Start intentionally normalizes open/inactive torrents through Stop -> Start because d.resume can leave them stuck.
|
||||||
c.call('d.stop', h)
|
c.call('d.stop', h)
|
||||||
|
|||||||
@@ -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."""
|
"""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,
|
# 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.
|
# 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:
|
def _has_recent_transfer_activity(t: dict[str, Any], stalled_seconds: int) -> bool:
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ _ERROR_PATTERNS = (
|
|||||||
"unreachable",
|
"unreachable",
|
||||||
"denied",
|
"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_cache: dict[int, dict] = {}
|
||||||
_summary_lock = RLock()
|
_summary_lock = RLock()
|
||||||
|
|
||||||
@@ -46,7 +46,9 @@ def _matches(row: dict, summary_type: str) -> bool:
|
|||||||
if summary_type == "all":
|
if summary_type == "all":
|
||||||
return True
|
return True
|
||||||
if summary_type == "downloading":
|
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":
|
if summary_type == "seeding":
|
||||||
return not checking and bool(row.get("complete")) and bool(row.get("state")) and not bool(row.get("paused"))
|
return not checking and bool(row.get("complete")) and bool(row.get("state")) and not bool(row.get("paused"))
|
||||||
if summary_type == "paused":
|
if summary_type == "paused":
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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 @@
|
|||||||
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
Reference in New Issue
Block a user