queue_stopped #3
@@ -143,6 +143,7 @@ CREATE TABLE IF NOT EXISTS smart_queue_settings (
|
|||||||
min_seeds INTEGER DEFAULT 1,
|
min_seeds INTEGER DEFAULT 1,
|
||||||
min_peers INTEGER DEFAULT 0,
|
min_peers INTEGER DEFAULT 0,
|
||||||
ignore_seed_peer INTEGER DEFAULT 0,
|
ignore_seed_peer INTEGER DEFAULT 0,
|
||||||
|
ignore_speed INTEGER DEFAULT 0,
|
||||||
manage_stopped INTEGER DEFAULT 0,
|
manage_stopped INTEGER DEFAULT 0,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
PRIMARY KEY(user_id, profile_id)
|
PRIMARY KEY(user_id, profile_id)
|
||||||
@@ -324,6 +325,7 @@ MIGRATIONS = [
|
|||||||
"ALTER TABLE smart_queue_settings ADD COLUMN manage_stopped INTEGER 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",
|
"ALTER TABLE smart_queue_settings ADD COLUMN min_peers INTEGER DEFAULT 0",
|
||||||
"ALTER TABLE smart_queue_settings ADD COLUMN ignore_seed_peer INTEGER DEFAULT 0",
|
"ALTER TABLE smart_queue_settings ADD COLUMN ignore_seed_peer INTEGER DEFAULT 0",
|
||||||
|
"ALTER TABLE smart_queue_settings ADD COLUMN ignore_speed INTEGER DEFAULT 0",
|
||||||
"CREATE TABLE IF NOT EXISTS tracker_summary_cache (profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, trackers_json TEXT NOT NULL, updated_at TEXT NOT NULL, updated_epoch REAL DEFAULT 0, PRIMARY KEY(profile_id, torrent_hash))",
|
"CREATE TABLE IF NOT EXISTS tracker_summary_cache (profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, trackers_json TEXT NOT NULL, updated_at TEXT NOT NULL, updated_epoch REAL DEFAULT 0, PRIMARY KEY(profile_id, torrent_hash))",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_tracker_summary_cache_profile ON tracker_summary_cache(profile_id, updated_epoch)",
|
"CREATE INDEX IF NOT EXISTS idx_tracker_summary_cache_profile ON tracker_summary_cache(profile_id, updated_epoch)",
|
||||||
"CREATE TABLE IF NOT EXISTS tracker_favicon_cache (domain TEXT PRIMARY KEY, source_url TEXT, file_path TEXT, mime_type TEXT, updated_at TEXT NOT NULL, updated_epoch REAL DEFAULT 0, error TEXT)",
|
"CREATE TABLE IF NOT EXISTS tracker_favicon_cache (domain TEXT PRIMARY KEY, source_url TEXT, file_path TEXT, mime_type TEXT, updated_at TEXT NOT NULL, updated_epoch REAL DEFAULT 0, error TEXT)",
|
||||||
|
|||||||
@@ -117,7 +117,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/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/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/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"}, "min_peers": {"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"}, "ignore_seed_peer": {"type": "boolean"}, "ignore_speed": {"type": "boolean"}}}}}}, "responses": {"200": {"description": "Saved"}}}},
|
||||||
"/api/smart-queue/check": {"post": {"summary": "Run Smart Queue immediately", "responses": {"200": {"description": "Smart Queue action result"}}}},
|
"/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/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"}}}}
|
"/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"}}}}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ def _default_settings(user_id: int, profile_id: int) -> dict[str, Any]:
|
|||||||
'min_seeds': 1,
|
'min_seeds': 1,
|
||||||
'min_peers': 0,
|
'min_peers': 0,
|
||||||
'ignore_seed_peer': 0,
|
'ignore_seed_peer': 0,
|
||||||
|
'ignore_speed': 0,
|
||||||
'manage_stopped': 1,
|
'manage_stopped': 1,
|
||||||
'updated_at': utcnow(),
|
'updated_at': utcnow(),
|
||||||
}
|
}
|
||||||
@@ -65,16 +66,18 @@ def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = N
|
|||||||
'min_seeds': _int_setting(data, current, 'min_seeds', 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.
|
# 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),
|
'min_peers': _int_setting(data, current, 'min_peers', 0, 0),
|
||||||
# Note: Ignore seed/peer lets long-stalled torrents wait by timer only, useful when sources appear rarely.
|
# Note: Ignore seed/peer removes source counts from stalled detection, useful when sources appear rarely.
|
||||||
'ignore_seed_peer': 1 if data.get('ignore_seed_peer', current.get('ignore_seed_peer')) else 0,
|
'ignore_seed_peer': 1 if data.get('ignore_seed_peer', current.get('ignore_seed_peer')) else 0,
|
||||||
|
# Note: Ignore speed removes low transfer rate from stalled detection; with both ignores enabled only stalled_seconds matters.
|
||||||
|
'ignore_speed': 1 if data.get('ignore_speed', current.get('ignore_speed')) else 0,
|
||||||
# Note: Compatibility field retained; enabled Smart Queue always manages stopped torrents and never manages user-paused torrents.
|
# Note: Compatibility field retained; enabled Smart Queue always manages stopped torrents and never manages user-paused torrents.
|
||||||
'manage_stopped': 1,
|
'manage_stopped': 1,
|
||||||
}
|
}
|
||||||
now = utcnow()
|
now = utcnow()
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
'''INSERT INTO smart_queue_settings(user_id,profile_id,enabled,max_active_downloads,stalled_seconds,min_speed_bytes,min_seeds,min_peers,ignore_seed_peer,manage_stopped,updated_at)
|
'''INSERT INTO smart_queue_settings(user_id,profile_id,enabled,max_active_downloads,stalled_seconds,min_speed_bytes,min_seeds,min_peers,ignore_seed_peer,ignore_speed,manage_stopped,updated_at)
|
||||||
VALUES(?,?,?,?,?,?,?,?,?,?,?)
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
ON CONFLICT(user_id, profile_id) DO UPDATE SET
|
ON CONFLICT(user_id, profile_id) DO UPDATE SET
|
||||||
enabled=excluded.enabled,
|
enabled=excluded.enabled,
|
||||||
max_active_downloads=excluded.max_active_downloads,
|
max_active_downloads=excluded.max_active_downloads,
|
||||||
@@ -83,9 +86,10 @@ def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = N
|
|||||||
min_seeds=excluded.min_seeds,
|
min_seeds=excluded.min_seeds,
|
||||||
min_peers=excluded.min_peers,
|
min_peers=excluded.min_peers,
|
||||||
ignore_seed_peer=excluded.ignore_seed_peer,
|
ignore_seed_peer=excluded.ignore_seed_peer,
|
||||||
|
ignore_speed=excluded.ignore_speed,
|
||||||
manage_stopped=excluded.manage_stopped,
|
manage_stopped=excluded.manage_stopped,
|
||||||
updated_at=excluded.updated_at''',
|
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['min_peers'], settings['ignore_seed_peer'], 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['ignore_seed_peer'], settings['ignore_speed'], settings['manage_stopped'], now),
|
||||||
)
|
)
|
||||||
return get_settings(profile_id, user_id)
|
return get_settings(profile_id, user_id)
|
||||||
|
|
||||||
@@ -459,24 +463,20 @@ def _is_running_download_slot(t: dict[str, Any]) -> bool:
|
|||||||
return _is_started_download_slot(t)
|
return _is_started_download_slot(t)
|
||||||
|
|
||||||
|
|
||||||
def _is_stalled_download(t: dict[str, Any], min_speed: int, min_seeds: int, min_peers: int, ignore_seed_peer: bool) -> bool:
|
def _is_stalled_download(t: dict[str, Any], min_speed: int, min_seeds: int, min_peers: int, ignore_seed_peer: bool, ignore_speed: bool) -> bool:
|
||||||
"""Return True when a started torrent should begin or continue the stalled timer."""
|
"""Return True when a started torrent should begin or continue the stalled timer."""
|
||||||
low_speed = int(t.get('down_rate') or 0) <= max(0, int(min_speed or 0))
|
# Note: Each ignore switch disables one stalled criterion; when both are enabled, only stalled_seconds matters.
|
||||||
if ignore_seed_peer:
|
speed_ok = True if ignore_speed else int(t.get('down_rate') or 0) <= max(0, int(min_speed or 0))
|
||||||
# Note: Optional seed/peer ignore keeps no-source torrents waiting until stalled_seconds expires.
|
source_ok = True if ignore_seed_peer else int(t.get('seeds') or 0) <= max(0, int(min_seeds or 0)) and (min_peers <= 0 or int(t.get('peers') or 0) <= min_peers)
|
||||||
return low_speed
|
return speed_ok and source_ok
|
||||||
return low_speed and int(t.get('seeds') or 0) <= max(0, int(min_seeds or 0)) and (min_peers <= 0 or int(t.get('peers') or 0) <= min_peers)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_low_activity_download(t: dict[str, Any], min_speed: int, min_seeds: int, min_peers: int, ignore_seed_peer: bool = False) -> bool:
|
def _is_low_activity_download(t: dict[str, Any], min_speed: int, min_seeds: int, min_peers: int, ignore_seed_peer: bool = False, ignore_speed: bool = False) -> bool:
|
||||||
"""Return True when a started torrent is weak and should be stopped first."""
|
"""Return True when a started torrent is weak and should be stopped first."""
|
||||||
# Note: These settings define stop priority; the hard queue cap still applies to the full started queue.
|
# Note: Stop priority uses only criteria that are not ignored, so disabled criteria cannot stop torrents earlier.
|
||||||
low_speed = int(t.get('down_rate') or 0) <= max(0, int(min_speed or 0))
|
low_speed = False if ignore_speed else int(t.get('down_rate') or 0) <= max(0, int(min_speed or 0))
|
||||||
if ignore_seed_peer:
|
low_seeds = False if ignore_seed_peer else int(t.get('seeds') or 0) <= max(0, int(min_seeds or 0))
|
||||||
# Note: With seed/peer ignore enabled, source counts never make a torrent stop earlier than the stalled timer.
|
low_peers = False if ignore_seed_peer or min_peers <= 0 else int(t.get('peers') or 0) <= max(0, int(min_peers or 0))
|
||||||
return low_speed
|
|
||||||
low_seeds = int(t.get('seeds') or 0) <= max(0, int(min_seeds or 0))
|
|
||||||
low_peers = int(t.get('peers') or 0) <= max(0, int(min_peers or 0)) if min_peers > 0 else False
|
|
||||||
return low_speed or low_seeds or low_peers
|
return low_speed or low_seeds or low_peers
|
||||||
|
|
||||||
|
|
||||||
@@ -544,6 +544,7 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
|
|||||||
min_seeds = int(settings.get('min_seeds') or 0)
|
min_seeds = int(settings.get('min_seeds') or 0)
|
||||||
min_peers = int(settings.get('min_peers') or 0)
|
min_peers = int(settings.get('min_peers') or 0)
|
||||||
ignore_seed_peer = bool(int(settings.get('ignore_seed_peer') or 0))
|
ignore_seed_peer = bool(int(settings.get('ignore_seed_peer') or 0))
|
||||||
|
ignore_speed = bool(int(settings.get('ignore_speed') or 0))
|
||||||
stalled_seconds = int(settings.get('stalled_seconds') or 300)
|
stalled_seconds = int(settings.get('stalled_seconds') or 300)
|
||||||
now = utcnow()
|
now = utcnow()
|
||||||
now_ts = datetime.now(timezone.utc).timestamp()
|
now_ts = datetime.now(timezone.utc).timestamp()
|
||||||
@@ -552,10 +553,10 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
|
|||||||
|
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
for t in downloading:
|
for t in downloading:
|
||||||
# Note: Stalled detection can ignore seed/peer counts and rely only on low speed plus the configured timer.
|
# Note: Stalled detection respects seed/peer and speed ignore switches before starting the timer.
|
||||||
is_stalled = _is_stalled_download(t, min_speed, min_seeds, min_peers, ignore_seed_peer)
|
is_stalled = _is_stalled_download(t, min_speed, min_seeds, min_peers, ignore_seed_peer, ignore_speed)
|
||||||
# Note: Hard-limit enforcement respects the same seed/peer ignore option before choosing weak items.
|
# Note: Hard-limit enforcement respects the same ignore switches before choosing weak items.
|
||||||
if _is_low_activity_download(t, min_speed, min_seeds, min_peers, ignore_seed_peer):
|
if _is_low_activity_download(t, min_speed, min_seeds, min_peers, ignore_seed_peer, ignore_speed):
|
||||||
stop_eligible.append(t)
|
stop_eligible.append(t)
|
||||||
h = t.get('hash')
|
h = t.get('hash')
|
||||||
if not h:
|
if not h:
|
||||||
|
|||||||
@@ -559,6 +559,7 @@
|
|||||||
if($('smartMinSeeds')) $('smartMinSeeds').value=st.min_seeds||1;
|
if($('smartMinSeeds')) $('smartMinSeeds').value=st.min_seeds||1;
|
||||||
if($('smartMinPeers')) $('smartMinPeers').value=st.min_peers||0;
|
if($('smartMinPeers')) $('smartMinPeers').value=st.min_peers||0;
|
||||||
if($('smartIgnoreSeedPeer')) $('smartIgnoreSeedPeer').checked=!!st.ignore_seed_peer;
|
if($('smartIgnoreSeedPeer')) $('smartIgnoreSeedPeer').checked=!!st.ignore_seed_peer;
|
||||||
|
if($('smartIgnoreSpeed')) $('smartIgnoreSpeed').checked=!!st.ignore_speed;
|
||||||
if($('smartManager')){
|
if($('smartManager')){
|
||||||
$('smartManager').innerHTML=ex.length
|
$('smartManager').innerHTML=ex.length
|
||||||
? responsiveTable(['Hash','Reason','Created','Action'],ex.map(x=>[esc(x.torrent_hash),esc(x.reason||''),dateCell(x.created_at),`<button class="btn btn-xs btn-outline-danger smart-unexclude" data-hash="${esc(x.torrent_hash)}"><i class="fa-solid fa-xmark"></i> remove exception</button>`]),'smart-exclusions-table')
|
? responsiveTable(['Hash','Reason','Created','Action'],ex.map(x=>[esc(x.torrent_hash),esc(x.reason||''),dateCell(x.created_at),`<button class="btn btn-xs btn-outline-danger smart-unexclude" data-hash="${esc(x.torrent_hash)}"><i class="fa-solid fa-xmark"></i> remove exception</button>`]),'smart-exclusions-table')
|
||||||
@@ -574,7 +575,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function setSmartException(hashes, excluded, reason='manual'){ const list=[...new Set(hashes||[])].filter(Boolean); if(!list.length) return toast('No torrents selected','warning'); setBusy(true); try{ for(const h of list) await post('/api/smart-queue/exclusion',{hash:h,excluded,reason}); toast(excluded?'Smart Queue exception added':'Smart Queue exception removed','success'); await loadSmartQueue(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }
|
async function setSmartException(hashes, excluded, reason='manual'){ const list=[...new Set(hashes||[])].filter(Boolean); if(!list.length) return toast('No torrents selected','warning'); setBusy(true); try{ for(const h of list) await post('/api/smart-queue/exclusion',{hash:h,excluded,reason}); toast(excluded?'Smart Queue exception added':'Smart Queue exception removed','success'); await loadSmartQueue(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }
|
||||||
async function saveSmartQueue(){ await post('/api/smart-queue',{enabled:$('smartEnabled')?.checked,max_active_downloads:$('smartMaxActive')?.value,stalled_seconds:$('smartStalled')?.value,min_speed_bytes:Math.round(Number($('smartMinSpeed')?.value||0)*1024),min_seeds:$('smartMinSeeds')?.value,min_peers:$('smartMinPeers')?.value,ignore_seed_peer:$('smartIgnoreSeedPeer')?.checked}); toast('Smart Queue saved','success'); await loadSmartQueue(); }
|
async function saveSmartQueue(){ await post('/api/smart-queue',{enabled:$('smartEnabled')?.checked,max_active_downloads:$('smartMaxActive')?.value,stalled_seconds:$('smartStalled')?.value,min_speed_bytes:Math.round(Number($('smartMinSpeed')?.value||0)*1024),min_seeds:$('smartMinSeeds')?.value,min_peers:$('smartMinPeers')?.value,ignore_seed_peer:$('smartIgnoreSeedPeer')?.checked,ignore_speed:$('smartIgnoreSpeed')?.checked}); toast('Smart Queue saved','success'); await loadSmartQueue(); }
|
||||||
|
|
||||||
async function loadAuthUsers(){
|
async function loadAuthUsers(){
|
||||||
if(!window.PYTORRENT.authEnabled || !$('authUsersManager')) return;
|
if(!window.PYTORRENT.authEnabled || !$('authUsersManager')) return;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user