1from datetime import datetime, timedelta 2 3from loguru import logger 4 5from flexget import plugin 6from flexget.event import event 7from flexget.manager import Session 8from flexget.utils.tools import parse_timedelta 9 10from . import db 11 12SCHEMA_VER = 3 13FAIL_LIMIT = 100 14 15logger = logger.bind(name='failed') 16 17 18class PluginFailed: 19 """ 20 Records entry failures and stores them for trying again after a certain interval. 21 Rejects them after they have failed too many times. 22 23 """ 24 25 schema = { 26 "oneOf": [ 27 # Allow retry_failed: no form to turn off plugin altogether 28 {"type": "boolean"}, 29 { 30 "type": "object", 31 "properties": { 32 "retry_time": {"type": "string", "format": "interval", "default": "1 hour"}, 33 "max_retries": { 34 "type": "integer", 35 "minimum": 0, 36 "maximum": FAIL_LIMIT, 37 "default": 3, 38 }, 39 "retry_time_multiplier": { 40 # Allow turning off the retry multiplier with 'no' as well as 1 41 "oneOf": [{"type": "number", "minimum": 0}, {"type": "boolean"}], 42 "default": 1.5, 43 }, 44 }, 45 "additionalProperties": False, 46 }, 47 ] 48 } 49 50 def prepare_config(self, config): 51 if not isinstance(config, dict): 52 config = {} 53 config.setdefault('retry_time', '1 hour') 54 config.setdefault('max_retries', 3) 55 if config.get('retry_time_multiplier', True) is True: 56 # If multiplier is not specified, or is specified as True, use the default 57 config['retry_time_multiplier'] = 1.5 58 else: 59 # If multiplier is False, turn it off 60 config['retry_time_multiplier'] = 1 61 return config 62 63 def retry_time(self, fail_count, config): 64 """Return the timedelta an entry that has failed `fail_count` times before should wait before being retried.""" 65 base_retry_time = parse_timedelta(config['retry_time']) 66 # Timedeltas do not allow floating point multiplication. Convert to seconds and then back to avoid this. 67 base_retry_secs = base_retry_time.days * 86400 + base_retry_time.seconds 68 retry_secs = base_retry_secs * (config['retry_time_multiplier'] ** fail_count) 69 # prevent OverflowError: date value out of range, cap to 30 days 70 max = 60 * 60 * 24 * 30 71 if retry_secs > max: 72 retry_secs = max 73 return timedelta(seconds=retry_secs) 74 75 @plugin.priority(plugin.PRIORITY_LAST) 76 def on_task_input(self, task, config): 77 if config is False: 78 return 79 config = self.prepare_config(config) 80 for entry in task.all_entries: 81 entry.on_fail(self.add_failed, config=config) 82 83 def add_failed(self, entry, reason=None, config=None, **kwargs): 84 """Adds entry to internal failed list, displayed with --failed""" 85 # Make sure reason is a string, in case it is set to an exception instance 86 reason = str(reason) or 'Unknown' 87 with Session() as session: 88 # query item's existence 89 item = ( 90 session.query(db.FailedEntry) 91 .filter(db.FailedEntry.title == entry['title']) 92 .filter(db.FailedEntry.url == entry['original_url']) 93 .first() 94 ) 95 if not item: 96 item = db.FailedEntry(entry['title'], entry['original_url'], reason) 97 item.count = 0 98 if item.count > FAIL_LIMIT: 99 logger.error( 100 "entry with title '{}' has failed over {} times", entry['title'], FAIL_LIMIT 101 ) 102 return 103 retry_time = self.retry_time(item.count, config) 104 item.retry_time = datetime.now() + retry_time 105 item.count += 1 106 item.tof = datetime.now() 107 item.reason = reason 108 session.merge(item) 109 logger.debug('Marking {} in failed list. Has failed {} times.', item.title, item.count) 110 if item.count <= config['max_retries']: 111 plugin.get('backlog', self).add_backlog( 112 entry.task, entry, amount=retry_time, session=session 113 ) 114 entry.task.rerun(plugin='retry_failed') 115 116 @plugin.priority(plugin.PRIORITY_FIRST) 117 def on_task_filter(self, task, config): 118 if config is False: 119 return 120 config = self.prepare_config(config) 121 max_count = config['max_retries'] 122 for entry in task.entries: 123 item = ( 124 task.session.query(db.FailedEntry) 125 .filter(db.FailedEntry.title == entry['title']) 126 .filter(db.FailedEntry.url == entry['original_url']) 127 .first() 128 ) 129 if item: 130 if item.count > max_count: 131 entry.reject( 132 'Has already failed %s times in the past. (failure reason: %s)' 133 % (item.count, item.reason) 134 ) 135 elif item.retry_time and item.retry_time > datetime.now(): 136 entry.reject( 137 'Waiting before retrying entry which has failed in the past. (failure reason: %s)' 138 % item.reason 139 ) 140 141 142@event('plugin.register') 143def register_plugin(): 144 plugin.register(PluginFailed, 'retry_failed', builtin=True, api_ver=2) 145