automatyzacje-comit3
This commit is contained in:
@@ -71,3 +71,4 @@ JOBS_RETENTION_DAYS = _env_int("PYTORRENT_JOBS_RETENTION_DAYS", 30, 1)
|
||||
SMART_QUEUE_HISTORY_RETENTION_DAYS = _env_int("PYTORRENT_SMART_QUEUE_HISTORY_RETENTION_DAYS", 30, 1)
|
||||
LOG_RETENTION_DAYS = _env_int("PYTORRENT_LOG_RETENTION_DAYS", 30, 1)
|
||||
SMART_QUEUE_LABEL = os.getenv("PYTORRENT_SMART_QUEUE_LABEL", "Smart Queue Paused")
|
||||
SMART_QUEUE_STALLED_LABEL = os.getenv("PYTORRENT_SMART_QUEUE_STALLED_LABEL", "Stalled")
|
||||
|
||||
@@ -139,6 +139,7 @@ CREATE TABLE IF NOT EXISTS smart_queue_settings (
|
||||
stalled_seconds INTEGER DEFAULT 300,
|
||||
min_speed_bytes INTEGER DEFAULT 1024,
|
||||
min_seeds INTEGER DEFAULT 1,
|
||||
min_peers INTEGER DEFAULT 0,
|
||||
manage_stopped INTEGER DEFAULT 0,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(user_id, profile_id)
|
||||
@@ -280,6 +281,7 @@ MIGRATIONS = [
|
||||
"ALTER TABLE rtorrent_config_overrides ADD COLUMN baseline_value TEXT",
|
||||
"ALTER TABLE torrent_stats_cache ADD COLUMN updated_epoch REAL DEFAULT 0",
|
||||
"ALTER TABLE smart_queue_settings ADD COLUMN manage_stopped INTEGER DEFAULT 0",
|
||||
"ALTER TABLE smart_queue_settings ADD COLUMN min_peers INTEGER DEFAULT 0",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ def openapi():
|
||||
"/api/rss/feeds": {"post": {"summary": "Add RSS feed", "requestBody": {"content": {"application/json": {"schema": {"type": "object"}}}}, "responses": {"200": {"description": "RSS config"}}}},
|
||||
"/api/rss/rules": {"post": {"summary": "Add RSS rule", "requestBody": {"content": {"application/json": {"schema": {"type": "object"}}}}, "responses": {"200": {"description": "RSS config"}}}},
|
||||
"/api/rss/check": {"post": {"summary": "Manually check RSS feeds", "responses": {"200": {"description": "Queued matches"}}}},
|
||||
"/api/smart-queue": {"get": {"summary": "Get Smart Queue settings, exceptions and history", "parameters": [{"name": "history_limit", "in": "query", "schema": {"type": "integer", "default": 10, "minimum": 1, "maximum": 100}, "description": "Number of Smart Queue history rows to return"}], "responses": {"200": {"description": "Smart Queue config with history and history_total"}}}, "post": {"summary": "Save Smart Queue settings", "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"enabled": {"type": "boolean"}, "max_active_downloads": {"type": "integer"}, "stalled_seconds": {"type": "integer"}, "min_speed_bytes": {"type": "integer"}, "min_seeds": {"type": "integer"}}}}}}, "responses": {"200": {"description": "Saved"}}}},
|
||||
"/api/smart-queue": {"get": {"summary": "Get Smart Queue settings, exceptions and history", "parameters": [{"name": "history_limit", "in": "query", "schema": {"type": "integer", "default": 10, "minimum": 1, "maximum": 100}, "description": "Number of Smart Queue history rows to return"}], "responses": {"200": {"description": "Smart Queue config with history and history_total"}}}, "post": {"summary": "Save Smart Queue settings", "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"enabled": {"type": "boolean"}, "max_active_downloads": {"type": "integer"}, "stalled_seconds": {"type": "integer"}, "min_speed_bytes": {"type": "integer"}, "min_seeds": {"type": "integer"}, "min_peers": {"type": "integer"}}}}}}, "responses": {"200": {"description": "Saved"}}}},
|
||||
"/api/smart-queue/check": {"post": {"summary": "Run Smart Queue immediately", "responses": {"200": {"description": "Smart Queue action result"}}}},
|
||||
"/api/smart-queue/exclusion": {"post": {"summary": "Add or remove a torrent Smart Queue exception", "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"hash": {"type": "string"}, "excluded": {"type": "boolean"}, "reason": {"type": "string"}}}}}}, "responses": {"200": {"description": "Exception list"}}}},
|
||||
"/api/traffic/history": {"get": {"summary": "Transfer history for charts", "parameters": [{"name": "range", "in": "query", "schema": {"type": "string", "enum": ["15m", "1h", "3h", "6h", "24h", "7d", "30d", "90d"]}}], "responses": {"200": {"description": "Aggregated traffic history"}}}}
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any
|
||||
import json
|
||||
import time
|
||||
|
||||
from ..config import SMART_QUEUE_LABEL
|
||||
from ..config import SMART_QUEUE_LABEL, SMART_QUEUE_STALLED_LABEL
|
||||
from ..db import connect, default_user_id, utcnow
|
||||
from . import rtorrent
|
||||
from .preferences import active_profile, get_profile
|
||||
@@ -20,6 +20,14 @@ def _ts(value: str | None) -> float:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _int_setting(data: dict[str, Any], current: dict[str, Any], key: str, default: int, minimum: int = 0) -> int:
|
||||
raw = data.get(key) if key in data else current.get(key)
|
||||
try:
|
||||
return max(minimum, int(raw if raw is not None and raw != '' else default))
|
||||
except (TypeError, ValueError):
|
||||
return max(minimum, int(default))
|
||||
|
||||
|
||||
def _default_settings(user_id: int, profile_id: int) -> dict[str, Any]:
|
||||
return {
|
||||
'user_id': user_id,
|
||||
@@ -29,6 +37,7 @@ def _default_settings(user_id: int, profile_id: int) -> dict[str, Any]:
|
||||
'stalled_seconds': 300,
|
||||
'min_speed_bytes': 1024,
|
||||
'min_seeds': 1,
|
||||
'min_peers': 0,
|
||||
'manage_stopped': 0,
|
||||
'updated_at': utcnow(),
|
||||
}
|
||||
@@ -49,27 +58,30 @@ def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = N
|
||||
current = get_settings(profile_id, user_id)
|
||||
settings = {
|
||||
'enabled': 1 if data.get('enabled', current.get('enabled')) else 0,
|
||||
'max_active_downloads': max(1, int(data.get('max_active_downloads') or current.get('max_active_downloads') or 5)),
|
||||
'stalled_seconds': max(30, int(data.get('stalled_seconds') or current.get('stalled_seconds') or 300)),
|
||||
'min_speed_bytes': max(0, int(data.get('min_speed_bytes') or current.get('min_speed_bytes') or 0)),
|
||||
'min_seeds': max(0, int(data.get('min_seeds') or current.get('min_seeds') or 0)),
|
||||
'max_active_downloads': _int_setting(data, current, 'max_active_downloads', 5, 1),
|
||||
'stalled_seconds': _int_setting(data, current, 'stalled_seconds', 300, 30),
|
||||
'min_speed_bytes': _int_setting(data, current, 'min_speed_bytes', 0, 0),
|
||||
'min_seeds': _int_setting(data, current, 'min_seeds', 0, 0),
|
||||
# Note: Min peers is optional; when set, stalled detection requires low speed, low seeds and low peers.
|
||||
'min_peers': _int_setting(data, current, 'min_peers', 0, 0),
|
||||
# Note: This switch protects fully stopped torrents from automatic starts; by default Smart Queue manages only paused items.
|
||||
'manage_stopped': 1 if data.get('manage_stopped', current.get('manage_stopped')) else 0,
|
||||
}
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
'''INSERT INTO smart_queue_settings(user_id,profile_id,enabled,max_active_downloads,stalled_seconds,min_speed_bytes,min_seeds,manage_stopped,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)
|
||||
'''INSERT INTO smart_queue_settings(user_id,profile_id,enabled,max_active_downloads,stalled_seconds,min_speed_bytes,min_seeds,min_peers,manage_stopped,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(user_id, profile_id) DO UPDATE SET
|
||||
enabled=excluded.enabled,
|
||||
max_active_downloads=excluded.max_active_downloads,
|
||||
stalled_seconds=excluded.stalled_seconds,
|
||||
min_speed_bytes=excluded.min_speed_bytes,
|
||||
min_seeds=excluded.min_seeds,
|
||||
min_peers=excluded.min_peers,
|
||||
manage_stopped=excluded.manage_stopped,
|
||||
updated_at=excluded.updated_at''',
|
||||
(user_id, profile_id, settings['enabled'], settings['max_active_downloads'], settings['stalled_seconds'], settings['min_speed_bytes'], settings['min_seeds'], settings['manage_stopped'], now),
|
||||
(user_id, profile_id, settings['enabled'], settings['max_active_downloads'], settings['stalled_seconds'], settings['min_speed_bytes'], settings['min_seeds'], settings['min_peers'], settings['manage_stopped'], now),
|
||||
)
|
||||
return get_settings(profile_id, user_id)
|
||||
|
||||
@@ -158,6 +170,32 @@ def _has_smart_queue_label(value: str | None) -> bool:
|
||||
def _without_smart_queue_label(value: str | None) -> str:
|
||||
return _label_value([label for label in _label_names(value) if label != SMART_QUEUE_LABEL])
|
||||
|
||||
|
||||
def _has_stalled_label(value: str | None) -> bool:
|
||||
return SMART_QUEUE_STALLED_LABEL in _label_names(value)
|
||||
|
||||
|
||||
def _without_queue_technical_labels(value: str | None) -> str:
|
||||
return _label_value([label for label in _label_names(value) if label != SMART_QUEUE_LABEL])
|
||||
|
||||
|
||||
def _ensure_stalled_label(client: Any, torrent_hash: str, current_label: str = '') -> bool:
|
||||
labels = [label for label in _label_names(current_label) if label != SMART_QUEUE_LABEL]
|
||||
changed = False
|
||||
if SMART_QUEUE_STALLED_LABEL not in labels:
|
||||
labels.append(SMART_QUEUE_STALLED_LABEL)
|
||||
changed = True
|
||||
if SMART_QUEUE_LABEL in _label_names(current_label):
|
||||
changed = True
|
||||
if not changed:
|
||||
return True
|
||||
try:
|
||||
# Note: Stalled marking is idempotent; it adds Stalled and removes only the Smart Queue technical marker.
|
||||
client.call('d.custom1.set', torrent_hash, _label_value(labels))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _remember_auto_label(profile_id: int, torrent_hash: str, previous_label: str) -> None:
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
@@ -200,11 +238,10 @@ def _restore_auto_label(client: Any, profile_id: int, torrent_hash: str, current
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
previous = row.get('previous_label') or ''
|
||||
try:
|
||||
# Note: Restore the saved label only when the current label still contains the Smart Queue marker.
|
||||
if _has_smart_queue_label(live_label) or current_label is None:
|
||||
client.call('d.custom1.set', torrent_hash, previous)
|
||||
# Note: Starting a torrent removes only Smart Queue's technical marker, so labels added while paused stay untouched.
|
||||
if _has_smart_queue_label(live_label):
|
||||
client.call('d.custom1.set', torrent_hash, _without_smart_queue_label(live_label))
|
||||
conn.execute('DELETE FROM smart_queue_auto_labels WHERE profile_id=? AND torrent_hash=?', (profile_id, torrent_hash))
|
||||
return True
|
||||
except Exception:
|
||||
@@ -338,6 +375,8 @@ def _mark_auto_paused(client: Any, profile_id: int, torrent: dict[str, Any]) ->
|
||||
def _is_smart_queue_hold(torrent: dict[str, Any] | None, manage_stopped: bool = True) -> bool:
|
||||
if not torrent or int(torrent.get('complete') or 0):
|
||||
return False
|
||||
if _has_stalled_label(str(torrent.get('label') or '')):
|
||||
return False
|
||||
if _has_smart_queue_label(str(torrent.get('label') or '')):
|
||||
return True
|
||||
# Note: Paused in rTorrent usually has state=1 and active=0, so state=0 must not be required.
|
||||
@@ -395,7 +434,7 @@ def _is_running_download_slot(t: dict[str, Any]) -> bool:
|
||||
# Paused can have state=1/open=1, so a slot is counted only after d.is_active=1.
|
||||
if int(t.get('complete') or 0):
|
||||
return False
|
||||
if _has_smart_queue_label(str(t.get('label') or '')):
|
||||
if _has_smart_queue_label(str(t.get('label') or '')) or _has_stalled_label(str(t.get('label') or '')):
|
||||
return False
|
||||
status = str(t.get('status') or '').lower()
|
||||
if status == 'checking' or status == 'paused' or bool(t.get('paused')):
|
||||
@@ -407,6 +446,8 @@ def _is_waiting_download_candidate(t: dict[str, Any], manage_stopped: bool) -> b
|
||||
"""Return True for paused/held torrents Smart Queue may resume later."""
|
||||
if int(t.get('complete') or 0):
|
||||
return False
|
||||
if _has_stalled_label(str(t.get('label') or '')):
|
||||
return False
|
||||
if _has_smart_queue_label(str(t.get('label') or '')):
|
||||
return True
|
||||
# Note: Paused items are the primary source for filling the queue, regardless of manage_stopped.
|
||||
@@ -457,6 +498,7 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
|
||||
]
|
||||
min_speed = int(settings.get('min_speed_bytes') or 0)
|
||||
min_seeds = int(settings.get('min_seeds') or 0)
|
||||
min_peers = int(settings.get('min_peers') or 0)
|
||||
stalled_seconds = int(settings.get('stalled_seconds') or 300)
|
||||
now = utcnow()
|
||||
now_ts = datetime.now(timezone.utc).timestamp()
|
||||
@@ -464,7 +506,8 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
|
||||
|
||||
with connect() as conn:
|
||||
for t in downloading:
|
||||
is_stalled = int(t.get('down_rate') or 0) <= min_speed and int(t.get('seeds') or 0) <= min_seeds
|
||||
# Note: Stalled detection requires low speed plus low seeds and, when configured, low peers.
|
||||
is_stalled = int(t.get('down_rate') or 0) <= min_speed and int(t.get('seeds') or 0) <= min_seeds and (min_peers <= 0 or int(t.get('peers') or 0) <= min_peers)
|
||||
h = t.get('hash')
|
||||
if not h:
|
||||
continue
|
||||
@@ -504,13 +547,12 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
|
||||
to_pause: list[dict[str, Any]] = pause_rank[:max(0, len(downloading) - max_active)]
|
||||
pause_hashes = {str(t.get('hash') or '') for t in to_pause}
|
||||
|
||||
# Note: Stalled rotation runs only when the queue is full. When slots are missing, Smart Queue should
|
||||
# first add missing items instead of pausing existing or incorrectly detected stalled items.
|
||||
if candidates and len(downloading) >= max_active:
|
||||
replaceable_stalled = [t for t in stalled if str(t.get('hash') or '') not in pause_hashes]
|
||||
for t in replaceable_stalled[:max(0, len(candidates) - len(to_pause))]:
|
||||
# Note: Confirmed stalled downloads are removed from the active queue immediately, then new candidates can fill those slots.
|
||||
for t in stalled:
|
||||
h = str(t.get('hash') or '')
|
||||
if h and h not in pause_hashes:
|
||||
to_pause.append(t)
|
||||
pause_hashes.add(str(t.get('hash') or ''))
|
||||
pause_hashes.add(h)
|
||||
|
||||
active_after_pause = max(0, len(downloading) - len(to_pause))
|
||||
available_slots = max(0, max_active - active_after_pause)
|
||||
@@ -523,6 +565,7 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
|
||||
paused: list[str] = []
|
||||
resumed: list[str] = []
|
||||
label_failed: list[str] = []
|
||||
stalled_labeled: list[str] = []
|
||||
start_failed: list[dict[str, str]] = []
|
||||
start_no_effect: list[dict[str, Any]] = []
|
||||
resume_requested: list[str] = []
|
||||
@@ -530,12 +573,18 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
|
||||
|
||||
for t in to_pause:
|
||||
try:
|
||||
pause_result = rtorrent.pause_hash(c, t['hash'])
|
||||
h = str(t.get('hash') or '')
|
||||
pause_result = rtorrent.pause_hash(c, h)
|
||||
if not pause_result.get('ok'):
|
||||
raise RuntimeError(pause_result.get('error') or 'pause failed')
|
||||
if not _mark_auto_paused(c, profile_id, t):
|
||||
label_failed.append(t['hash'])
|
||||
paused.append(t['hash'])
|
||||
if h in stalled_hashes:
|
||||
if _ensure_stalled_label(c, h, _read_label(c, h, str(t.get('label') or ''))):
|
||||
stalled_labeled.append(h)
|
||||
else:
|
||||
label_failed.append(h)
|
||||
elif not _mark_auto_paused(c, profile_id, t):
|
||||
label_failed.append(h)
|
||||
paused.append(h)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -573,6 +622,6 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
|
||||
| {str(t.get('hash') or '') for t in stopped if _has_smart_queue_label(str(t.get('label') or '')) and str(t.get('hash') or '') not in set(resumed)}
|
||||
)
|
||||
restored = _cleanup_auto_labels(c, profile_id, torrents, keep_labels, manage_stopped)
|
||||
details = {'excluded': len(excluded), 'enabled': bool(settings.get('enabled')), 'auto_label': SMART_QUEUE_LABEL, 'labels_restored': restored, 'labels_failed': label_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'start_results': start_results, 'resume_requested': resume_requested, 'active_verified': active_verified, 'waiting_labeled': len(to_label_waiting), 'manage_stopped': manage_stopped, 'max_active_downloads': max_active, 'active_before': len(downloading), 'active_after_expected': active_after_pause + len(resumed), 'paused_planned': len(to_pause), 'resumed_planned': len(to_resume), 'rtorrent_cap': rtorrent_cap}
|
||||
details = {'excluded': len(excluded), 'enabled': bool(settings.get('enabled')), 'auto_label': SMART_QUEUE_LABEL, 'stalled_label': SMART_QUEUE_STALLED_LABEL, 'stalled_labeled': stalled_labeled, 'labels_restored': restored, 'labels_failed': label_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'start_results': start_results, 'resume_requested': resume_requested, 'active_verified': active_verified, 'waiting_labeled': len(to_label_waiting), 'manage_stopped': manage_stopped, 'max_active_downloads': max_active, 'active_before': len(downloading), 'active_after_expected': active_after_pause + len(resumed), 'paused_planned': len(to_pause), 'resumed_planned': len(to_resume), 'rtorrent_cap': rtorrent_cap}
|
||||
add_history(profile_id, 'force_check' if force else 'auto_check', paused, resumed, len(torrents), details, user_id)
|
||||
return {'ok': True, 'enabled': bool(settings.get('enabled')), 'paused': paused, 'resumed': resumed, 'resume_requested': resume_requested, 'waiting_labeled': len(to_label_waiting), 'labels_restored': restored, 'labels_failed': label_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'active_verified': active_verified, 'rtorrent_cap': rtorrent_cap, 'checked': len(torrents), 'excluded': len(excluded), 'settings': settings}
|
||||
return {'ok': True, 'enabled': bool(settings.get('enabled')), 'paused': paused, 'resumed': resumed, 'resume_requested': resume_requested, 'waiting_labeled': len(to_label_waiting), 'stalled_labeled': stalled_labeled, 'labels_restored': restored, 'labels_failed': label_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'active_verified': active_verified, 'rtorrent_cap': rtorrent_cap, 'checked': len(torrents), 'excluded': len(excluded), 'settings': settings}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1327,22 +1327,32 @@ body.mobile-mode .mobile-card {
|
||||
.automation-history-table {
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
white-space: normal;
|
||||
}
|
||||
.automation-history-table th,
|
||||
.automation-history-table td {
|
||||
max-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
vertical-align: top;
|
||||
word-break: break-word;
|
||||
}
|
||||
.automation-history-table th:nth-child(1),
|
||||
.automation-history-table td:nth-child(1) {
|
||||
width: 9.5rem;
|
||||
width: 10.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.automation-history-table th:nth-child(2),
|
||||
.automation-history-table td:nth-child(2) {
|
||||
width: 13rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.automation-history-table th:nth-child(3),
|
||||
.automation-history-table td:nth-child(3) {
|
||||
width: 14rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.automation-history-table th:nth-child(4),
|
||||
.automation-history-table td:nth-child(4) {
|
||||
width: 42%;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
.automation-history-details {
|
||||
max-width: 100%;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user