<gh_stars>0
# -*- coding: UTF-8 -*-
""" Base class for tv checks """

__author__ = "d01"
__copyright__ = "Copyright (C) 2014-20, <NAME>"
__license__ = "MIT"
__version__ = "0.6.8"
__date__ = "2020-05-11"
# Created: 2014-05-18 04:08

import abc
from collections import OrderedDict
import datetime
from datetime import timedelta
import os
import shutil
import threading
import time
import typing

import flotils
import pytz


logger = flotils.get_logger()


class TVNextException(Exception):
    """ Base exception for project """

    pass


class TVNextFatalException(Exception):
    """ Exception that can not be recovered from """

    pass


class TVNextNotFoundException(Exception):
    """ Show not found """

    pass


class JSONDecoder(flotils.loadable.DateTimeDecoder):
    """ Extend DateTimeDecoder with tzinfo field """

    @staticmethod
    def _as_tzinfo(dct):
        if "__pytzinfo__" in dct.keys():
            return pytz.timezone(dct['__pytzinfo__'])
        raise TypeError("Not pytzinfo")

    @staticmethod
    def decode(dct: typing.Any) -> typing.Any:
        """ Decode object """
        try:
            if isinstance(dct, dict):
                return JSONDecoder._as_tzinfo(dct)
        except Exception:
            pass
        res = super(JSONDecoder, JSONDecoder).decode(dct)
        if isinstance(res, datetime.datetime):
            res = res.replace(tzinfo=pytz.UTC)
        return res


class JSONEncoder(flotils.loadable.DateTimeEncoder):
    """ Extend DateTimeEncoder with tzinfo field """

    def default(self, obj: typing.Any) -> typing.Any:
        """ Encode object """
        if isinstance(obj, datetime.tzinfo):
            return obj.zone
        else:
            return super(JSONEncoder, self).default(obj)


