diff --git a/pytorrent/db.py b/pytorrent/db.py index d673a5e..61060f2 100644 --- a/pytorrent/db.py +++ b/pytorrent/db.py @@ -142,6 +142,7 @@ CREATE TABLE IF NOT EXISTS smart_queue_settings ( min_speed_bytes INTEGER DEFAULT 1024, min_seeds INTEGER DEFAULT 1, min_peers INTEGER DEFAULT 0, + ignore_seed_peer INTEGER DEFAULT 0, manage_stopped INTEGER DEFAULT 0, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id) @@ -322,6 +323,7 @@ MIGRATIONS = [ "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", + "ALTER TABLE smart_queue_settings ADD COLUMN ignore_seed_peer 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 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)", diff --git a/pytorrent/services/smart_queue.py b/pytorrent/services/smart_queue.py index d0a96d7..ab33256 100644 --- a/pytorrent/services/smart_queue.py +++ b/pytorrent/services/smart_queue.py @@ -38,6 +38,7 @@ def _default_settings(user_id: int, profile_id: int) -> dict[str, Any]: 'min_speed_bytes': 1024, 'min_seeds': 1, 'min_peers': 0, + 'ignore_seed_peer': 0, 'manage_stopped': 1, 'updated_at': utcnow(), } @@ -64,14 +65,16 @@ 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), # 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: Ignore seed/peer lets long-stalled torrents wait by timer only, useful when sources appear rarely. + 'ignore_seed_peer': 1 if data.get('ignore_seed_peer', current.get('ignore_seed_peer')) else 0, # Note: Compatibility field retained; enabled Smart Queue always manages stopped torrents and never manages user-paused torrents. 'manage_stopped': 1, } 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,min_peers,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,ignore_seed_peer,manage_stopped,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(user_id, profile_id) DO UPDATE SET enabled=excluded.enabled, max_active_downloads=excluded.max_active_downloads, @@ -79,9 +82,10 @@ def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = N min_speed_bytes=excluded.min_speed_bytes, min_seeds=excluded.min_seeds, min_peers=excluded.min_peers, + ignore_seed_peer=excluded.ignore_seed_peer, 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['min_peers'], 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['manage_stopped'], now), ) return get_settings(profile_id, user_id) @@ -455,10 +459,22 @@ def _is_running_download_slot(t: dict[str, Any]) -> bool: return _is_started_download_slot(t) -def _is_low_activity_download(t: dict[str, Any], min_speed: int, min_seeds: int, min_peers: int) -> bool: +def _is_stalled_download(t: dict[str, Any], min_speed: int, min_seeds: int, min_peers: int, ignore_seed_peer: bool) -> bool: + """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)) + if ignore_seed_peer: + # Note: Optional seed/peer ignore keeps no-source torrents waiting until stalled_seconds expires. + return low_speed + 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: """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. low_speed = int(t.get('down_rate') or 0) <= max(0, int(min_speed or 0)) + if ignore_seed_peer: + # Note: With seed/peer ignore enabled, source counts never make a torrent stop earlier than the stalled timer. + 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 @@ -527,6 +543,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) + ignore_seed_peer = bool(int(settings.get('ignore_seed_peer') or 0)) stalled_seconds = int(settings.get('stalled_seconds') or 300) now = utcnow() now_ts = datetime.now(timezone.utc).timestamp() @@ -535,10 +552,10 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = with connect() as conn: for t in downloading: - # Note: Stalled detection keeps the existing conservative rule: 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) - # Note: Hard-limit enforcement may stop only inactive / no-source items, including manually started ones. - if _is_low_activity_download(t, min_speed, min_seeds, min_peers): + # Note: Stalled detection can ignore seed/peer counts and rely only on low speed plus the configured timer. + is_stalled = _is_stalled_download(t, min_speed, min_seeds, min_peers, ignore_seed_peer) + # Note: Hard-limit enforcement respects the same seed/peer ignore option before choosing weak items. + if _is_low_activity_download(t, min_speed, min_seeds, min_peers, ignore_seed_peer): stop_eligible.append(t) h = t.get('hash') if not h: @@ -665,6 +682,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(started_by_queue)} ) restored = _cleanup_auto_labels(c, profile_id, torrents, keep_labels, manage_stopped) - details = {'excluded': len(user_excluded), 'excluded_stalled': len(stalled_label_hashes), 'manual_labeled_running': len(manual_labeled_running), 'manual_labeled_running_hashes': manual_labeled_running[:100], '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, 'stop_failed': stop_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'start_results': start_results, 'start_requested': start_requested, 'active_verified': active_verified, 'waiting_labeled': len(to_label_waiting), 'manage_stopped': True, 'max_active_downloads': max_active, 'active_before': len(downloading), 'active_after_stop': active_after_stop, 'active_after_expected': active_after_stop + len(started_by_queue), 'over_limit': over_limit, 'stop_eligible': len(stop_eligible), 'healthy_active_protected': 0, 'stopped_planned': len(to_stop), 'started_planned': len(to_start), 'paused_planned': len(to_stop), 'resumed_planned': len(to_start), 'rtorrent_cap': rtorrent_cap} + details = {'excluded': len(user_excluded), 'excluded_stalled': len(stalled_label_hashes), 'manual_labeled_running': len(manual_labeled_running), 'manual_labeled_running_hashes': manual_labeled_running[:100], '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, 'stop_failed': stop_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'start_results': start_results, 'start_requested': start_requested, 'active_verified': active_verified, 'waiting_labeled': len(to_label_waiting), 'manage_stopped': True, 'max_active_downloads': max_active, 'active_before': len(downloading), 'active_after_stop': active_after_stop, 'active_after_expected': active_after_stop + len(started_by_queue), 'over_limit': over_limit, 'stop_eligible': len(stop_eligible), 'ignore_seed_peer': ignore_seed_peer, 'healthy_active_protected': 0, 'stopped_planned': len(to_stop), 'started_planned': len(to_start), 'paused_planned': len(to_stop), 'resumed_planned': len(to_start), 'rtorrent_cap': rtorrent_cap} add_history(profile_id, 'force_check' if force else 'auto_check', stopped_by_queue, started_by_queue, len(torrents), {**details, 'stopped': stopped_by_queue, 'started': started_by_queue}, user_id) - return {'ok': True, 'enabled': bool(settings.get('enabled')), 'paused': stopped_by_queue, 'resumed': started_by_queue, 'stopped': stopped_by_queue, 'started': started_by_queue, 'start_requested': start_requested, 'waiting_labeled': len(to_label_waiting), 'stalled_labeled': stalled_labeled, 'excluded_stalled': len(stalled_label_hashes), 'manual_labeled_running': len(manual_labeled_running), 'labels_restored': restored, 'labels_failed': label_failed, 'stop_failed': stop_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'active_verified': active_verified, 'active_before': len(downloading), 'active_after_stop': active_after_stop, 'over_limit': over_limit, 'stop_eligible': len(stop_eligible), 'healthy_active_protected': 0, 'rtorrent_cap': rtorrent_cap, 'checked': len(torrents), 'excluded': len(user_excluded), 'settings': settings} + return {'ok': True, 'enabled': bool(settings.get('enabled')), 'paused': stopped_by_queue, 'resumed': started_by_queue, 'stopped': stopped_by_queue, 'started': started_by_queue, 'start_requested': start_requested, 'waiting_labeled': len(to_label_waiting), 'stalled_labeled': stalled_labeled, 'excluded_stalled': len(stalled_label_hashes), 'manual_labeled_running': len(manual_labeled_running), 'labels_restored': restored, 'labels_failed': label_failed, 'stop_failed': stop_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'active_verified': active_verified, 'active_before': len(downloading), 'active_after_stop': active_after_stop, 'over_limit': over_limit, 'stop_eligible': len(stop_eligible), 'ignore_seed_peer': ignore_seed_peer, 'healthy_active_protected': 0, 'rtorrent_cap': rtorrent_cap, 'checked': len(torrents), 'excluded': len(user_excluded), 'settings': settings} diff --git a/pytorrent/static/app.js b/pytorrent/static/app.js index 6e7c81f..7269c33 100644 --- a/pytorrent/static/app.js +++ b/pytorrent/static/app.js @@ -558,6 +558,7 @@ if($('smartMinSpeed')) $('smartMinSpeed').value=Math.round((st.min_speed_bytes||0)/1024); if($('smartMinSeeds')) $('smartMinSeeds').value=st.min_seeds||1; if($('smartMinPeers')) $('smartMinPeers').value=st.min_peers||0; + if($('smartIgnoreSeedPeer')) $('smartIgnoreSeedPeer').checked=!!st.ignore_seed_peer; if($('smartManager')){ $('smartManager').innerHTML=ex.length ? responsiveTable(['Hash','Reason','Created','Action'],ex.map(x=>[esc(x.torrent_hash),esc(x.reason||''),dateCell(x.created_at),``]),'smart-exclusions-table') @@ -573,7 +574,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 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}); 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}); toast('Smart Queue saved','success'); await loadSmartQueue(); } async function loadAuthUsers(){ if(!window.PYTORRENT.authEnabled || !$('authUsersManager')) return; diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index 07ba72b..567e232 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -151,7 +151,7 @@