mirror of
https://github.com/SickGear/SickGear.git
synced 2024-11-25 22:35:05 +00:00
1329 lines
64 KiB
Python
1329 lines
64 KiB
Python
# !/usr/bin/env python2
|
|
# encoding:utf-8
|
|
# author:dbr/Ben
|
|
# project:tvdb_api
|
|
# repository:http://github.com/dbr/tvdb_api
|
|
# license:un license (http://unlicense.org/)
|
|
|
|
from functools import wraps
|
|
|
|
__author__ = 'dbr/Ben'
|
|
__version__ = '2.0'
|
|
__api_version__ = '3.0.0'
|
|
|
|
import copy
|
|
import datetime
|
|
import getpass
|
|
import logging
|
|
import os
|
|
import random
|
|
import re
|
|
import requests
|
|
import requests.exceptions
|
|
import tempfile
|
|
import time
|
|
import warnings
|
|
|
|
from bs4_parser import BS4Parser
|
|
from collections import OrderedDict
|
|
from sg_helpers import clean_data, get_url, try_int
|
|
from sickgear import ENV
|
|
|
|
from lib.cachecontrol import CacheControl, caches
|
|
from lib.dateutil.parser import parse
|
|
from lib.exceptions_helper import ConnectionSkipException
|
|
from lib.tvinfo_base import CastList, TVInfoCharacter, CrewList, TVInfoPerson, RoleTypes, \
|
|
TVINFO_TVDB, TVINFO_TVDB_SLUG, TVInfoBase, TVInfoIDs, TVInfoNetwork, TVInfoShow
|
|
|
|
from .tvdb_exceptions import TvdbError, TvdbShownotfound, TvdbTokenexpired
|
|
from .tvdb_ui import BaseUI, ConsoleUI
|
|
|
|
from six import integer_types, iteritems, PY2, string_types
|
|
|
|
# noinspection PyUnreachableCode
|
|
if False:
|
|
# noinspection PyUnresolvedReferences
|
|
from typing import Any, AnyStr, Dict, List, Optional, Union
|
|
|
|
|
|
THETVDB_V2_API_TOKEN = {'token': None, 'datetime': datetime.datetime.fromordinal(1)}
|
|
log = logging.getLogger('tvdb.api')
|
|
log.addHandler(logging.NullHandler())
|
|
|
|
|
|
# noinspection HttpUrlsUsage,PyUnusedLocal
|
|
def _record_hook(r, *args, **kwargs):
|
|
r.hook_called = True
|
|
if 301 == r.status_code and isinstance(r.headers.get('Location'), string_types) \
|
|
and r.headers.get('Location').startswith('http://api.thetvdb.com/'):
|
|
r.headers['Location'] = r.headers['Location'].replace('http://', 'https://')
|
|
return r
|
|
|
|
|
|
def retry(exception_to_check, tries=4, delay=3, backoff=2):
|
|
"""Retry calling the decorated function using an exponential backoff.
|
|
|
|
www.saltycrane.com/blog/2009/11/trying-out-retry-decorator-python/
|
|
original from: wiki.python.org/moin/PythonDecoratorLibrary#Retry
|
|
|
|
:param exception_to_check: the exception to check. may be a tuple of
|
|
exceptions to check
|
|
:type exception_to_check: Exception or tuple
|
|
:param tries: number of times to try (not retry) before giving up
|
|
:type tries: int
|
|
:param delay: initial delay between retries in seconds
|
|
:type delay: int
|
|
:param backoff: backoff multiplier e.g. value of 2 will double the delay
|
|
each retry
|
|
:type backoff: int
|
|
"""
|
|
|
|
def deco_retry(f):
|
|
|
|
@wraps(f)
|
|
def f_retry(*args, **kwargs):
|
|
mtries, mdelay = tries, delay
|
|
auth_error = 0
|
|
while 1 < mtries:
|
|
try:
|
|
return f(*args, **kwargs)
|
|
except exception_to_check as e:
|
|
msg = '%s, Retrying in %d seconds...' % (str(e), mdelay)
|
|
log.warning(msg)
|
|
time.sleep(mdelay)
|
|
if isinstance(e, TvdbTokenexpired) and not auth_error:
|
|
auth_error += 1
|
|
else:
|
|
mtries -= 1
|
|
mdelay *= backoff
|
|
except ConnectionSkipException as e:
|
|
raise e
|
|
try:
|
|
return f(*args, **kwargs)
|
|
except TvdbTokenexpired:
|
|
if not auth_error:
|
|
return f(*args, **kwargs)
|
|
raise TvdbTokenexpired
|
|
except ConnectionSkipException as e:
|
|
raise e
|
|
|
|
return f_retry # true decorator
|
|
|
|
return deco_retry
|
|
|
|
|
|
class Actors(list):
|
|
"""Holds all Actor instances for a show
|
|
"""
|
|
pass
|
|
|
|
|
|
class Actor(dict):
|
|
"""Represents a single actor. Should contain..
|
|
|
|
id,
|
|
image,
|
|
name,
|
|
role,
|
|
sortorder
|
|
"""
|
|
|
|
def __repr__(self):
|
|
return '<Actor "%r">' % self.get('name')
|
|
|
|
|
|
class Tvdb(TVInfoBase):
|
|
"""Create easy-to-use interface to name of season/episode name
|
|
>> t = Tvdb()
|
|
>> t['Scrubs'][1][24]['episodename']
|
|
'My Last Day'
|
|
"""
|
|
map_languages = {}
|
|
reverse_map_languages = {v: k for k, v in iteritems(map_languages)}
|
|
supported_id_searches = [TVINFO_TVDB, TVINFO_TVDB_SLUG]
|
|
|
|
# noinspection PyUnusedLocal
|
|
def __init__(self,
|
|
interactive=False,
|
|
select_first=False,
|
|
debug=False,
|
|
cache=True,
|
|
banners=False,
|
|
fanart=False,
|
|
posters=False,
|
|
seasons=False,
|
|
seasonwides=False,
|
|
actors=False,
|
|
custom_ui=None,
|
|
language=None,
|
|
search_all_languages=False,
|
|
apikey=None,
|
|
dvdorder=False,
|
|
proxy=None,
|
|
*args,
|
|
**kwargs):
|
|
|
|
"""interactive (True/False):
|
|
When True, uses built-in console UI is used to select the correct show.
|
|
When False, the first search result is used.
|
|
|
|
select_first (True/False):
|
|
Automatically selects the first series search result (rather
|
|
than showing the user a list of more than one series).
|
|
Is overridden by interactive = False, or specifying a custom_ui
|
|
|
|
debug (True/False) DEPRECATED:
|
|
Replaced with proper use of logging module. To show debug messages:
|
|
|
|
>> import logging
|
|
>> logging.basicConfig(level = logging.DEBUG)
|
|
|
|
cache (True/False/str/unicode/urllib2 opener):
|
|
Retrieved XML are persisted to to disc. If true, stores in
|
|
tvdb_api folder under your systems TEMP_DIR, if set to
|
|
str/unicode instance it will use this as the cache
|
|
location. If False, disables caching. Can also be passed
|
|
an arbitrary Python object, which is used as a urllib2
|
|
opener, which should be created by urllib2.build_opener
|
|
|
|
banners (True/False):
|
|
Retrieves the banners for a show. These are accessed
|
|
via the banners key of a Show(), for example:
|
|
|
|
>> Tvdb(banners=True)['scrubs']['banners'].keys()
|
|
['fanart', 'poster', 'series', 'season']
|
|
|
|
actors (True/False):
|
|
Retrieves a list of the actors for a show. These are accessed
|
|
via the actors key of a Show(), for example:
|
|
|
|
>> t = Tvdb(actors=True)
|
|
>> t['scrubs']['actors'][0]['name']
|
|
'Zach Braff'
|
|
|
|
custom_ui (tvdb_ui.BaseUI subclass):
|
|
A callable subclass of tvdb_ui.BaseUI (overrides interactive option)
|
|
|
|
language (2 character language abbreviation):
|
|
The language of the returned data. Is also the language search
|
|
uses. Default is "en" (English). For full list, run..
|
|
|
|
>> Tvdb().config['valid_languages'] #doctest: +ELLIPSIS
|
|
['da', 'fi', 'nl', ...]
|
|
|
|
search_all_languages (True/False):
|
|
By default, Tvdb will only search in the language specified using
|
|
the language option. When this is True, it will search for the
|
|
show in and language
|
|
|
|
apikey (str/unicode):
|
|
Override the default thetvdb.com API key. By default it will use
|
|
tvdb_api's own key (fine for small scripts), but you can use your
|
|
own key if desired - this is recommended if you are embedding
|
|
tvdb_api in a larger application)
|
|
See thetvdb.com/?tab=apiregister to get your own key
|
|
|
|
"""
|
|
|
|
super(Tvdb, self).__init__(*args, **kwargs)
|
|
self.config = {}
|
|
|
|
if None is not apikey:
|
|
self.config['apikey'] = apikey
|
|
else:
|
|
self.config['apikey'] = '0629B785CE550C8D' # tvdb_api's API key
|
|
|
|
self.config['debug_enabled'] = debug # show debugging messages
|
|
|
|
self.config['custom_ui'] = custom_ui
|
|
|
|
self.config['interactive'] = interactive # prompt for correct series?
|
|
|
|
self.config['select_first'] = select_first
|
|
|
|
self.config['search_all_languages'] = search_all_languages
|
|
|
|
self.config['dvdorder'] = dvdorder
|
|
|
|
self.config['proxy'] = proxy
|
|
|
|
if cache is True:
|
|
self.config['cache_enabled'] = True
|
|
self.config['cache_location'] = self._get_temp_dir()
|
|
elif cache is False:
|
|
self.config['cache_enabled'] = False
|
|
elif isinstance(cache, string_types):
|
|
self.config['cache_enabled'] = True
|
|
self.config['cache_location'] = cache
|
|
else:
|
|
raise ValueError('Invalid value for Cache %r (type was %s)' % (cache, type(cache)))
|
|
|
|
self.config['banners_enabled'] = banners
|
|
self.config['posters_enabled'] = posters
|
|
self.config['seasons_enabled'] = seasons
|
|
self.config['seasonwides_enabled'] = seasonwides
|
|
self.config['fanart_enabled'] = fanart
|
|
self.config['actors_enabled'] = actors
|
|
|
|
if self.config['debug_enabled']:
|
|
warnings.warn('The debug argument to tvdb_api.__init__ will be removed in the next version. ' +
|
|
'To enable debug messages, use the following code before importing: ' +
|
|
'import logging; logging.basicConfig(level=logging.DEBUG)')
|
|
logging.basicConfig(level=logging.DEBUG)
|
|
|
|
# List of language from http://thetvdb.com/api/0629B785CE550C8D/languages.xml
|
|
# Hard-coded here as it is relatively static, and saves another HTTP request, as
|
|
# recommended on http://thetvdb.com/wiki/index.php/API:languages.xml
|
|
self.config['valid_languages'] = [
|
|
'cs', 'da', 'de', 'el', 'en', 'es', 'fi', 'fr',
|
|
'he', 'hr', 'hu', 'it', 'ja', 'ko', 'nl', 'no',
|
|
'pl', 'pt', 'ru', 'sl', 'sv', 'tr', 'zh'
|
|
]
|
|
|
|
# not mapped: el, sl, tr. added as guess: fin, pol. unknown: _1
|
|
self.config['langabbv_23'] = {
|
|
'cs': 'ces', 'da': 'dan', 'de': 'deu', 'en': 'eng', 'es': 'spa', 'fi': 'fin', 'fr': 'fra',
|
|
'he': 'heb', 'hr': 'hrv', 'hu': 'hun', 'it': 'ita', 'ja': 'jpn', 'ko': 'kor', 'nb': 'nor',
|
|
'nl': 'nld', 'no': 'nor',
|
|
'pl': 'pol', 'pt': 'pot', 'ru': 'rus', 'sk': 'slv', 'sv': 'swe', 'zh': 'zho', '_1': 'srp',
|
|
}
|
|
self.config['valid_languages_3'] = list(self.config['langabbv_23'].values())
|
|
|
|
# TheTvdb.com should be based around numeric language codes,
|
|
# but to link to a series like http://thetvdb.com/?tab=series&id=79349&lid=16
|
|
# requires the language ID, thus this mapping is required (mainly
|
|
# for usage in tvdb_ui - internally tvdb_api will use the language abbreviations)
|
|
self.config['langabbv_to_id'] = {
|
|
'cs': 28, 'da': 10, 'de': 14, 'el': 20, 'en': 7, 'es': 16, 'fi': 11, 'fr': 17,
|
|
'he': 24, 'hr': 31, 'hu': 19, 'it': 15, 'ja': 25, 'ko': 32, 'nl': 13, 'no': 9,
|
|
'pl': 18, 'pt': 26, 'ru': 22, 'sl': 30, 'sv': 8, 'tr': 21, 'zh': 27
|
|
}
|
|
|
|
if not language:
|
|
self.config['language'] = 'en'
|
|
else:
|
|
if language not in self.config['valid_languages']:
|
|
raise ValueError('Invalid language %s, options are: %s' % (language, self.config['valid_languages']))
|
|
else:
|
|
self.config['language'] = language
|
|
|
|
# The following url_ configs are based of the
|
|
# http://thetvdb.com/wiki/index.php/Programmers_API
|
|
self.config['base_url'] = 'https://thetvdb.com/'
|
|
self.config['api3_url'] = 'https://api.thetvdb.com/'
|
|
|
|
self.config['url_search_series'] = '%(api3_url)ssearch/series' % self.config
|
|
self.config['params_search_series'] = {'name': ''}
|
|
|
|
self.config['url_series_episodes_info'] = '%(api3_url)sseries/%%s/episodes?page=%%s' % self.config
|
|
|
|
self.config['url_series_info'] = '%(api3_url)sseries/%%s' % self.config
|
|
self.config['url_episodes_info'] = '%(api3_url)sepisodes/%%s' % self.config
|
|
self.config['url_actors_info'] = '%(api3_url)sseries/%%s/actors' % self.config
|
|
|
|
self.config['url_series_images'] = '%(api3_url)sseries/%%s/images/query?keyType=%%s' % self.config
|
|
self.config['url_artworks'] = 'https://artworks.thetvdb.com/banners/%s'
|
|
self.config['url_artworks_search'] = 'https://artworks.thetvdb.com/%s'
|
|
|
|
self.config['url_people'] = '%(base_url)speople/%%s' % self.config
|
|
self.config['url_series_people'] = '%(base_url)sseries/%%s/people' % self.config
|
|
self.config['url_series_all'] = '%(base_url)sseries/%%s/allseasons/official' % self.config
|
|
self.config['url_series_dvd'] = '%(base_url)sseries/%%s/allseasons/dvd' % self.config
|
|
self.config['url_series_abs'] = '%(base_url)sseries/%%s/seasons/absolute/1' % self.config
|
|
|
|
def _search_show(self, name=None, ids=None, **kwargs):
|
|
# type: (AnyStr, Dict[integer_types, integer_types], Optional[Any]) -> List[TVInfoShow]
|
|
def make_tvinfoshow(data):
|
|
_ti_show = TVInfoShow()
|
|
_ti_show.id, _ti_show.banner, _ti_show.firstaired, _ti_show.poster, _ti_show.network, _ti_show.overview, \
|
|
_ti_show.seriesname, _ti_show.slug, _ti_show.status, _ti_show.aliases, _ti_show.ids = \
|
|
clean_data(data['id']), clean_data(data.get('banner')), clean_data(data.get('firstaired')), \
|
|
clean_data(data.get('poster')), clean_data(data.get('network')), clean_data(data.get('overview')), \
|
|
clean_data(data.get('seriesname')), clean_data(data.get('slug')), clean_data(data.get('status')), \
|
|
clean_data((data.get('aliases'))), TVInfoIDs(tvdb=try_int(clean_data(data['id'])))
|
|
return _ti_show
|
|
|
|
results = []
|
|
if ids:
|
|
if ids.get(TVINFO_TVDB):
|
|
cache_id_key = 's-id-%s-%s' % (TVINFO_TVDB, ids[TVINFO_TVDB])
|
|
is_none, shows = self._get_cache_entry(cache_id_key)
|
|
if not self.config.get('cache_search') or (None is shows and not is_none):
|
|
try:
|
|
d_m = self._get_show_data(ids.get(TVINFO_TVDB), self.config['language'], direct_data=True)
|
|
self._set_cache_entry(cache_id_key, d_m, expire=self.search_cache_expire)
|
|
except (BaseException, Exception):
|
|
d_m = None
|
|
else:
|
|
d_m = shows
|
|
if d_m:
|
|
results.append(make_tvinfoshow(d_m['data']))
|
|
if ids.get(TVINFO_TVDB_SLUG):
|
|
cache_id_key = 's-id-%s-%s' % (TVINFO_TVDB, ids[TVINFO_TVDB_SLUG])
|
|
is_none, shows = self._get_cache_entry(cache_id_key)
|
|
if not self.config.get('cache_search') or (None is shows and not is_none):
|
|
try:
|
|
d_m = self.get_series(ids.get(TVINFO_TVDB_SLUG).replace('-', ' '))
|
|
self._set_cache_entry(cache_id_key, d_m, expire=self.search_cache_expire)
|
|
except (BaseException, Exception):
|
|
d_m = None
|
|
else:
|
|
d_m = shows
|
|
if d_m:
|
|
for r in d_m:
|
|
if ids.get(TVINFO_TVDB_SLUG) == r['slug']:
|
|
results.append(make_tvinfoshow(r))
|
|
break
|
|
if name:
|
|
for n in ([name], name)[isinstance(name, list)]:
|
|
cache_name_key = 's-name-%s' % n
|
|
is_none, shows = self._get_cache_entry(cache_name_key)
|
|
if not self.config.get('cache_search') or (None is shows and not is_none):
|
|
try:
|
|
r = self.get_series(n)
|
|
self._set_cache_entry(cache_name_key, r, expire=self.search_cache_expire)
|
|
except (BaseException, Exception):
|
|
r = None
|
|
else:
|
|
r = shows
|
|
if r:
|
|
if not isinstance(r, list):
|
|
r = [r]
|
|
results.extend([make_tvinfoshow(_s) for _s in r])
|
|
|
|
seen = set()
|
|
results = [seen.add(r['id']) or r for r in results if r['id'] not in seen]
|
|
return results
|
|
|
|
def get_new_token(self):
|
|
global THETVDB_V2_API_TOKEN
|
|
token = THETVDB_V2_API_TOKEN.get('token', None)
|
|
dt = THETVDB_V2_API_TOKEN.get('datetime', datetime.datetime.fromordinal(1))
|
|
url = '%s%s' % (self.config['api3_url'], 'login')
|
|
params = {'apikey': self.config['apikey']}
|
|
resp = get_url(url.strip(), post_json=params, parse_json=True, raise_skip_exception=True)
|
|
if resp:
|
|
if 'token' in resp:
|
|
token = resp['token']
|
|
dt = datetime.datetime.now()
|
|
|
|
return {'token': token, 'datetime': dt}
|
|
|
|
def get_token(self):
|
|
global THETVDB_V2_API_TOKEN
|
|
if None is THETVDB_V2_API_TOKEN.get(
|
|
'token') or datetime.datetime.now() - THETVDB_V2_API_TOKEN.get(
|
|
'datetime', datetime.datetime.fromordinal(1)) > datetime.timedelta(hours=23):
|
|
THETVDB_V2_API_TOKEN = self.get_new_token()
|
|
if not THETVDB_V2_API_TOKEN.get('token'):
|
|
raise TvdbError('Could not get Authentification Token')
|
|
return THETVDB_V2_API_TOKEN.get('token')
|
|
|
|
@staticmethod
|
|
def _get_temp_dir():
|
|
"""Returns the [system temp dir]/tvdb_api-u501 (or
|
|
tvdb_api-myuser)
|
|
"""
|
|
if hasattr(os, 'getuid'):
|
|
uid = 'u%d' % (os.getuid())
|
|
else:
|
|
# For Windows
|
|
try:
|
|
uid = getpass.getuser()
|
|
except ImportError:
|
|
return os.path.join(tempfile.gettempdir(), 'tvdb_api')
|
|
|
|
return os.path.join(tempfile.gettempdir(), 'tvdb_api-%s' % uid)
|
|
|
|
def _match_url_pattern(self, pattern, url):
|
|
if pattern in self.config:
|
|
try:
|
|
if PY2:
|
|
return None is not re.search('^%s$' % re.escape(self.config[pattern]).replace('\\%s', '[^/]+'), url)
|
|
else:
|
|
return None is not re.search('^%s$' % re.escape(self.config[pattern]).replace(r'%s', '[^/]+'), url)
|
|
except (BaseException, Exception):
|
|
pass
|
|
return False
|
|
|
|
def is_apikey(self, check_url=None):
|
|
return bool(self.config['apikey']) and (None is check_url or '://api' in check_url)
|
|
|
|
@retry((TvdbError, TvdbTokenexpired))
|
|
def _load_url(self, url, params=None, language=None, parse_json=False, **kwargs):
|
|
log.debug('Retrieving URL %s' % url)
|
|
|
|
parse_json = parse_json or self.is_apikey(url)
|
|
session = requests.session()
|
|
|
|
if self.config['cache_enabled']:
|
|
session = CacheControl(session, cache=caches.FileCache(self.config['cache_location']))
|
|
|
|
if self.config['proxy']:
|
|
log.debug('Using proxy for URL: %s' % url)
|
|
session.proxies = {'http': self.config['proxy'], 'https': self.config['proxy']}
|
|
|
|
headers = {'Accept-Encoding': 'gzip,deflate'}
|
|
if self.is_apikey(url):
|
|
headers.update({'Authorization': 'Bearer %s' % self.get_token(),
|
|
'Accept': 'application/vnd.thetvdb.v%s' % __api_version__})
|
|
|
|
if None is not language and language in self.config['valid_languages']:
|
|
headers.update({'Accept-Language': language})
|
|
|
|
resp = None
|
|
is_series_info = self._match_url_pattern('url_series_info', url)
|
|
if is_series_info:
|
|
self.show_not_found = False
|
|
self.not_found = False
|
|
try:
|
|
resp = get_url(url.strip(), params=params, session=session, headers=headers, parse_json=parse_json,
|
|
raise_status_code=True, raise_exceptions=True, raise_skip_exception=True, **kwargs)
|
|
except ConnectionSkipException as e:
|
|
raise e
|
|
except requests.exceptions.HTTPError as e:
|
|
if 401 == e.response.status_code:
|
|
if self.is_apikey(url):
|
|
# token expired, get new token, raise error to retry
|
|
global THETVDB_V2_API_TOKEN
|
|
THETVDB_V2_API_TOKEN = self.get_new_token()
|
|
raise TvdbTokenexpired
|
|
elif 404 == e.response.status_code:
|
|
if is_series_info:
|
|
self.show_not_found = True
|
|
elif self._match_url_pattern('url_series_episodes_info', url):
|
|
resp = {'data': []}
|
|
self.not_found = True
|
|
elif 404 != e.response.status_code:
|
|
raise TvdbError
|
|
except (BaseException, Exception):
|
|
raise TvdbError
|
|
|
|
if is_series_info and isinstance(resp, dict) and isinstance(resp.get('data'), dict) and \
|
|
isinstance(resp['data'].get('seriesName'), string_types) and \
|
|
re.search(r'^[*]\s*[*]\s*[*]', resp['data'].get('seriesName', ''), flags=re.I):
|
|
self.show_not_found = True
|
|
self.not_found = True
|
|
|
|
map_show = {'airstime': 'airs_time', 'airsdayofweek': 'airs_dayofweek', 'imdbid': 'imdb_id',
|
|
'writers': 'writer', 'siterating': 'rating'}
|
|
|
|
def map_show_keys(data):
|
|
keep_data = {}
|
|
del_keys = []
|
|
new_data = {}
|
|
for k, v in iteritems(data):
|
|
k_org = k
|
|
k = k.lower()
|
|
if None is not v:
|
|
if k in ['banner', 'fanart', 'poster', 'image'] and v:
|
|
v = (self.config['url_artworks'],
|
|
self.config['url_artworks_search'])[
|
|
isinstance(v, string_types) and v.lstrip('/').startswith('banners/')] % v.lstrip('/')
|
|
elif 'genre' == k:
|
|
keep_data['genre_list'] = v
|
|
v = '|'.join([clean_data(c) for c in v if isinstance(c, string_types)])
|
|
elif 'gueststars' == k:
|
|
keep_data['gueststars_list'] = v
|
|
v = '|%s|' % '|'.join([clean_data(c) for c in v if isinstance(c, string_types)])
|
|
elif 'writers' == k:
|
|
keep_data[k] = v
|
|
v = '|%s|' % '|'.join([clean_data(c) for c in v if isinstance(c, string_types)])
|
|
elif 'rating' == k:
|
|
new_data['contentrating'] = v
|
|
elif 'firstaired' == k:
|
|
if v:
|
|
try:
|
|
v = parse(v, fuzzy=True).strftime('%Y-%m-%d')
|
|
except (BaseException, Exception):
|
|
v = None
|
|
else:
|
|
v = None
|
|
elif 'imdbid' == k:
|
|
if v:
|
|
if re.search(r'^(tt)?\d{1,9}$', v, flags=re.I):
|
|
v = clean_data(v)
|
|
else:
|
|
v = ''
|
|
else:
|
|
v = clean_data(v)
|
|
|
|
if not v and 'seriesname' == k:
|
|
if isinstance(data.get('aliases'), list) and 0 < len(data.get('aliases')):
|
|
v = data['aliases'].pop(0)
|
|
# this is a invalid show, it has no Name
|
|
if not v:
|
|
return None
|
|
|
|
if k in map_show:
|
|
k = map_show[k]
|
|
if k_org is not k:
|
|
del_keys.append(k_org)
|
|
new_data[k] = v
|
|
else:
|
|
data[k] = v
|
|
for d in del_keys:
|
|
del (data[d])
|
|
if isinstance(data, dict):
|
|
data.update(new_data)
|
|
data.update(keep_data)
|
|
return data
|
|
|
|
if resp and isinstance(resp, dict):
|
|
if isinstance(resp.get('data'), dict):
|
|
resp['data'] = map_show_keys(resp['data'])
|
|
elif isinstance(resp.get('data'), list):
|
|
data_list = []
|
|
for idx, row in enumerate(resp['data']):
|
|
if isinstance(row, dict):
|
|
cr = map_show_keys(row)
|
|
if None is not cr:
|
|
data_list.append(cr)
|
|
resp['data'] = data_list
|
|
return resp
|
|
return dict([('data', (None, resp)[isinstance(resp, string_types)])])
|
|
|
|
def _getetsrc(self, url, params=None, language=None, parse_json=False):
|
|
"""Loads a URL using caching
|
|
"""
|
|
try:
|
|
src = self._load_url(url, params=params, language=language, parse_json=parse_json)
|
|
if isinstance(src, dict):
|
|
if None is not src['data']:
|
|
data = src['data']
|
|
else:
|
|
data = {}
|
|
# data = src['data'] or {}
|
|
if isinstance(data, list):
|
|
if 0 < len(data):
|
|
data = data[0]
|
|
# data = data[0] or {}
|
|
if None is data or (isinstance(data, dict) and 1 > len(data.keys())):
|
|
raise ValueError
|
|
return src
|
|
except (KeyError, IndexError, Exception):
|
|
pass
|
|
|
|
@staticmethod
|
|
def clean_overview(text):
|
|
"""replace newlines with period and space, remove multiple spaces"""
|
|
return ' '.join(['%s.' % re.sub(r'[\s][\s]+', r' ', x).strip().rstrip('.') for x in text.split('\r\n')])
|
|
|
|
def get_show_info(self, sid, language=None):
|
|
# type: (int, Optional[str]) -> Optional[dict]
|
|
results = self.search_tvs(sid, language=language)
|
|
for cur_result in (isinstance(results, dict) and results.get('results') or []):
|
|
result = list(filter(lambda r: 'series' == r['type'] and sid == r['id'],
|
|
cur_result.get('nbHits') and cur_result.get('hits') or []))
|
|
if 1 == len(result):
|
|
result[0]['overview'] = self.clean_overview(
|
|
result[0]['overviews'][self.config['langabbv_23'].get(language) or 'eng'])
|
|
# remap
|
|
for from_key, to_key in iteritems({
|
|
'name': 'seriesname', 'first_air_date': 'firstaired'
|
|
}):
|
|
result[0][to_key] = result[0][from_key]
|
|
del result[0][from_key] # delete also prevents false +ve with the following new key notifier
|
|
|
|
# notify of new keys
|
|
if ENV.get('SG_DEV_MODE'):
|
|
new_keys = set(list(result[0])).difference({
|
|
'_highlightResult', 'aliases', 'banner',
|
|
'fanart', 'firstaired', 'follower_count',
|
|
'id', 'image', 'is_tvdb_searchable', 'is_tvt_searchable',
|
|
'seriesname', 'network',
|
|
'objectID', 'overviews', 'poster', 'release_year',
|
|
'slug', 'status',
|
|
'translations', 'type',
|
|
'url', 'uuid'
|
|
})
|
|
if new_keys:
|
|
log.warning('DEV_MODE: New get_show_info tvdb attrs for %s %r' % (sid, new_keys))
|
|
|
|
return result[0]
|
|
|
|
# fallback : e.g. https://thetvdb.com/?tab=series&id=349309&lid=7
|
|
response = self._load_url(self.config['base_url'], params={
|
|
'tab': 'series', 'id': sid, 'lid': self.config['langabbv_to_id'].get(language, 7)})
|
|
series = {}
|
|
|
|
def get_value(tag, contains):
|
|
try:
|
|
rc_contains = re.compile(r'(?i)%s' % contains)
|
|
parent = copy.copy(tag.find(string=rc_contains, recursive=True).find_parent(class_=re.compile('item')))
|
|
return ', '.join(re.sub(r'(?i)(\s)([\s]+)', r'\1', i.get_text(strip=True))
|
|
for i in parent.find_all('span'))
|
|
except(BaseException, Exception):
|
|
pass
|
|
|
|
with BS4Parser(response.get('data', '')) as soup:
|
|
basic_info = soup.find(id='series_basic_info')
|
|
series_id = try_int(get_value(basic_info, r'series\sid'), None)
|
|
if None is not series_id:
|
|
series['id'] = series_id
|
|
series['firstaired'] = None # fill from ep listings page
|
|
series['genrelist'] = get_value(basic_info, 'genres').split(', ') # extra field
|
|
series['genre'] = '|'.join(series['genrelist'])
|
|
series['language'] = language
|
|
series['seriesname'] = soup.find(id='series_title').get_text(strip=True)
|
|
series['networklist'] = get_value(basic_info, 'network').split(', ') # extra field
|
|
series['network'] = '|%s|' % '|'.join(series['networklist']) # e.g. '|network|network n|network 10|'
|
|
series['status'] = get_value(basic_info, 'status')
|
|
series['type'] = 'series' # extra field
|
|
|
|
airs_at = get_value(basic_info, 'airs')
|
|
airs = airs_at and airs_at.split(', ') or []
|
|
if 0 < len(airs):
|
|
series['airs_time'] = 'at ' in airs[-1] \
|
|
and re.sub(r'(?i)\s+([ap]m)', r'\1', airs[-1]).split()[-1] or ''
|
|
series['airs_dayofweek'] = ', '.join(airs[0:-1])
|
|
else:
|
|
series['airs_time'] = airs_at
|
|
series['airs_dayofweek'] = ''
|
|
|
|
# alias list
|
|
series['aliases'] = []
|
|
try:
|
|
lang_tag = soup.find(id='translations').select('.change_translation_text[data-language="%s"]' % (
|
|
self.config['langabbv_23'].get(language) or 'eng'))[0]
|
|
series['aliases'] = [t.get_text(strip=True) for t in lang_tag
|
|
.find(string=re.compile('(?i)alias'), recursive=True).find_parent()
|
|
.find_next_sibling('ul').find_all('li')]
|
|
except(BaseException, Exception):
|
|
pass
|
|
|
|
# images
|
|
series['image'] = series['poster'] = (soup.find(rel=re.compile('artwork_posters')) or {}).get('href')
|
|
series['banner'] = (soup.find(rel=re.compile('artwork_banners')) or {}).get('href')
|
|
series['fanart'] = (soup.find(rel=re.compile('artwork_backgrounds')) or {}).get('href')
|
|
|
|
series['imdb_id'] = re.sub(r'.*(tt\d+)', r'\1',
|
|
(soup.find(href=re.compile(r'imdb\.com')) or {}).get('href', ''))
|
|
|
|
# {lang: overview}
|
|
series.setdefault('overviews', {})
|
|
for cur_tag in soup.find_all(class_='change_translation_text'):
|
|
try:
|
|
lang = cur_tag.attrs.get('data-language')
|
|
if None is not lang:
|
|
text = cur_tag.p.get_text(strip=True)
|
|
if text:
|
|
text = self.clean_overview(text)
|
|
series['overviews'].setdefault(lang, text) # extra field
|
|
if lang == self.config['langabbv_23'].get(language):
|
|
series['overview'] = text
|
|
except(BaseException, Exception):
|
|
pass
|
|
|
|
runtime = get_value(basic_info, 'runtime')
|
|
runtime_often = None
|
|
if ', ' in runtime:
|
|
try:
|
|
# sort runtimes by most number of episodes (e.g. '25 minutes (700 episodes)')
|
|
runtime_often = sorted([re.findall(r'([^(]+)\((\d+).*', i)[0] for i in runtime.split(', ')],
|
|
key=lambda x: try_int(x[1]), reverse=True)
|
|
runtime_often = next(iter(runtime_often))[0].strip() # first item is most frequent runtime
|
|
except(BaseException, Exception):
|
|
runtime_often = None
|
|
series['runtime'] = runtime_often and re.sub('^([0-9]+).*', r'\1', runtime_often) or runtime
|
|
|
|
series['season'] = None
|
|
try:
|
|
last_season = sorted([x.get('href')
|
|
for x in soup.find_all(href=re.compile(r'/seasons/official/(\d+)'))])[-1]
|
|
series['season'] = re.findall(r'(\d+)$', last_season)[0]
|
|
except(BaseException, Exception):
|
|
pass
|
|
|
|
series['slug'] = series['url'] = ''
|
|
try:
|
|
rc_slug = re.compile('(?i)/series/(?P<slug>[^/]+)/(?:episode|season)')
|
|
series['slug'] = rc_slug.search(soup.find(href=rc_slug).get('href')).group('slug')
|
|
series['url'] = '%sseries/%s' % (self.config['base_url'], series['slug']) # extra field
|
|
except(BaseException, Exception):
|
|
pass
|
|
|
|
# {lang: show title in lang} # extra field
|
|
series['translations'] = {t.attrs.get('data-language'): t.attrs.get('data-title')
|
|
for t in soup.find_all(class_='change_translation_text')
|
|
if all(t.attrs.get(a) for a in ('data-title', 'data-language'))}
|
|
|
|
return series
|
|
|
|
def search_tvs(self, terms, language=None):
|
|
# type: (Union[int, str], Optional[str]) -> Optional[dict]
|
|
try:
|
|
src = self._load_url(
|
|
'https://tvshow''time-%s.algo''lia.net/1/'
|
|
'indexes/*/queries' % random.choice([1, 2, 3, 'dsn']),
|
|
params={'x-algo''lia-agent': 'Alg''olia for vani''lla JavaScript (lite) 3.3''2.0;'
|
|
'instant''search.js (3.5''.3);JS Helper (2.2''8.0)',
|
|
'x-algo''lia''-app''lication-id': 'tvshow''time',
|
|
'x-algo''lia''-ap''i-key': '3d''978dd96c457390f21cec6131ce5d''9c'[::-1]},
|
|
post_json={'requests': [
|
|
{'indexName': 'TVDB',
|
|
'params': '&'.join(
|
|
['query=%s' % terms, 'maxValuesPerFacet=10', 'page=0',
|
|
'facetFilters=[["type:series", "type:person"]]',
|
|
'tagFilters=', 'analytics=false', 'advancedSyntax=true',
|
|
'highlightPreTag=__ais-highlight__', 'highlightPostTag=__/ais-highlight__'
|
|
])
|
|
}]},
|
|
language=language, parse_json=True)
|
|
return src
|
|
except (KeyError, IndexError, Exception):
|
|
pass
|
|
|
|
def search(self, series):
|
|
# type: (AnyStr) -> List
|
|
"""This searches TheTVDB.com for the series name
|
|
and returns the result list
|
|
"""
|
|
if PY2:
|
|
series = series.encode('utf-8')
|
|
self.config['params_search_series']['name'] = series
|
|
log.debug('Searching for show %s' % series)
|
|
|
|
try:
|
|
series_found = self._getetsrc(self.config['url_search_series'], params=self.config['params_search_series'],
|
|
language=self.config['language'])
|
|
if series_found:
|
|
return list(series_found.values())[0]
|
|
except (BaseException, Exception):
|
|
pass
|
|
|
|
return []
|
|
|
|
def get_series(self, series):
|
|
"""This searches TheTVDB.com for the series name,
|
|
If a custom_ui UI is configured, it uses this to select the correct
|
|
series. If not, and interactive == True, ConsoleUI is used, if not
|
|
BaseUI is used to select the first result.
|
|
"""
|
|
all_series = self.search(series)
|
|
if not isinstance(all_series, list):
|
|
all_series = [all_series]
|
|
|
|
if 0 == len(all_series):
|
|
log.debug('Series result returned zero')
|
|
raise TvdbShownotfound('Show-name search returned zero results (cannot find show on TVDB)')
|
|
|
|
if None is not self.config['custom_ui']:
|
|
log.debug('Using custom UI %s' % self.config['custom_ui'].__name__)
|
|
custom_ui = self.config['custom_ui']
|
|
ui = custom_ui(config=self.config)
|
|
else:
|
|
if not self.config['interactive']:
|
|
log.debug('Auto-selecting first search result using BaseUI')
|
|
ui = BaseUI(config=self.config)
|
|
else:
|
|
log.debug('Interactively selecting show using ConsoleUI')
|
|
ui = ConsoleUI(config=self.config)
|
|
|
|
return ui.select_series(all_series)
|
|
|
|
def _parse_banners(self, sid, img_list):
|
|
banners = {}
|
|
|
|
try:
|
|
for cur_banner in img_list:
|
|
bid = cur_banner['id']
|
|
btype = (cur_banner['keytype'], 'banner')['series' == cur_banner['keytype']]
|
|
btype2 = (cur_banner['resolution'], try_int(cur_banner['subkey'], cur_banner['subkey']))[
|
|
btype in ('season', 'seasonwide')]
|
|
if None is btype or None is btype2:
|
|
continue
|
|
|
|
for k, v in iteritems(cur_banner):
|
|
if None is k or None is v:
|
|
continue
|
|
|
|
k, v = k.lower(), v.lower() if isinstance(v, string_types) else v
|
|
if 'filename' == k:
|
|
k = 'bannerpath'
|
|
v = self._make_image(self.config['url_artworks'], v)
|
|
elif 'thumbnail' == k:
|
|
k = 'thumbnailpath'
|
|
v = self._make_image(self.config['url_artworks'], v)
|
|
elif 'keytype' == k:
|
|
k = 'bannertype'
|
|
banners.setdefault(btype, OrderedDict()).setdefault(btype2, OrderedDict()).setdefault(bid, {})[
|
|
k] = v
|
|
|
|
except (BaseException, Exception):
|
|
pass
|
|
|
|
self._set_show_data(sid, '_banners', banners, add=True)
|
|
|
|
@staticmethod
|
|
def _make_image(base_url, url):
|
|
# type: (str, str) -> str
|
|
if not url or url.lower().startswith('http'):
|
|
return url or ''
|
|
return base_url % url
|
|
|
|
def _parse_actors(self, sid, actor_list, actor_list_alt):
|
|
|
|
a = []
|
|
cast = CastList()
|
|
try:
|
|
alts = {}
|
|
if actor_list_alt:
|
|
with BS4Parser(actor_list_alt) as soup:
|
|
rc_role = re.compile(r'/series/(?P<show_slug>[^/]+)/people/(?P<role_id>\d+)/?$')
|
|
rc_img = re.compile(r'/(?P<url>person/(?P<person_id>[0-9]+)/(?P<img_id>[^/]+)\..*)')
|
|
rc_img_v3 = re.compile(r'/(?P<url>actors/(?P<img_id>[^/]+)\..*)')
|
|
max_people = 5
|
|
rc_clean = re.compile(r'[^a-z0-9]')
|
|
for cur_enum, cur_role in enumerate(soup.find_all('a', href=rc_role) or []):
|
|
try:
|
|
image = person_id = None
|
|
for cur_rc in (rc_img, rc_img_v3):
|
|
img_tag = cur_role.find('img', src=cur_rc)
|
|
if img_tag:
|
|
img_parsed = cur_rc.search(img_tag.get('src'))
|
|
image, person_id = [x in img_parsed.groupdict() and img_parsed.group(x)
|
|
for x in ('url', 'person_id')]
|
|
break
|
|
lines = [x.strip() for x in cur_role.get_text().split('\n') if x.strip()][0:2]
|
|
name = role = ''
|
|
if len(lines):
|
|
name = lines[0]
|
|
for line in lines[1:]:
|
|
if line.lower().startswith('as '):
|
|
role = line[3:]
|
|
break
|
|
if not person_id and max_people:
|
|
max_people -= 1
|
|
results = self.search_tvs(name)
|
|
try:
|
|
for cur_result in (isinstance(results, dict) and results.get('results') or []):
|
|
# sorts 'banners/images/missing/' to last before filter
|
|
people = list(filter(
|
|
lambda r: 'person' == r['type']
|
|
and rc_clean.sub(name, '') == rc_clean.sub(r['name'], ''),
|
|
cur_result.get('nbHits')
|
|
and sorted(cur_result.get('hits'),
|
|
key=lambda x: len(x['image']), reverse=True) or []))
|
|
if ENV.get('SG_DEV_MODE'):
|
|
for person in people:
|
|
new_keys = set(list(person)).difference({
|
|
'_highlightResult', 'banner', 'id', 'image',
|
|
'is_tvdb_searchable', 'is_tvt_searchable', 'name',
|
|
'objectID', 'people_birthdate', 'people_died',
|
|
'poster', 'type', 'url'
|
|
})
|
|
if new_keys:
|
|
log.warning('DEV_MODE: New _parse_actors tvdb attrs for %s %r'
|
|
% (person['id'], new_keys))
|
|
|
|
person_ok = False
|
|
for person in people:
|
|
if image:
|
|
people_data = self._load_url(person['url'])['data']
|
|
person_ok = re.search(re.escape(image), people_data)
|
|
if not image or person_ok:
|
|
person_id = person['id']
|
|
raise ValueError('value okay, id found')
|
|
except (BaseException, Exception):
|
|
pass
|
|
|
|
rid = int(rc_role.search(cur_role.get('href')).group('role_id'))
|
|
alts.setdefault(rid, {'id': rid, 'person_id': person_id or None, 'name': name, 'role': role,
|
|
'image': image, 'sortorder': cur_enum, 'lastupdated': 0})
|
|
except(BaseException, Exception):
|
|
pass
|
|
if not self.is_apikey(): # for the future when apikey == ''
|
|
actor_list = sorted([d for _, d in iteritems(alts)], key=lambda x: x.get('sortorder'))
|
|
|
|
unique_c_p, c_p_list, new_actor_list = set(), [], []
|
|
for actor in sorted(actor_list, key=lambda x: x.get('lastupdated'), reverse=True):
|
|
c_p_list.append((actor['name'], actor['role']))
|
|
if (actor['name'], actor['role']) not in unique_c_p:
|
|
unique_c_p.add((actor['name'], actor['role']))
|
|
new_actor_list.append(actor)
|
|
for n in sorted(new_actor_list, key=lambda x: x['sortorder']):
|
|
role_image = (alts.get(n['id'], {}).get('image'), n.get('image'))[
|
|
any([n.get('image')]) and 1 == c_p_list.count((n['name'], n['role']))]
|
|
if role_image:
|
|
role_image = self._make_image(self.config['url_artworks'], role_image)
|
|
character_name = n.get('role', '').strip() or alts.get(n['id'], {}).get('role', '')
|
|
person_name = n.get('name', '').strip() or alts.get(n['id'], {}).get('name', '')
|
|
person_id = None
|
|
person_id = person_id or alts.get(n['id'], {}).get('person_id')
|
|
character_id = n.get('id', None) or alts.get(n['id'], {}).get('rid')
|
|
a.append({'character': {'id': character_id,
|
|
'name': character_name,
|
|
'url': None, # not supported by tvdb
|
|
'image': role_image,
|
|
},
|
|
'person': {'id': person_id,
|
|
'name': person_name,
|
|
'url': person_id and (self.config['url_people'] % person_id) or None,
|
|
'image': None, # not supported by tvdb
|
|
'birthday': None, # not supported by tvdb
|
|
'deathday': None, # not supported by tvdb
|
|
'gender': None, # not supported by tvdb
|
|
'country': None, # not supported by tvdb
|
|
},
|
|
})
|
|
cast[RoleTypes.ActorMain].append(
|
|
TVInfoCharacter(
|
|
p_id=character_id, name=character_name, person=[TVInfoPerson(p_id=person_id, name=person_name)],
|
|
image=role_image, show=self.ti_shows[sid]))
|
|
except (BaseException, Exception):
|
|
pass
|
|
self._set_show_data(sid, 'actors', a)
|
|
self._set_show_data(sid, 'cast', cast)
|
|
self.ti_shows[sid].actors_loaded = True
|
|
|
|
def get_episode_data(self, epid):
|
|
# Parse episode information
|
|
data = None
|
|
log.debug('Getting all episode data for %s' % epid)
|
|
url = self.config['url_episodes_info'] % epid
|
|
episode_data = self._getetsrc(url, language=self.config['language'])
|
|
|
|
if episode_data and 'data' in episode_data:
|
|
data = episode_data['data']
|
|
if isinstance(data, dict):
|
|
for k, v in iteritems(data):
|
|
k = k.lower()
|
|
|
|
if None is not v:
|
|
if 'filename' == k and v:
|
|
v = self._make_image(self.config['url_artworks'], v)
|
|
else:
|
|
v = clean_data(v)
|
|
data[k] = v
|
|
|
|
return data
|
|
|
|
def _parse_images(self, sid, language, show_data, image_type, enabled_type, type_bool):
|
|
mapped_img_types = {'banner': 'series'}
|
|
excluded_main_data = enabled_type in ['seasons_enabled', 'seasonwides_enabled']
|
|
loaded_name = '%s_loaded' % image_type
|
|
if (type_bool or self.config[enabled_type]) and not getattr(self.ti_shows.get(sid), loaded_name, False):
|
|
image_data = self._getetsrc(self.config['url_series_images'] %
|
|
(sid, mapped_img_types.get(image_type, image_type)), language=language)
|
|
if image_data and 0 < len(image_data.get('data', '') or ''):
|
|
image_data['data'] = sorted(image_data['data'], reverse=True,
|
|
key=lambda x: (x['ratingsinfo']['average'], x['ratingsinfo']['count']))
|
|
if not excluded_main_data:
|
|
url_image = self._make_image(self.config['url_artworks'], image_data['data'][0]['filename'])
|
|
url_thumb = self._make_image(self.config['url_artworks'], image_data['data'][0]['thumbnail'])
|
|
self._set_show_data(sid, image_type, url_image)
|
|
self._set_show_data(sid, f'{image_type}_thumb', url_thumb)
|
|
excluded_main_data = True # artwork found so prevent fallback
|
|
self._parse_banners(sid, image_data['data'])
|
|
self.ti_shows[sid].__dict__[loaded_name] = True
|
|
|
|
# fallback image thumbnail for none excluded_main_data if artwork is not found
|
|
if not excluded_main_data and show_data.get(image_type):
|
|
self._set_show_data(sid, f'{image_type}_thumb',
|
|
re.sub(r'\.jpg$', '_t.jpg', show_data[image_type], flags=re.I))
|
|
|
|
def _get_show_data(self,
|
|
sid, # type: integer_types
|
|
language, # type: AnyStr
|
|
get_ep_info=False, # type: bool
|
|
banners=False, # type: bool
|
|
posters=False, # type: bool
|
|
seasons=False, # type: bool
|
|
seasonwides=False, # type: bool
|
|
fanart=False, # type: bool
|
|
actors=False, # type: bool
|
|
direct_data=False, # type: bool
|
|
**kwargs # type: Optional[Any]
|
|
): # type: (...) -> Optional[bool, dict]
|
|
"""Takes a series ID, gets the epInfo URL and parses the TVDB
|
|
XML file into the shows dict in layout:
|
|
shows[series_id][season_number][episode_number]
|
|
"""
|
|
|
|
# Parse show information
|
|
url = self.config['url_series_info'] % sid
|
|
if direct_data or sid not in self.ti_shows or None is self.ti_shows[sid].id or \
|
|
language != self.ti_shows[sid].language:
|
|
log.debug('Getting all series data for %s' % sid)
|
|
show_data = self._getetsrc(url, language=language)
|
|
if not show_data or not show_data.get('data'):
|
|
show_data = {'data': self.get_show_info(sid, language=language)}
|
|
if direct_data:
|
|
return show_data
|
|
|
|
# check and make sure we have data to process and that it contains a series name
|
|
if not (show_data and 'seriesname' in show_data.get('data', {}) or {}):
|
|
return False
|
|
|
|
show_data = show_data['data']
|
|
ti_show = self.ti_shows[sid] # type: TVInfoShow
|
|
ti_show.banner_loaded = ti_show.poster_loaded = ti_show.fanart_loaded = True
|
|
ti_show.id = show_data['id']
|
|
ti_show.seriesname = clean_data(show_data.get('seriesname'))
|
|
ti_show.slug = clean_data(show_data.get('slug'))
|
|
ti_show.poster = clean_data(show_data.get('poster'))
|
|
ti_show.banner = clean_data(show_data.get('banner'))
|
|
ti_show.fanart = clean_data(show_data.get('fanart'))
|
|
ti_show.firstaired = clean_data(show_data.get('firstAired'))
|
|
ti_show.rating = show_data.get('rating')
|
|
ti_show.contentrating = clean_data(show_data.get('contentRatings'))
|
|
ti_show.aliases = show_data.get('aliases') or []
|
|
ti_show.status = clean_data(show_data['status'])
|
|
if clean_data(show_data.get('network')):
|
|
ti_show.network = clean_data(show_data['network'])
|
|
ti_show.networks = [TVInfoNetwork(clean_data(show_data['network']),
|
|
n_id=clean_data(show_data.get('networkid')))]
|
|
ti_show.runtime = try_int(show_data.get('runtime'), 0)
|
|
ti_show.language = clean_data(show_data.get('language'))
|
|
ti_show.genre = clean_data(show_data.get('genre'))
|
|
ti_show.genre_list = clean_data(show_data.get('genre_list')) or []
|
|
ti_show.overview = clean_data(show_data.get('overview'))
|
|
ti_show.imdb_id = clean_data(show_data.get('imdb_id')) or None
|
|
ti_show.airs_time = clean_data(show_data.get('airs_time'))
|
|
ti_show.airs_dayofweek = clean_data(show_data.get('airs_dayofweek'))
|
|
ti_show.ids = TVInfoIDs(tvdb=ti_show.id, imdb=try_int(str(ti_show.imdb_id).replace('tt', ''), None))
|
|
|
|
else:
|
|
show_data = {'data': {}}
|
|
|
|
for img_type, en_type, p_type in [('poster', 'posters_enabled', posters),
|
|
('banner', 'banners_enabled', banners),
|
|
('fanart', 'fanart_enabled', fanart),
|
|
('season', 'seasons_enabled', seasons),
|
|
('seasonwide', 'seasonwides_enabled', seasonwides)]:
|
|
self._parse_images(sid, language, show_data, img_type, en_type, p_type)
|
|
|
|
if (actors or self.config['actors_enabled']) and not getattr(self.ti_shows.get(sid), 'actors_loaded', False):
|
|
actor_data = self._getetsrc(self.config['url_actors_info'] % sid, language=language)
|
|
actor_data_alt = self._getetsrc(self.config['url_series_people'] % sid, language=language)
|
|
if actor_data and 0 < len(actor_data.get('data', '') or '') or actor_data_alt and actor_data_alt['data']:
|
|
self._parse_actors(sid, actor_data and actor_data.get('data', ''), actor_data_alt and actor_data_alt['data'])
|
|
|
|
if get_ep_info and not getattr(self.ti_shows.get(sid), 'ep_loaded', False):
|
|
# Parse episode data
|
|
log.debug('Getting all episodes of %s' % sid)
|
|
|
|
page = 0 # type: int
|
|
episodes = [] # type: list
|
|
episode_data_found = False # type: bool
|
|
episode_data_broken = False # type: bool
|
|
page_count = 0 # type: int
|
|
pages_loaded = 0 # type: int
|
|
start_page = 0 # type: int
|
|
while page <= 400:
|
|
episode_data = {}
|
|
if self.is_apikey() and not episode_data_broken:
|
|
episode_data = self._getetsrc(
|
|
self.config['url_series_episodes_info'] % (sid, page), language=language)
|
|
# fallback to correct old pagination
|
|
if 0 == page and None is episode_data:
|
|
page = 1
|
|
continue
|
|
if episode_data:
|
|
if 1 == page and not bool(episodes):
|
|
start_page = 1
|
|
pages_loaded += 1
|
|
episode_data_found |= start_page == page and bool(episode_data)
|
|
|
|
if episode_data_broken or \
|
|
(not episode_data_found and isinstance(show_data, dict) and 'slug' in show_data):
|
|
response = {'data': None}
|
|
items_found = False
|
|
# fallback to page 'all' if dvd is enabled and response has no items
|
|
for page_type in ('url_series_dvd', 'url_series_all'):
|
|
if 'dvd' not in page_type or self.config['dvdorder']:
|
|
response = self._load_url(self.config[page_type] % show_data.get('slug'))
|
|
with BS4Parser(response.get('data') or '') as soup:
|
|
items_found = bool(soup.find_all(class_='list-group-item'))
|
|
if items_found:
|
|
break
|
|
if not items_found:
|
|
break
|
|
|
|
episode_data = {'data': []}
|
|
with BS4Parser(response.get('data')) as soup:
|
|
items = soup.find_all(class_='list-group-item')
|
|
rc_sxe = re.compile(r'(?i)s(?:pecial\s*)?(\d+)\s*[xe]\s*(\d+)') # Special nxn or SnnEnn
|
|
rc_episode = re.compile(r'(?i)/series/%s/episodes?/(?P<ep_id>\d+)' % show_data['slug'])
|
|
rc_date = re.compile(r'\s\d{4}\s*$')
|
|
season_type, episode_type = ['%s%s' % (('aired', 'dvd')['dvd' in page_type], x)
|
|
for x in ('season', 'episodenumber')]
|
|
for cur_item in items:
|
|
try:
|
|
heading_tag = cur_item.find(class_='list-group-item-heading')
|
|
sxe = heading_tag.find(class_='episode-label').get_text(strip=True)
|
|
ep_season, ep_episode = [try_int(x) for x in rc_sxe.findall(sxe)[0]]
|
|
link_ep_tag = heading_tag.find(href=rc_episode) or {}
|
|
link_match = rc_episode.search(link_ep_tag.get('href', ''))
|
|
ep_id = link_match and try_int(link_match.group('ep_id'), None)
|
|
ep_name = link_ep_tag.get_text(strip=True)
|
|
# ep_network = None # extra field
|
|
ep_aired = None
|
|
for cur_tag in cur_item.find('ul').find_all('li'):
|
|
text = cur_tag.get_text(strip=True)
|
|
if rc_date.search(text):
|
|
ep_aired = parse(text).strftime('%Y-%m-%d')
|
|
# elif text in show_data['data']['network']: # unreliable data
|
|
# ep_network = text
|
|
ep_overview = None
|
|
item_tag = cur_item.find(class_='list-group-item-text')
|
|
if item_tag:
|
|
ep_overview = self.clean_overview(item_tag.get_text() or '')
|
|
ep_filename = None
|
|
link_ep_tag = item_tag.find(href=rc_episode) or None
|
|
if link_ep_tag:
|
|
ep_filename = (link_ep_tag.find('img') or {}).get('src', '')
|
|
|
|
episode_data['data'].append({
|
|
'id': ep_id, season_type: ep_season, episode_type: ep_episode,
|
|
'episodename': ep_name, 'firstaired': ep_aired, 'overview': ep_overview,
|
|
'filename': ep_filename, # 'network': ep_network
|
|
})
|
|
|
|
if not show_data['firstaired'] and ep_aired \
|
|
and (1, 1) == (ep_season, ep_episode):
|
|
show_data['firstaired'] = ep_aired
|
|
|
|
episode_data['fallback'] = True
|
|
except (BaseException, Exception):
|
|
continue
|
|
|
|
if episode_data_found and not episode_data:
|
|
if pages_loaded < page_count or 0 == page_count:
|
|
episode_data_broken = True
|
|
continue
|
|
else:
|
|
break
|
|
|
|
if None is episode_data and not bool(episodes) and not episode_data_found:
|
|
raise TvdbError('Exception retrieving episodes for show')
|
|
if isinstance(episode_data, dict) and not episode_data.get('data', []):
|
|
if start_page != page:
|
|
self.not_found = False
|
|
break
|
|
if not getattr(self, 'not_found', False) and None is not episode_data.get('data'):
|
|
episodes.extend(episode_data['data'])
|
|
next_link = episode_data.get('links', {}).get('next', None)
|
|
# check if page is a valid following page
|
|
if not isinstance(next_link, integer_types) or next_link <= page:
|
|
next_link = None
|
|
if isinstance(episode_data, dict) and 'links' in episode_data \
|
|
and isinstance(episode_data['links'], dict) and 'last' in episode_data['links'] \
|
|
and isinstance(episode_data['links']['last'], int) \
|
|
and episode_data['links']['last'] > page_count:
|
|
page_count = episode_data['links']['last']
|
|
if not next_link and isinstance(episode_data, dict) \
|
|
and isinstance(episode_data.get('data', []), list) and \
|
|
(100 > len(episode_data.get('data', [])) or episode_data.get('fallback')):
|
|
break
|
|
if isinstance(next_link, int) and page + 1 == next_link:
|
|
page = next_link
|
|
else:
|
|
page += 1
|
|
|
|
ep_map_keys = {'absolutenumber': 'absolute_number', 'airedepisodenumber': 'episodenumber',
|
|
'airedseason': 'seasonnumber', 'airedseasonid': 'seasonid',
|
|
'dvdepisodenumber': 'dvd_episodenumber', 'dvdseason': 'dvd_season'}
|
|
|
|
for cur_ep in episodes:
|
|
if self.config['dvdorder']:
|
|
log.debug('Using DVD ordering.')
|
|
use_dvd = None is not cur_ep.get('dvdseason') and None is not cur_ep.get('dvdepisodenumber')
|
|
else:
|
|
use_dvd = False
|
|
|
|
if use_dvd:
|
|
elem_seasnum, elem_epno = cur_ep.get('dvdseason'), cur_ep.get('dvdepisodenumber')
|
|
else:
|
|
elem_seasnum, elem_epno = cur_ep.get('airedseason'), cur_ep.get('airedepisodenumber')
|
|
|
|
if None is elem_seasnum or None is elem_epno:
|
|
log.warning('An episode has incomplete season/episode number (season: %r, episode: %r)' % (
|
|
elem_seasnum, elem_epno))
|
|
continue # Skip to next episode
|
|
|
|
# float() is because https://github.com/dbr/tvnamer/issues/95 - should probably be fixed in TVDB data
|
|
seas_no = int(float(elem_seasnum))
|
|
ep_no = int(float(elem_epno))
|
|
|
|
if not cur_ep.get('network'):
|
|
cur_ep['network'] = self.ti_shows[sid].network
|
|
for k, v in iteritems(cur_ep):
|
|
k = k.lower()
|
|
|
|
if None is not v:
|
|
if 'filename' == k and v:
|
|
if '://' not in v:
|
|
v = self._make_image(self.config['url_artworks'], v)
|
|
else:
|
|
v = clean_data(v)
|
|
|
|
if k in ep_map_keys:
|
|
k = ep_map_keys[k]
|
|
self._set_item(sid, seas_no, ep_no, k, v)
|
|
|
|
crew = CrewList()
|
|
cast = CastList()
|
|
try:
|
|
for director in cur_ep.get('directors', []):
|
|
crew[RoleTypes.CrewDirector].append(TVInfoPerson(name=director))
|
|
except (BaseException, Exception):
|
|
pass
|
|
try:
|
|
for guest in cur_ep.get('gueststars_list', []):
|
|
cast[RoleTypes.ActorGuest].append(TVInfoCharacter(person=[TVInfoPerson(name=guest)],
|
|
show=self.ti_shows[sid]))
|
|
except (BaseException, Exception):
|
|
pass
|
|
try:
|
|
for writers in cur_ep.get('writers', []):
|
|
crew[RoleTypes.CrewWriter].append(TVInfoPerson(name=writers))
|
|
except (BaseException, Exception):
|
|
pass
|
|
self._set_item(sid, seas_no, ep_no, 'crew', crew)
|
|
self._set_item(sid, seas_no, ep_no, 'cast', cast)
|
|
|
|
self.ti_shows[sid].ep_loaded = True
|
|
|
|
return True
|
|
|
|
def _name_to_sid(self, name):
|
|
"""Takes show name, returns the correct series ID (if the show has
|
|
already been grabbed), or grabs all episodes and returns
|
|
the correct SID.
|
|
"""
|
|
if name in self.corrections:
|
|
log.debug('Correcting %s to %s' % (name, self.corrections[name]))
|
|
return self.corrections[name]
|
|
else:
|
|
log.debug('Getting show %s' % name)
|
|
selected_series = self.get_series(name)
|
|
if isinstance(selected_series, dict):
|
|
selected_series = [selected_series]
|
|
sids = [int(x['id']) for x in selected_series if
|
|
self._get_show_data(int(x['id']), self.config['language'])]
|
|
self.corrections.update(dict([(x['seriesname'], int(x['id'])) for x in selected_series]))
|
|
return sids
|
|
|
|
def _get_languages(self):
|
|
if not Tvdb._supported_languages:
|
|
Tvdb._supported_languages = [{'id': _l, 'name': None, 'nativeName': None, 'sg_lang': _l}
|
|
for _l in self.config['valid_languages']]
|
|
|
|
|
|
def main():
|
|
"""Simple example of using tvdb_api - it just
|
|
grabs an episode name interactively.
|
|
"""
|
|
import logging
|
|
|
|
logging.basicConfig(level=logging.DEBUG)
|
|
|
|
tvdb_instance = Tvdb(interactive=True, cache=False)
|
|
print(tvdb_instance['Lost']['seriesname'])
|
|
print(tvdb_instance['Lost'][1][4]['episodename'])
|
|
|
|
|
|
if '__main__' == __name__:
|
|
main()
|