class TVNext(flotils.Loadable, flotils.StartStopable, abc.ABC):
    """ Abstract checker for new tv episodes """

    def __init__(
            self,
            settings: typing.Optional[typing.Dict[str, typing.Any]] = None
    ) -> None:
        """
        Initialise instance

        :param settings: Settings
        """
        if settings is None:
            settings = {}
        super().__init__(settings)

        self._shows = OrderedDict(settings.get('shows', {}))
        """ Show information
            :type : dict[unicode, dict[unicode, bool | unicode | \
            datetime.time | datetime.datetime | int | dict[unicode, dict[\
            unicode, unicode | datetime.datetime]]] """
        self._cache_path = settings.get('cache_path', None)
        """ Path to cache directory (default: None)
            :type _cache_path: None | str """
        self._cache_file = settings.get('cache_file', "sallie.tmp.json")
        """ Cache information file on shows (default: sallie.tmp.json)
            :type : str """
        self._show_file = settings.get('show_file', None)
        """ File of show names (default: None)
            :type : None | str """

        self._access_interval = settings.get('update_interval', 7)
        """ Refresh data every x days for active shows (default: 1)
            :type : int """
        self._access_interval_hiatus = settings.get(
            'update_interval_hiatus', None
        )
        """ Refresh interval for shows marked as hiatus in days (default: None)
        :type : None | int """
        self._access_interval_inactive = settings.get(
            'update_interval_inactive', None
        )
        """ Refresh interval for shows marked as inactive in days (default: None)
        :type : None | int """
        self._access_interval_aired = settings.get(
            'update_interval_aired', None
        )
        """ Refresh interval for shows after a new episode aired within \
            the last x days (default: None)
            :type : None | int """
        self._access_interval_error = settings.get(
            'update_interval_error', 10
        )
        """ Refresh interval for shows with error loading (min) (default: 10)
        :type : int """

        self._timezone = settings.get('timezone_default', "US/Pacific")
        """ Timezone to be used if not provided for show
            :type : datetime.timezone """

        if isinstance(self._timezone, str):
            # If given as name -> convert to timezone with pytz
            self._timezone = pytz.timezone(self._timezone)

        self._error_sleep_time = settings.get('update_retry_delay', 2.0)
        """ Time to sleep after error
            :type : float """
        self._max_retries = settings.get('update_retry_num', 3)
        """ Maximum number of retries on load failure
            :type : int """
        self._should_retry = settings.get('update_retry', True)
        """ Should be retried
            :type : bool """

        if self._cache_path:
            self._cache_path = self.join_path_prefix(self._cache_path)

            if self._cache_file:
                self._cache_file = os.path.join(
                    self._cache_path, self._cache_file
                )

        self._lock_update = threading.RLock()
        """ Lock to prevent concurrent access
            :type : threading.RLock """

    def start(self, blocking: bool = False) -> None:
        """
        Start the interface

        :param blocking: Should the call block until stop() is called
            (default: False)
        """
        if self._cache_file and os.path.exists(self._cache_file):
            shows = self._load_json_file(
                self._cache_file, decoder=JSONDecoder
            )
            shows.update(self._shows)
            self._shows.update(shows)
        # Don't care whether cache file exitsts -> generate it
        # else:
        #    raise IOError("File '{}' not found".format(self._showPath))
        if self._show_file:
            self.show_name_file_load()
        super(TVNext, self).start(blocking)

    def stop(self) -> None:
        """ Stop the interface """
        self.debug("()")
        super(TVNext, self).stop()
        # self.show_save_all()

    def show_name_file_load(self) -> None:
        """
        Load file containing show names

        :raises IOError: File not found
        :raises IOError: Failed to load
        """
        self._show_file = self.join_path_prefix(self._show_file)

        if os.path.exists(self._show_file):
            show_names: typing.List[
                typing.Union[str, typing.Tuple[str, str]]
            ] = self._load_json_file(
                self._show_file, decoder=JSONDecoder
            )
            """ :type : list[str | (str, str)] """

            for name in show_names:
                timezone = self._timezone
                if isinstance(name, list):
                    name, timezone = name
                if isinstance(timezone, str):
                    timezone = pytz.timezone(timezone)
                self._shows.setdefault(name, {})
                self._shows[name]['air_timezone'] = timezone
        else:
            raise IOError("File '{}' not found".format(self._show_file))
        self.debug("Loaded show names from {}".format(self._show_file))

    def show_add(self, show: str, timezone: typing.Optional = None) -> None:
        """
        Add new show with default params

        :param show: Name of show
        :param timezone: Timezone to add - None means no timezone
            (Default: None)
        """
        if isinstance(timezone, str):
            timezone = pytz.timezone(timezone)
        with self._lock_update:
            self._shows.setdefault(show, {})
            if timezone:
                self._shows[show]['air_timezone'] = timezone

    def show_remove(self, show: str) -> None:
        """
        Remove show

        :param show: Name of show
        """
        with self._lock_update:
            if show in self._shows:
                del self._shows[show]

    @property
    def shows(self) -> typing.Dict[str, typing.Dict[str, typing.Union[
        str, bool, datetime.time, datetime.datetime, int,
        typing.Dict[
            str, typing.Dict[str, typing.Union[str, datetime.datetime]]
        ]
    ]]]:
        """
        Show information

        :return: Shows being watched watching
        """
        return self._shows

    def show_save_all(self, path: typing.Optional[str] = None) -> None:
        """
        Save shows cache

        :param path: Path to save to (default: None)
            if None -> use cache_file
        """
        if not path:
            path = self._cache_file
        temp_path = path + "temp.json"
        try:
            self._save_json_file(
                temp_path,
                self._shows,
                pretty=True,
                sort=True,
                encoder=JSONEncoder
            )
            shutil.move(temp_path, path)
            self.info("Saved shows to {}".format(path))
        except Exception:
            self.exception("Failed to save shows to {}".format(path))

    def show_episodes_flatten(
            self, key: str, show: typing.Optional = None
    ) -> typing.List[typing.Dict[str, typing.Union[str, datetime.datetime]]]:
        """
        Get a (flat) list of this shows episodes

        :param key: Show key
        :param show: Show information (default: None)
            if None -> use key to retrieve show information
        :type show: dict[unicode, bool | unicode | datetime.time | \
            datetime.datetime | int | dict[unicode, dict[\
            unicode, unicode | datetime.datetime]]
        :return: Episode list
        """
        if not show:
            show = self._shows[key]
        eps = show['episodes']
        return [eps[s_nr][e_nr] for s_nr in eps for e_nr in eps[s_nr]]

    def show_update_should(self, key: str, force_check: bool = False) -> bool:
        """
        Decide if show needs updating

        :param key: Show key
        :param force_check: Force checking
        :return: Should show be updated
        """
        show = self._shows[key]
        now = pytz.utc.localize(datetime.datetime.utcnow())

        # set defaults
        show.setdefault('active', True)
        show.setdefault('hiatus', False)
        show.setdefault('episodes', {})
        accessed = show.get('accessed', None)
        episodes = self.show_episodes_flatten(key, show)

        diff = None
        """ :type : datetime.timedelta """

        if accessed:
            diff = now - accessed
        else:
            self.info("Never accessed {}".format(key))
            return True
        # Update if errors last time
        if show.get('errors', 0) >= 3 and \
                (diff.days >= self._access_interval_error or force_check):
            self.info("Error retry {}".format(key))
            # Only retry once
            show['errors'] = max(1, self._max_retries - 1)
            return True

        if force_check:
            return True
        if self._access_interval_aired is not None:
            # Update after new episode aired (in last x days)
            # Reverse list (newest first)
            for ep in episodes[::-1]:
                aired = ep['aired']
                """ :type : datetime.datetime """
                if not aired:
                    # Skip with no air dates
                    continue
                # TODO: make sure tzinfo is correctly loaded
                if aired.tzinfo is None:
                    self.warning("Unset tzinfo in {} ({})".format(
                        key, ep['id']
                    ))
                    aired = pytz.utc.localize(aired)
                if aired > now:
                    # Skip not aired yet
                    continue
                if (aired - accessed).days >= self._access_interval_aired:
                    # Last access before aired
                    self.debug("Aired {} ago {}".format(now - aired, key))
                    return True
                else:
                    # Out of reach
                    break
        if not show['active']:
            # Update inactive shows every so often in case they were mislabeled
            if diff is not None and self._access_interval_inactive:
                # self.debug("Inactive {} ago {}".format(diff, key))
                return diff.days >= self._access_interval_inactive
            # self.debug("{} inactive".format(key))
            return False
        if show['hiatus']:
            # Update shows on hiatus every so often in case episodes got added
            if diff is not None and self._access_interval_hiatus:
                # self.debug("Hiatus {} ago {}".format(diff, key))
                return diff.days >= self._access_interval_hiatus
            # self.debug("{} hiatus".format(key))
            return False

        # self.debug("Last access {} ago {}".format(diff, key))
        # Use generic access interval
        return diff.days >= self._access_interval

    def show_update(
            self, key: str, force_check: bool = False, auto_save: bool = False
    ) -> bool:
        """
        Update show (and adds it if not already present)

        :param key: Show name
        :param force_check: Force information load (default: False)
        :param auto_save: Save after update (default: False)
        :return: Changed
        """
        if not self.show_update_should(key, force_check):
            # Show information already up to date
            return False
        changed = False
        retry = True
        error_sleep = self._error_sleep_time
        self.shows[key].setdefault('errors', 0)

        while retry \
                and self.shows[key]['errors'] < self._max_retries:
            retry = False

            # TODO: move into implementation (Backoff exception?) - fatal
            try:
                changed = self._show_update(key)
            except TVNextNotFoundException:
                # Show not found
                self.shows[key]['errors'] = self._max_retries
                self.shows[key]['accessed'] = pytz.utc.localize(
                    datetime.datetime.utcnow()
                )
                changed = True
                break
            except Exception as e:
                if "connection reset by peer" in "{}".format(e).lower():
                    # Connection reset by peer -> exponential backoff
                    self.warning(
                        "Connection reset by peer (Sleeping {})".format(
                            error_sleep
                        )
                    )
                    time.sleep(error_sleep)
                    error_sleep *= 2.0
                else:
                    self.exception("Failed to load {}".format(key))
                self.shows[key]['errors'] += 1
                retry = self._should_retry
                changed = True

        if auto_save and changed:
            self.show_save_all()
        return changed

    @abc.abstractmethod
    def _show_update(self, key: str) -> bool:
        """
        Update show (and adds it if not already present)

        Overwrite to implement

        :param key: Show name
        :return: Changed
        """
        raise NotImplementedError

    def show_update_all(
            self, force_check: bool = False, auto_save: bool = False,
    ) -> None:
        """
        Update information for all shows

        :param force_check: Force information load (default: False)
        :param auto_save: Save after each update (default: False)
        """
        shows = self._shows
        changed = False
        for key in sorted(shows):
            changed = self.show_update(key, force_check, auto_save) or changed
        if not auto_save and changed:
            self.show_save_all()

    def check(
            self,
            key: str,
            force_check: bool = False,
            delta_min: typing.Optional[typing.Union[
                int, datetime.date, datetime.timedelta
            ]] = None,
            delta_max: typing.Optional[typing.Union[
                int, datetime.date, datetime.timedelta
            ]] = None,
            auto_save: bool = False,
    ) -> typing.List[typing.Tuple[str, typing.Dict]]:
        """
        Check a single show for updates in a period
        (adds it if not already present and updates info)

        :param key: Show to check
        :param force_check:  Force information load (default: False)
        :param delta_min: Time in the past (either date or delta from now)
        :param delta_max: Time in the future (either date or delta from now)
        :param auto_save: Save after each update (default: False)
        :return: Show, episode pair matching the query
        """
        if not self.is_running:
            self.warning("Not running")
        # Delta as ints = days
        if isinstance(delta_min, int):
            delta_min = timedelta(days=delta_min)
        if isinstance(delta_max, int):
            delta_max = timedelta(days=delta_max)
        # Delta defaults
        if delta_min is None:
            delta_min = timedelta()
        if delta_max is None:
            delta_max = timedelta()

        now = pytz.utc.localize(datetime.datetime.utcnow()).date()
        if isinstance(delta_min, datetime.date):
            # Delta as date
            day_min = delta_min
        else:
            # Delta as timedelta
            day_min = now - delta_min
        if isinstance(delta_max, datetime.date):
            # Delta as date
            day_max = delta_max
        else:
            # Delta as timedelta
            day_max = now + delta_max

        # Wrong order
        if day_min > day_max:
            day_min, day_max = day_max, day_min

        with self._lock_update:
            if key not in self.shows:
                # Add missing
                self.show_add(key)
            # Update info
            self.show_update(key, force_check, auto_save)

            # self.info("Searching between {} and {}".format(day_min, day_max))
            show = self.shows[key]
            episodes = self.show_episodes_flatten(key, show)
            results = []

            # More likely that recent epsiode selected -> backwards
            for ep in episodes[::-1]:
                d = ep['aired']
                if not d:
                    # self.debug(u"No aired: {}.{}".format(key, ep['id']))
                    continue
                d = d.date()
                # if d < day_min:
                #    break
                if d > day_max or d < day_min:
                    # Out of range
                    continue
                results.append((key, ep.copy()))
            return results

    def check_all(
            self,
            keys: typing.Optional[typing.List[str]] = None,
            delta_min: typing.Optional[typing.Union[
                int, datetime.date, datetime.timedelta
            ]] = None,
            delta_max: typing.Optional[typing.Union[
                int, datetime.date, datetime.timedelta
            ]] = None,
            force_check: bool = False,
            auto_save: bool = False,
    ) -> typing.List[typing.Tuple[str, typing.Dict]]:
        """
        Check multiple shows for updates in a period
        (adds it if not already present and updates info)

        :param keys: Shows to check; None -> check all (default: None)
        :param force_check:  Force information load (default: False)
        :param delta_min: Time in the past (either date or delta from now)
        :param delta_max: Time in the future (either date or delta from now)
        :param auto_save: Save after each update (default: False)
        :return: Show, episode pair matching the query
        """
        if not self._is_running:
            self.warning("Not running")
        results = []
        with self._lock_update:
            if keys is None:
                keys = sorted(list(self.shows))
                self.show_update_all(force_check, auto_save)
                # All updates allready done
                force_check = False
                auto_save = False
            for key in keys:
                results.extend(
                    self.check(
                        key, force_check=force_check, auto_save=auto_save,
                        delta_min=delta_min, delta_max=delta_max
                    )
                )
        return results

