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