# # This file is part of SickGear. # # SickGear is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # SickGear is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with SickGear. If not, see <http://www.gnu.org/licenses/>. import datetime import re import traceback from . import classes, db, logger from .helpers import try_int from .indexers.indexer_config import TVINFO_IMDB, TVINFO_TMDB, TVINFO_TRAKT, TVINFO_TVDB, TVINFO_TVMAZE import sickgear from lib.dateutil.parser import parse from _23 import unidecode from six import iteritems, moves, string_types, PY2 # noinspection PyUnreachableCode if False: # noinspection PyUnresolvedReferences from typing import Any, AnyStr, Dict, List, Optional, Tuple, Union from six import integer_types from sickgear.tv import TVShow tv_maze_retry_wait = 10 defunct_indexer = [] indexer_list = [] class NewIdDict(dict): def __init__(self, *args, **kwargs): tv_src = kwargs.pop('tv_src') super(NewIdDict, self).__init__(*args, **kwargs) self.verified = {s: (False, True)[s == tv_src] for s in indexer_list} def set_value(self, value, old_value=None, tv_src=None, key=None): # type: (Any, Any, int, int) -> Any if (None is tv_src or tv_src != key) and old_value is MapStatus.MISMATCH or ( 0 < value and old_value not in [None, value] and 0 < old_value): return MapStatus.MISMATCH if value and tv_src and tv_src == key: self.verified[tv_src] = True return value @staticmethod def get_value(value): if value in [None, 0]: return MapStatus.NOT_FOUND return value def __getitem__(self, key): return self.get_value(super(NewIdDict, self).get(key)) def get(self, key, default=None): return self.get_value(super(NewIdDict, self).get(key, default)) def __setitem__(self, key, value): super(NewIdDict, self).__setitem__(key, self.set_value(value, self.get(key))) def update(self, other=None, tv_src=None, **kwargs): # type: (Dict[int, Any], int, Any) -> None """ updates dict with new ids set MapStatus.MISMATCH if values mismatch, except if it's tv_src (this will be treated as verified source id) :param other: new data dict :param tv_src: verified tv src id :param kwargs: """ if isinstance(other, dict): other = {o: self.set_value(v, self.get(o), tv_src, o) for o, v in iteritems(other)} super(NewIdDict, self).update(other, **kwargs) def get_missing_ids(show_ids, show_obj, tv_src): # type: (Dict[int, integer_types], TVShow, int) -> Dict[int, integer_types] """ :param show_ids: :param show_obj: :param tv_src: :return: """ try: tvinfo_config = sickgear.TVInfoAPI(tv_src).api_params.copy() tvinfo_config['cache_search'] = True tvinfo_config['custom_ui'] = classes.AllShowInfosNoFilterListUI t = sickgear.TVInfoAPI(tv_src).setup(**tvinfo_config) show_name, f_date = None, None if any(1 for k, v in iteritems(show_ids) if v and k in t.supported_id_searches): try: found_shows = t.search_show(ids=show_ids) res_count = len(found_shows or []) if 1 < res_count: show_name, f_date = get_show_name_date(show_obj) for show in found_shows or []: if 1 == res_count or confirm_show(f_date, show['firstaired'], show_name, clean_show_name(show['seriesname'])): return combine_new_ids(show_ids, show['ids'], tv_src) except (BaseException, Exception): pass found_shows = t.search_show(name=clean_show_name(show_obj.name)) if not show_name: show_name, f_date = get_show_name_date(show_obj) for show in found_shows or []: if confirm_show(f_date, show['firstaired'], show_name, clean_show_name(show['seriesname'])): if any(v for k, v in iteritems(show['ids']) if tv_src != k and v): f_show = [show] else: f_show = t.search_show(ids={tv_src: show['id']}) if f_show and 1 == len(f_show): return combine_new_ids(show_ids, f_show[0]['ids'], tv_src) except (BaseException, Exception): pass return {} def confirm_show(premiere_date, shows_premiere, expected_name, show_name): # type: (Optional[datetime.date], Optional[Union[AnyStr, datetime.date]], AnyStr, AnyStr) -> bool """ confirm show possible confirmations: 1. premiere dates are less then 2 days apart 2. show name is the same and premiere year is 1 year or less apart :param premiere_date: expected show premiere date :param shows_premiere: compare date :param expected_name: :param show_name: """ if any(t is None for t in (premiere_date, shows_premiere)): return False if isinstance(shows_premiere, string_types): try: shows_premiere = parse(shows_premiere).date() except (BaseException, Exception): return False start_year = (shows_premiere and shows_premiere.year) or 0 return abs(premiere_date - shows_premiere) < datetime.timedelta(days=2) or ( expected_name == show_name and abs(premiere_date.year - start_year) <= 1) def get_premieredate(show_obj): """ :param show_obj: show object :type show_obj: sickgear.tv.TVShow :return: :rtype: datetime.date or None """ try: ep_obj = show_obj.first_aired_regular_episode if ep_obj and ep_obj.airdate: return ep_obj.airdate except (BaseException, Exception): pass return None def clean_show_name(showname): """ :param showname: show name :type showname: AnyStr :return: :rtype: AnyStr """ if not PY2: return re.sub(r'[(\s]*(?:19|20)\d\d[)\s]*$', '', showname) return re.sub(r'[(\s]*(?:19|20)\d\d[)\s]*$', '', unidecode(showname)) def get_show_name_date(show_obj): # type: (TVShow) -> Tuple[Optional[AnyStr], Optional[datetime.date]] return clean_show_name(show_obj.name), get_premieredate(show_obj) def combine_mapped_new_dict(mapped, new_ids): # type: (Dict[int, Dict], Dict[int, integer_types]) -> Dict[int, integer_types] return {n: m for d in ({k: v['id'] for k, v in iteritems(mapped) if v['id']}, new_ids) for n, m in iteritems(d)} def combine_new_ids(cur_ids, new_ids, src_id): # type: (Dict[int, integer_types], Dict[int, integer_types], int) -> Dict[int, integer_types] """ combine cur_ids with new_ids, priority has cur_ids with exception of src_id key :param cur_ids: :param new_ids: :param src_id: """ return {k: v for d in (cur_ids, new_ids) for k, v in iteritems(d) if v and (k == src_id or not cur_ids.get(k) or v == cur_ids.get(k, ''))} def map_indexers_to_show(show_obj, update=False, force=False, recheck=False, im_sql_result=None): # type: (sickgear.tv.TVShow, Optional[bool], Optional[bool], Optional[bool], Optional[list]) -> dict """ :param show_obj: TVShow Object :param update: add missing + previously not found ids :param force: search for and replace all mapped/missing ids (excluding NO_AUTOMATIC_CHANGE flagged) :param recheck: load all ids, don't remove existing :param im_sql_result: :return: mapped ids """ mapped = {} # init mapped tvids object for tvid in indexer_list: mapped[tvid] = {'id': (0, show_obj.prodid)[int(tvid) == int(show_obj.tvid)], 'status': (MapStatus.NONE, MapStatus.SOURCE)[int(tvid) == int(show_obj.tvid)], 'date': datetime.date.fromordinal(1)} sql_result = [] for cur_row in im_sql_result or []: if show_obj.prodid == cur_row['indexer_id'] and show_obj.tvid == cur_row['indexer']: sql_result.append(cur_row) if not sql_result: my_db = db.DBConnection() sql_result = my_db.select( 'SELECT * FROM indexer_mapping WHERE indexer = ? AND indexer_id = ?', [show_obj.tvid, show_obj.prodid]) # for each mapped entry for cur_row in sql_result or []: date = try_int(cur_row['date']) mapped[int(cur_row['mindexer'])] = {'status': int(cur_row['status']), 'id': int(cur_row['mindexer_id']), 'date': datetime.date.fromordinal(date if 0 < date else 1)} # get list of needed ids mis_map = [k for k, v in iteritems(mapped) if (v['status'] not in [ MapStatus.NO_AUTOMATIC_CHANGE, MapStatus.SOURCE]) and ((0 == v['id'] and MapStatus.NONE == v['status']) or force or recheck or (update and 0 == v['id'] and k not in defunct_indexer))] if mis_map: src_tv_id = show_obj._tvid new_ids = NewIdDict(tv_src=src_tv_id) # type: NewIdDict if show_obj.imdbid and re.search(r'\d+$', show_obj.imdbid): new_ids[TVINFO_IMDB] = try_int(re.search(r'(?:tt)?(\d+)', show_obj.imdbid).group(1)) all_ids_srcs = [src_tv_id] + [s for s in (TVINFO_TRAKT, TVINFO_TMDB, TVINFO_TVMAZE, TVINFO_TVDB, TVINFO_IMDB) if s != src_tv_id] searched, confirmed = {}, False for r in moves.range(len(all_ids_srcs)): search_done = False for i in all_ids_srcs: if new_ids.verified.get(i): continue search_ids = {k: v for k, v in iteritems(combine_mapped_new_dict(mapped, new_ids)) if k not in searched.setdefault(i, {})} if search_ids: search_done = True searched[i].update(search_ids) new_ids.update(get_missing_ids(search_ids, show_obj, tv_src=i), tv_src=i) if new_ids.get(i) and 0 < new_ids.get(i): searched[i].update({i: new_ids[i]}) confirmed = all(v for k, v in iteritems(new_ids.verified) if k not in defunct_indexer) if confirmed: break if confirmed or not search_done: break for i in indexer_list: if i != show_obj.tvid and ((i in mis_map and 0 != new_ids.get(i, 0)) or (new_ids.verified.get(i) and 0 < new_ids.get(i, 0))): if i not in new_ids: mapped[i] = {'status': MapStatus.NOT_FOUND, 'id': 0} continue if new_ids.verified.get(i) and 0 < new_ids[i] and mapped.get(i, {'id': 0})['id'] != new_ids[i]: if i not in mis_map: mis_map.append(i) mapped[i] = {'status': MapStatus.NONE, 'id': new_ids[i]} continue if 0 > new_ids[i]: mapped[i] = {'status': new_ids[i], 'id': 0} elif force or not recheck or 0 >= mapped.get(i, {'id': 0}).get('id', 0): mapped[i] = {'status': MapStatus.NONE, 'id': new_ids[i]} if [k for k in mis_map if 0 != mapped.get(k, {'id': 0, 'status': 0})['id'] or mapped.get(k, {'id': 0, 'status': 0})['status'] not in [MapStatus.NONE, MapStatus.SOURCE]]: sql_l = [] today = datetime.date.today() date = today.toordinal() for tvid in indexer_list: if show_obj.tvid == tvid or tvid not in mis_map: continue if 0 != mapped[tvid]['id'] or MapStatus.NONE != mapped[tvid]['status']: mapped[tvid]['date'] = today sql_l.append([ 'REPLACE INTO indexer_mapping (indexer_id, indexer, mindexer_id, mindexer, date, status)' ' VALUES (?,?,?,?,?,?)', [show_obj.prodid, show_obj.tvid, mapped[tvid]['id'], tvid, date, mapped[tvid]['status']]]) else: sql_l.append([ 'DELETE FROM indexer_mapping' ' WHERE indexer = ? AND indexer_id = ? AND mindexer = ?', [show_obj.tvid, show_obj.prodid, tvid]]) if 0 < len(sql_l): logger.debug('Adding TV info mapping to DB for show: %s' % show_obj.unique_name) my_db = db.DBConnection() my_db.mass_action(sql_l) show_obj.ids = mapped return mapped def save_mapping(show_obj, save_map=None): # type: (sickgear.tv.TVShow, Optional[List[int]]) -> None """ :param show_obj: show object :param save_map: list of tvid ints """ sql_l = [] today = datetime.date.today() date = today.toordinal() for tvid in indexer_list: if show_obj.tvid == tvid or (isinstance(save_map, list) and tvid not in save_map): continue if 0 != show_obj.ids[tvid]['id'] or MapStatus.NONE != show_obj.ids[tvid]['status']: show_obj.ids[tvid]['date'] = today sql_l.append([ 'REPLACE INTO indexer_mapping' ' (indexer_id, indexer, mindexer_id, mindexer, date, status) VALUES (?,?,?,?,?,?)', [show_obj.prodid, show_obj.tvid, show_obj.ids[tvid]['id'], tvid, date, show_obj.ids[tvid]['status']]]) else: sql_l.append([ 'DELETE FROM indexer_mapping WHERE indexer = ? AND indexer_id = ? AND mindexer = ?', [show_obj.tvid, show_obj.prodid, tvid]]) if 0 < len(sql_l): logger.debug('Saving TV info mapping to DB for show: %s' % show_obj.unique_name) my_db = db.DBConnection() my_db.mass_action(sql_l) def del_mapping(tvid, prodid): """ :param tvid: tvid :type tvid: int :param prodid: prodid :type prodid: int or long """ my_db = db.DBConnection() my_db.action('DELETE FROM indexer_mapping WHERE indexer = ? AND indexer_id = ?', [tvid, prodid]) def should_recheck_update_ids(show_obj): """ :param show_obj: show object :type show_obj: sickgear.tv.TVShow :return: :rtype: bool """ try: today = datetime.date.today() ids_updated = min([v.get('date') for k, v in iteritems(show_obj.ids) if k != show_obj.tvid and k not in defunct_indexer] or [datetime.date.fromtimestamp(1)]) if today - ids_updated >= datetime.timedelta(days=365): return True ep_obj = show_obj.first_aired_regular_episode if ep_obj and ep_obj.airdate and ep_obj.airdate > datetime.date.fromtimestamp(1): show_age = (today - ep_obj.airdate).days # noinspection PyTypeChecker for d in [365, 270, 180, 135, 90, 60, 30, 16, 9] + range(4, -4, -1): if d <= show_age: return ids_updated < (ep_obj.airdate + datetime.timedelta(days=d)) except (BaseException, Exception): pass return False def load_mapped_ids(**kwargs): logger.log('Start loading TV info mappings...') if 'load_all' in kwargs: del kwargs['load_all'] my_db = db.DBConnection() sql_result = my_db.select('SELECT * FROM indexer_mapping ORDER BY indexer, indexer_id') else: sql_result = None for cur_show_obj in sickgear.showList: with cur_show_obj.lock: n_kargs = kwargs.copy() if 'update' in kwargs and should_recheck_update_ids(cur_show_obj): n_kargs['recheck'] = True if sql_result: n_kargs['im_sql_result'] = sql_result try: cur_show_obj.ids = sickgear.indexermapper.map_indexers_to_show(cur_show_obj, **n_kargs) except (BaseException, Exception): logger.debug('Error loading mapped id\'s for show: %s' % cur_show_obj.unique_name) logger.log('Traceback: %s' % traceback.format_exc(), logger.ERROR) logger.log('TV info mappings loaded') class MapStatus(object): def __init__(self): pass SOURCE = 1 NONE = 0 NOT_FOUND = -1 MISMATCH = -2 NO_AUTOMATIC_CHANGE = -100 allstatus = [SOURCE, NONE, NOT_FOUND, MISMATCH, NO_AUTOMATIC_CHANGE]