# -*- coding: UTF-8 -*-
""" Base class for tv checks """

__author__ = "d01"
__copyright__ = "Copyright (C) 2014-20, <NAME>"
__license__ = "MIT"
__version__ = "0.6.8"
__date__ = "2020-05-11"
# Created: 2014-05-18 04:08

import abc
from collections import OrderedDict
import datetime
from datetime import timedelta
import os
import shutil
import threading
import time
import typing

import flotils
import pytz


logger = flotils.get_logger()


class TVNextException(Exception):
    """ Base exception for project """

    pass


class TVNextFatalException(Exception):
    """ Exception that can not be recovered from """

    pass


class TVNextNotFoundException(Exception):
    """ Show not found """

    pass


class JSONDecoder(flotils.loadable.DateTimeDecoder):
    """ Extend DateTimeDecoder with tzinfo field """

    @staticmethod
    def _as_tzinfo(dct):
        if "__pytzinfo__" in dct.keys():
            return pytz.timezone(dct['__pytzinfo__'])
        raise TypeError("Not pytzinfo")

    @staticmethod
    def decode(dct: typing.Any) -> typing.Any:
        """ Decode object """
        try:
            if isinstance(dct, dict):
                return JSONDecoder._as_tzinfo(dct)
        except Exception:
            pass
        res = super(JSONDecoder, JSONDecoder).decode(dct)
        if isinstance(res, datetime.datetime):
            res = res.replace(tzinfo=pytz.UTC)
        return res


class JSONEncoder(flotils.loadable.DateTimeEncoder):
    """ Extend DateTimeEncoder with tzinfo field """

    def default(self, obj: typing.Any) -> typing.Any:
        """ Encode object """
        if isinstance(obj, datetime.tzinfo):
            return obj.zone
        else:
            return super(JSONEncoder, self).default(obj)


class TVNext(flotils.Loadable, flotils.StartStopable, abc.ABC):
    """ Abstract checker for new tv episodes """

    def __init__(
            self,
            settings: typing.Optional[typing.Dict[str, typing.Any]] = None
    ) -> None:
        """
        Initialise instance

        :param settings: Settings
        """
        if settings is None:
            settings = {}
        super().__init__(settings)

        self._shows = OrderedDict(settings.get('shows', {}))
        """ Show information
            :type : dict[unicode, dict[unicode, bool | unicode | \
            datetime.time | datetime.datetime | int | dict[unicode, dict[\
            unicode, unicode | datetime.datetime]]] """
        self._cache_path = settings.get('cache_path', None)
        """ Path to cache directory (default: None)
            :type _cache_path: None | str """
        self._cache_file = settings.get('cache_file', "sallie.tmp.json")
        """ Cache information file on shows (default: sallie.tmp.json)
            :type : str """
        self._show_file = settings.get('show_file', None)
        """ File of show names (default: None)
            :type : None | str """

        self._access_interval = settings.get('update_interval', 7)
        """ Refresh data every x days for active shows (default: 1)
            :type : int """
        self._access_interval_hiatus = settings.get(
            'update_interval_hiatus', None
        )
        """ Refresh interval for shows marked as hiatus in days (default: None)
        :type : None | int """
        self._access_interval_inactive = settings.get(
            'update_interval_inactive', None
        )
        """ Refresh interval for shows marked as inactive in days (default: None)
        :type : None | int """
        self._access_interval_aired = settings.get(
            'update_interval_aired', None
        )
        """ Refresh interval for shows after a new episode aired within \
            the last x days (default: None)
            :type : None | int """
        self._access_interval_error = settings.get(
            'update_interval_error', 10
        )
        """ Refresh interval for shows with error loading (min) (default: 10)
        :type : int """

        self._timezone = settings.get('timezone_default', "US/Pacific")
        """ Timezone to be used if not provided for show
            :type : datetime.timezone """

        if isinstance(self._timezone, str):
            # If given as name -> convert to timezone with pytz
            self._timezone = pytz.timezone(self._timezone)

        self._error_sleep_time = settings.get('update_retry_delay', 2.0)
        """ Time to sleep after error
            :type : float """
        self._max_retries = settings.get('update_retry_num', 3)
        """ Maximum number of retries on load failure
            :type : int """
        self._should_retry = settings.get('update_retry', True)
        """ Should be retried
            :type : bool """

        if self._cache_path:
            self._cache_path = self.join_path_prefix(self._cache_path)

            if self._cache_file:
                self._cache_file = os.path.join(
                    self._cache_path, self._cache_file
                )

        self._lock_update = threading.RLock()
        """ Lock to prevent concurrent access
            :type : threading.RLock """

    def start(self, blocking: bool = False) -> None:
        """
        Start the interface

        :param blocking: Should the call block until stop() is called
            (default: False)
        """
        if self._cache_file and os.path.exists(self._cache_file):
            shows = self._load_json_file(
                self._cache_file, decoder=JSONDecoder
            )
            shows.update(self._shows)
            self._shows.update(shows)
        # Don't care whether cache file exitsts -> generate it
        # else:
        #    raise IOError("File '{}' not found".format(self._showPath))
        if self._show_file:
            self.show_name_file_load()
        super(TVNext, self).start(blocking)

    def stop(self) -> None:
        """ Stop the interface """
        self.debug("()")
        super(TVNext, self).stop()
        # self.show_save_all()

    def show_name_file_load(self) -> None:
        """
        Load file containing show names

        :raises IOError: File not found
        :raises IOError: Failed to load
        """
        self._show_file = self.join_path_prefix(self._show_file)

        if os.path.exists(self._show_file):
            show_names: typing.List[
                typing.Union[str, typing.Tuple[str, str]]
            ] = self._load_json_file(
                self._show_file, decoder=JSONDecoder
            )
            """ :type : list[str | (str, str)] """

            for name in show_names:
                timezone = self._timezone
                if isinstance(name, list):
                    name, timezone = name
                if isinstance(timezone, str):
                    timezone = pytz.timezone(timezone)
                self._shows.setdefault(name, {})
                self._shows[name]['air_timezone'] = timezone
        else:
            raise IOError("File '{}' not found".format(self._show_file))
        self.debug("Loaded show names from {}".format(self._show_file))

    def show_add(self, show: str, timezone: typing.Optional = None) -> None:
        """
        Add new show with default params

        :param show: Name of show
        :param timezone: Timezone to add - None means no timezone
            (Default: None)
        """
        if isinstance(timezone, str):
            timezone = pytz.timezone(timezone)
        with self._lock_update:
            self._shows.setdefault(show, {})
            if timezone:
                self._shows[show]['air_timezone'] = timezone

    def show_remove(self, show: str) -> None:
        """
        Remove show

        :param show: Name of show
        """
        with self._lock_update:
            if show in self._shows:
                del self._shows[show]

    @property
    def shows(self) -> typing.Dict[str, typing.Dict[str, typing.Union[
        str, bool, datetime.time, datetime.datetime, int,
        typing.Dict[
            str, typing.Dict[str, typing.Union[str, datetime.datetime]]
        ]
    ]]]:
        """
        Show information

        :return: Shows being watched watching
        """
        return self._shows

    def show_save_all(self, path: typing.Optional[str] = None) -> None:
        """
        Save shows cache

        :param path: Path to save to (default: None)
            if None -> use cache_file
        """
        if not path:
            path = self._cache_file
        temp_path = path + "temp.json"
        try:
            self._save_json_file(
                temp_path,
                self._shows,
                pretty=True,
                sort=True,
                encoder=JSONEncoder
            )
            shutil.move(temp_path, path)
            self.info("Saved shows to {}".format(path))
        except Exception:
            self.exception("Failed to save shows to {}".format(path))

    def show_episodes_flatten(
            self, key: str, show: typing.Optional = None
    ) -> typing.List[typing.Dict[str, typing.Union[str, datetime.datetime]]]:
        """
        Get a (flat) list of this shows episodes

        :param key: Show key
        :param show: Show information (default: None)
            if None -> use key to retrieve show information
        :type show: dict[unicode, bool | unicode | datetime.time | \
            datetime.datetime | int | dict[unicode, dict[\
            unicode, unicode | datetime.datetime]]
        :return: Episode list
        """
        if not show:
            show = self._shows[key]
        eps = show['episodes']
        return [eps[s_nr][e_nr] for s_nr in eps for e_nr in eps[s_nr]]

    def show_update_should(self, key: str, force_check: bool = False) -> bool:
        """
        Decide if show needs updating

        :param key: Show key
        :param force_check: Force checking
        :return: Should show be updated
        """
        show = self._shows[key]
        now = pytz.utc.localize(datetime.datetime.utcnow())

        # set defaults
        show.setdefault('active', True)
        show.setdefault('hiatus', False)
        show.setdefault('episodes', {})
        accessed = show.get('accessed', None)
        episodes = self.show_episodes_flatten(key, show)

        diff = None
        """ :type : datetime.timedelta """

        if accessed:
            diff = now - accessed
        else:
            self.info("Never accessed {}".format(key))
            return True
        # Update if errors last time
        if show.get('errors', 0) >= 3 and \
                (diff.days >= self._access_interval_error or force_check):
            self.info("Error retry {}".format(key))
            # Only retry once
            show['errors'] = max(1, self._max_retries - 1)
            return True

        if force_check:
            return True
        if self._access_interval_aired is not None:
            # Update after new episode aired (in last x days)
            # Reverse list (newest first)
            for ep in episodes[::-1]:
                aired = ep['aired']
                """ :type : datetime.datetime """
                if not aired:
                    # Skip with no air dates
                    continue
                # TODO: make sure tzinfo is correctly loaded
                if aired.tzinfo is None:
                    self.warning("Unset tzinfo in {} ({})".format(
                        key, ep['id']
                    ))
                    aired = pytz.utc.localize(aired)
                if aired > now:
                    # Skip not aired yet
                    continue
                if (aired - accessed).days >= self._access_interval_aired:
                    # Last access before aired
                    self.debug("Aired {} ago {}".format(now - aired, key))
                    return True
                else:
                    # Out of reach
                    break
        if not show['active']:
            # Update inactive shows every so often in case they were mislabeled
            if diff is not None and self._access_interval_inactive:
                # self.debug("Inactive {} ago {}".format(diff, key))
                return diff.days >= self._access_interval_inactive
            # self.debug("{} inactive".format(key))
            return False
        if show['hiatus']:
            # Update shows on hiatus every so often in case episodes got added
            if diff is not None and self._access_interval_hiatus:
                # self.debug("Hiatus {} ago {}".format(diff, key))
                return diff.days >= self._access_interval_hiatus
            # self.debug("{} hiatus".format(key))
            return False

        # self.debug("Last access {} ago {}".format(diff, key))
        # Use generic access interval
        return diff.days >= self._access_interval

    def show_update(
            self, key: str, force_check: bool = False, auto_save: bool = False
    ) -> bool:
        """
        Update show (and adds it if not already present)

        :param key: Show name
        :param force_check: Force information load (default: False)
        :param auto_save: Save after update (default: False)
        :return: Changed
        """
        if not self.show_update_should(key, force_check):
            # Show information already up to date
            return False
        changed = False
        retry = True
        error_sleep = self._error_sleep_time
        self.shows[key].setdefault('errors', 0)

        while retry \
                and self.shows[key]['errors'] < self._max_retries:
            retry = False

            # TODO: move into implementation (Backoff exception?) - fatal
            try:
                changed = self._show_update(key)
            except TVNextNotFoundException:
                # Show not found
                self.shows[key]['errors'] = self._max_retries
                self.shows[key]['accessed'] = pytz.utc.localize(
                    datetime.datetime.utcnow()
                )
                changed = True
                break
            except Exception as e:
                if "connection reset by peer" in "{}".format(e).lower():
                    # Connection reset by peer -> exponential backoff
                    self.warning(
                        "Connection reset by peer (Sleeping {})".format(
                            error_sleep
                        )
                    )
                    time.sleep(error_sleep)
                    error_sleep *= 2.0
                else:
                    self.exception("Failed to load {}".format(key))
                self.shows[key]['errors'] += 1
                retry = self._should_retry
                changed = True

        if auto_save and changed:
            self.show_save_all()
        return changed

    @abc.abstractmethod
    def _show_update(self, key: str) -> bool:
        """
        Update show (and adds it if not already present)

        Overwrite to implement

        :param key: Show name
        :return: Changed
        """
        raise NotImplementedError

    def show_update_all(
            self, force_check: bool = False, auto_save: bool = False,
    ) -> None:
        """
        Update information for all shows

        :param force_check: Force information load (default: False)
        :param auto_save: Save after each update (default: False)
        """
        shows = self._shows
        changed = False
        for key in sorted(shows):
            changed = self.show_update(key, force_check, auto_save) or changed
        if not auto_save and changed:
            self.show_save_all()

    def check(
            self,
            key: str,
            force_check: bool = False,
            delta_min: typing.Optional[typing.Union[
                int, datetime.date, datetime.timedelta
            ]] = None,
            delta_max: typing.Optional[typing.Union[
                int, datetime.date, datetime.timedelta
            ]] = None,
            auto_save: bool = False,
    ) -> typing.List[typing.Tuple[str, typing.Dict]]:
        """
        Check a single show for updates in a period
        (adds it if not already present and updates info)

        :param key: Show to check
        :param force_check:  Force information load (default: False)
        :param delta_min: Time in the past (either date or delta from now)
        :param delta_max: Time in the future (either date or delta from now)
        :param auto_save: Save after each update (default: False)
        :return: Show, episode pair matching the query
        """
        if not self.is_running:
            self.warning("Not running")
        # Delta as ints = days
        if isinstance(delta_min, int):
            delta_min = timedelta(days=delta_min)
        if isinstance(delta_max, int):
            delta_max = timedelta(days=delta_max)
        # Delta defaults
        if delta_min is None:
            delta_min = timedelta()
        if delta_max is None:
            delta_max = timedelta()

        now = pytz.utc.localize(datetime.datetime.utcnow()).date()
        if isinstance(delta_min, datetime.date):
            # Delta as date
            day_min = delta_min
        else:
            # Delta as timedelta
            day_min = now - delta_min
        if isinstance(delta_max, datetime.date):
            # Delta as date
            day_max = delta_max
        else:
            # Delta as timedelta
            day_max = now + delta_max

        # Wrong order
        if day_min > day_max:
            day_min, day_max = day_max, day_min

        with self._lock_update:
            if key not in self.shows:
                # Add missing
                self.show_add(key)
            # Update info
            self.show_update(key, force_check, auto_save)

            # self.info("Searching between {} and {}".format(day_min, day_max))
            show = self.shows[key]
            episodes = self.show_episodes_flatten(key, show)
            results = []

            # More likely that recent epsiode selected -> backwards
            for ep in episodes[::-1]:
                d = ep['aired']
                if not d:
                    # self.debug(u"No aired: {}.{}".format(key, ep['id']))
                    continue
                d = d.date()
                # if d < day_min:
                #    break
                if d > day_max or d < day_min:
                    # Out of range
                    continue
                results.append((key, ep.copy()))
            return results

    def check_all(
            self,
            keys: typing.Optional[typing.List[str]] = None,
            delta_min: typing.Optional[typing.Union[
                int, datetime.date, datetime.timedelta
            ]] = None,
            delta_max: typing.Optional[typing.Union[
                int, datetime.date, datetime.timedelta
            ]] = None,
            force_check: bool = False,
            auto_save: bool = False,
    ) -> typing.List[typing.Tuple[str, typing.Dict]]:
        """
        Check multiple shows for updates in a period
        (adds it if not already present and updates info)

        :param keys: Shows to check; None -> check all (default: None)
        :param force_check:  Force information load (default: False)
        :param delta_min: Time in the past (either date or delta from now)
        :param delta_max: Time in the future (either date or delta from now)
        :param auto_save: Save after each update (default: False)
        :return: Show, episode pair matching the query
        """
        if not self._is_running:
            self.warning("Not running")
        results = []
        with self._lock_update:
            if keys is None:
                keys = sorted(list(self.shows))
                self.show_update_all(force_check, auto_save)
                # All updates allready done
                force_check = False
                auto_save = False
            for key in keys:
                results.extend(
                    self.check(
                        key, force_check=force_check, auto_save=auto_save,
                        delta_min=delta_min, delta_max=delta_max
                    )
                )
        return results
# -*- coding: UTF-8 -*- Base class for tv checks # Created: 2014-05-18 04:08 Base exception for project Exception that can not be recovered from Show not found Extend DateTimeDecoder with tzinfo field Decode object Extend DateTimeEncoder with tzinfo field Encode object Abstract checker for new tv episodes Initialise instance          :param settings: Settings Show information             :type : dict[unicode, dict[unicode, bool | unicode | \             datetime.time | datetime.datetime | int | dict[unicode, dict[\             unicode, unicode | datetime.datetime]]] Path to cache directory (default: None)             :type _cache_path: None | str Cache information file on shows (default: sallie.tmp.json)             :type : str File of show names (default: None)             :type : None | str Refresh data every x days for active shows (default: 1)             :type : int Refresh interval for shows marked as hiatus in days (default: None)         :type : None | int Refresh interval for shows marked as inactive in days (default: None)         :type : None | int Refresh interval for shows after a new episode aired within \             the last x days (default: None)             :type : None | int Refresh interval for shows with error loading (min) (default: 10)         :type : int Timezone to be used if not provided for show             :type : datetime.timezone # If given as name -> convert to timezone with pytz Time to sleep after error             :type : float Maximum number of retries on load failure             :type : int Should be retried             :type : bool Lock to prevent concurrent access             :type : threading.RLock Start the interface          :param blocking: Should the call block until stop() is called             (default: False) # Don't care whether cache file exitsts -> generate it # else: #    raise IOError("File '{}' not found".format(self._showPath)) Stop the interface # self.show_save_all() Load file containing show names          :raises IOError: File not found         :raises IOError: Failed to load :type : list[str | (str, str)] Add new show with default params          :param show: Name of show         :param timezone: Timezone to add - None means no timezone             (Default: None) Remove show          :param show: Name of show Show information          :return: Shows being watched watching Save shows cache          :param path: Path to save to (default: None)             if None -> use cache_file Get a (flat) list of this shows episodes          :param key: Show key         :param show: Show information (default: None)             if None -> use key to retrieve show information         :type show: dict[unicode, bool | unicode | datetime.time | \             datetime.datetime | int | dict[unicode, dict[\             unicode, unicode | datetime.datetime]]         :return: Episode list Decide if show needs updating          :param key: Show key         :param force_check: Force checking         :return: Should show be updated # set defaults :type : datetime.timedelta # Update if errors last time # Only retry once # Update after new episode aired (in last x days) # Reverse list (newest first) :type : datetime.datetime # Skip with no air dates # TODO: make sure tzinfo is correctly loaded # Skip not aired yet # Last access before aired # Out of reach # Update inactive shows every so often in case they were mislabeled # self.debug("Inactive {} ago {}".format(diff, key)) # self.debug("{} inactive".format(key)) # Update shows on hiatus every so often in case episodes got added # self.debug("Hiatus {} ago {}".format(diff, key)) # self.debug("{} hiatus".format(key)) # self.debug("Last access {} ago {}".format(diff, key)) # Use generic access interval Update show (and adds it if not already present)          :param key: Show name         :param force_check: Force information load (default: False)         :param auto_save: Save after update (default: False)         :return: Changed # Show information already up to date # TODO: move into implementation (Backoff exception?) - fatal # Show not found # Connection reset by peer -> exponential backoff Update show (and adds it if not already present)          Overwrite to implement          :param key: Show name         :return: Changed Update information for all shows          :param force_check: Force information load (default: False)         :param auto_save: Save after each update (default: False) Check a single show for updates in a period         (adds it if not already present and updates info)          :param key: Show to check         :param force_check:  Force information load (default: False)         :param delta_min: Time in the past (either date or delta from now)         :param delta_max: Time in the future (either date or delta from now)         :param auto_save: Save after each update (default: False)         :return: Show, episode pair matching the query # Delta as ints = days # Delta defaults # Delta as date # Delta as timedelta # Delta as date # Delta as timedelta # Wrong order # Add missing # Update info # self.info("Searching between {} and {}".format(day_min, day_max)) # More likely that recent epsiode selected -> backwards # self.debug(u"No aired: {}.{}".format(key, ep['id'])) # if d < day_min: #    break # Out of range Check multiple shows for updates in a period         (adds it if not already present and updates info)          :param keys: Shows to check; None -> check all (default: None)         :param force_check:  Force information load (default: False)         :param delta_min: Time in the past (either date or delta from now)         :param delta_max: Time in the future (either date or delta from now)         :param auto_save: Save after each update (default: False)         :return: Show, episode pair matching the query # All updates allready done