summaryrefslogtreecommitdiffstats
path: root/addons/metadata.tvshows.themoviedb.org.python/libs
diff options
context:
space:
mode:
Diffstat (limited to 'addons/metadata.tvshows.themoviedb.org.python/libs')
-rw-r--r--addons/metadata.tvshows.themoviedb.org.python/libs/actions.py240
-rw-r--r--addons/metadata.tvshows.themoviedb.org.python/libs/api_utils.py79
-rw-r--r--addons/metadata.tvshows.themoviedb.org.python/libs/cache.py81
-rw-r--r--addons/metadata.tvshows.themoviedb.org.python/libs/data_utils.py451
-rw-r--r--addons/metadata.tvshows.themoviedb.org.python/libs/debugger.py115
-rw-r--r--addons/metadata.tvshows.themoviedb.org.python/libs/imdbratings.py72
-rw-r--r--addons/metadata.tvshows.themoviedb.org.python/libs/settings.py111
-rw-r--r--addons/metadata.tvshows.themoviedb.org.python/libs/tmdb.py466
-rw-r--r--addons/metadata.tvshows.themoviedb.org.python/libs/traktratings.py66
-rw-r--r--addons/metadata.tvshows.themoviedb.org.python/libs/utils.py72
10 files changed, 1753 insertions, 0 deletions
diff --git a/addons/metadata.tvshows.themoviedb.org.python/libs/actions.py b/addons/metadata.tvshows.themoviedb.org.python/libs/actions.py
new file mode 100644
index 0000000..100476a
--- /dev/null
+++ b/addons/metadata.tvshows.themoviedb.org.python/libs/actions.py
@@ -0,0 +1,240 @@
+# -*- coding: UTF-8 -*-
+#
+# Copyright (C) 2020, Team Kodi
+#
+# This program 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.
+#
+# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
+# pylint: disable=missing-docstring
+#
+# This is based on the metadata.tvmaze scrapper by Roman Miroshnychenko aka Roman V.M.
+
+"""Plugin route actions"""
+
+from __future__ import absolute_import, unicode_literals
+
+import sys
+import json
+import urllib.parse
+import xbmcgui
+import xbmcplugin
+from . import tmdb, data_utils
+from .utils import logger, safe_get
+try:
+ from typing import Optional, Text, Union, ByteString # pylint: disable=unused-import
+except ImportError:
+ pass
+
+HANDLE = int(sys.argv[1]) # type: int
+
+
+def find_show(title, year=None):
+ # type: (Union[Text, bytes], Optional[Text]) -> None
+ """Find a show by title"""
+ if not isinstance(title, str):
+ title = title.decode('utf-8')
+ logger.debug('Searching for TV show {} ({})'.format(title, year))
+ search_results = tmdb.search_show(title, year)
+ for search_result in search_results:
+ show_name = search_result['name']
+ if safe_get(search_result, 'first_air_date') is not None:
+ show_name += ' ({})'.format(search_result['first_air_date'][:4])
+ list_item = xbmcgui.ListItem(show_name, offscreen=True)
+ show_info = search_result
+ list_item = data_utils.add_main_show_info(
+ list_item, show_info, full_info=False)
+ # Below "url" is some unique ID string (may be an actual URL to a show page)
+ # that is used to get information about a specific TV show.
+ xbmcplugin.addDirectoryItem(
+ HANDLE,
+ url=str(search_result['id']),
+ listitem=list_item,
+ isFolder=True
+ )
+
+
+def get_show_id_from_nfo(nfo):
+ # type: (Text) -> None
+ """
+ Get show ID by NFO file contents
+
+ This function is called first instead of find_show
+ if a NFO file is found in a TV show folder.
+
+ :param nfo: the contents of a NFO file
+ """
+ if isinstance(nfo, bytes):
+ nfo = nfo.decode('utf-8', 'replace')
+ logger.debug('Parsing NFO file:\n{}'.format(nfo))
+ parse_result, named_seasons = data_utils.parse_nfo_url(nfo)
+ if parse_result:
+ if parse_result.provider == 'themoviedb':
+ show_info = tmdb.load_show_info(
+ parse_result.show_id, ep_grouping=parse_result.ep_grouping, named_seasons=named_seasons)
+ else:
+ show_info = None
+ if show_info is not None:
+ list_item = xbmcgui.ListItem(show_info['name'], offscreen=True)
+ # "url" is some string that unique identifies a show.
+ # It may be an actual URL of a TV show page.
+ xbmcplugin.addDirectoryItem(
+ HANDLE,
+ url=str(show_info['id']),
+ listitem=list_item,
+ isFolder=True
+ )
+
+
+def get_details(show_id):
+ # type: (Text) -> None
+ """Get details about a specific show"""
+ logger.debug('Getting details for show id {}'.format(show_id))
+ show_info = tmdb.load_show_info(show_id)
+ if show_info is not None:
+ list_item = xbmcgui.ListItem(show_info['name'], offscreen=True)
+ list_item = data_utils.add_main_show_info(
+ list_item, show_info, full_info=True)
+ xbmcplugin.setResolvedUrl(HANDLE, True, list_item)
+ else:
+ xbmcplugin.setResolvedUrl(
+ HANDLE, False, xbmcgui.ListItem(offscreen=True))
+
+
+def get_episode_list(show_ids): # pylint: disable=missing-docstring
+ # type: (Text) -> None
+ # Kodi has a bug: when a show directory contains an XML NFO file with
+ # episodeguide URL, that URL is always passed here regardless of
+ # the actual parsing result in get_show_from_nfo()
+ # so much of this weird logic is to deal with that
+ try:
+ all_ids = json.loads(show_ids)
+ show_id = all_ids.get('tmdb')
+ if not show_id:
+ for key, value in all_ids.items():
+ show_id = str(data_utils._convert_ext_id(key, value))
+ if show_id:
+ break
+ if not show_id:
+ show_id = str(show_ids)
+ except (ValueError, AttributeError):
+ show_id = str(show_ids)
+ if show_id.isdigit():
+ logger.error(
+ 'using deprecated episodeguide format, this show should be refreshed or rescraped')
+ if not show_id:
+ raise RuntimeError(
+ 'No TMDb TV show id found in episode guide, this show should be refreshed or rescraped')
+ elif not show_id.isdigit():
+ parse_result, named_seasons = data_utils.parse_nfo_url(show_id)
+ if parse_result:
+ show_id = parse_result.show_id
+ else:
+ raise RuntimeError(
+ 'No TMDb TV show id found in episode guide, this show should be refreshed or rescraped')
+ logger.debug('Getting episode list for show id {}'.format(show_id))
+ show_info = tmdb.load_show_info(show_id)
+ if show_info is not None:
+ theindex = 0
+ for episode in show_info['episodes']:
+ epname = episode.get('name', 'Episode ' +
+ str(episode['episode_number']))
+ list_item = xbmcgui.ListItem(epname, offscreen=True)
+ list_item = data_utils.add_episode_info(
+ list_item, episode, full_info=False)
+ encoded_ids = urllib.parse.urlencode(
+ {'show_id': str(show_info['id']), 'episode_id': str(theindex)}
+ )
+ theindex = theindex + 1
+ # Below "url" is some unique ID string (may be an actual URL to an episode page)
+ # that allows to retrieve information about a specific episode.
+ url = urllib.parse.quote(encoded_ids)
+ xbmcplugin.addDirectoryItem(
+ HANDLE,
+ url=url,
+ listitem=list_item,
+ isFolder=True
+ )
+ else:
+ logger.error(
+ 'unable to get show information using show id {}'.format(show_id))
+ logger.error('you may need to refresh the show to get a valid show id')
+
+
+def get_episode_details(encoded_ids): # pylint: disable=missing-docstring
+ # type: (Text) -> None
+ encoded_ids = urllib.parse.unquote(encoded_ids)
+ decoded_ids = dict(urllib.parse.parse_qsl(encoded_ids))
+ logger.debug('Getting episode details for {}'.format(decoded_ids))
+ episode_info = tmdb.load_episode_info(
+ decoded_ids['show_id'], decoded_ids['episode_id']
+ )
+ if episode_info:
+ list_item = xbmcgui.ListItem(episode_info['name'], offscreen=True)
+ list_item = data_utils.add_episode_info(
+ list_item, episode_info, full_info=True)
+ xbmcplugin.setResolvedUrl(HANDLE, True, list_item)
+ else:
+ xbmcplugin.setResolvedUrl(
+ HANDLE, False, xbmcgui.ListItem(offscreen=True))
+
+
+def get_artwork(show_id):
+ # type: (Text) -> None
+ """
+ Get available artwork for a show
+
+ :param show_id: default unique ID set by setUniqueIDs() method
+ """
+ if not show_id:
+ return
+ logger.debug('Getting artwork for show ID {}'.format(show_id))
+ show_info = tmdb.load_show_info(show_id)
+ if show_info is not None:
+ list_item = xbmcgui.ListItem(show_info['name'], offscreen=True)
+ list_item = data_utils.set_show_artwork(show_info, list_item)
+ xbmcplugin.setResolvedUrl(HANDLE, True, list_item)
+ else:
+ xbmcplugin.setResolvedUrl(
+ HANDLE, False, xbmcgui.ListItem(offscreen=True))
+
+
+def router(paramstring):
+ # type: (Text) -> None
+ """
+ Route addon calls
+
+ :param paramstring: url-encoded query string
+ :raises RuntimeError: on unknown call action
+ """
+ params = dict(urllib.parse.parse_qsl(paramstring))
+ logger.debug('Called addon with params: {}'.format(sys.argv))
+ if params['action'] == 'find':
+ logger.debug('performing find action')
+ find_show(params['title'], params.get('year'))
+ elif params['action'].lower() == 'nfourl':
+ logger.debug('performing nfourl action')
+ get_show_id_from_nfo(params['nfo'])
+ elif params['action'] == 'getdetails':
+ logger.debug('performing getdetails action')
+ get_details(params['url'])
+ elif params['action'] == 'getepisodelist':
+ logger.debug('performing getepisodelist action')
+ get_episode_list(params['url'])
+ elif params['action'] == 'getepisodedetails':
+ logger.debug('performing getepisodedetails action')
+ get_episode_details(params['url'])
+ elif params['action'] == 'getartwork':
+ logger.debug('performing getartwork action')
+ get_artwork(params.get('id'))
+ else:
+ raise RuntimeError('Invalid addon call: {}'.format(sys.argv))
+ xbmcplugin.endOfDirectory(HANDLE)
diff --git a/addons/metadata.tvshows.themoviedb.org.python/libs/api_utils.py b/addons/metadata.tvshows.themoviedb.org.python/libs/api_utils.py
new file mode 100644
index 0000000..ce65fa3
--- /dev/null
+++ b/addons/metadata.tvshows.themoviedb.org.python/libs/api_utils.py
@@ -0,0 +1,79 @@
+# coding: utf-8
+#
+# Copyright (C) 2020, Team Kodi
+#
+# This program 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.
+#
+# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
+
+"""Functions to interact with various web site APIs"""
+
+from __future__ import absolute_import, unicode_literals
+
+import json
+from urllib.request import Request, urlopen
+from urllib.error import URLError
+from urllib.parse import urlencode
+from pprint import pformat
+from .utils import logger
+try:
+ from typing import Text, Optional, Union, List, Dict, Any # pylint: disable=unused-import
+ InfoType = Dict[Text, Any] # pylint: disable=invalid-name
+except ImportError:
+ pass
+
+HEADERS = {}
+
+
+def set_headers(headers):
+ # type: (Dict) -> None
+ HEADERS.update(headers)
+
+
+def load_info(url, params=None, default=None, resp_type='json', verboselog=False):
+ # type: (Text, Dict, Text, Text, bool) -> Optional[Text]
+ """
+ Load info from external api
+
+ :param url: API endpoint URL
+ :param params: URL query params
+ :default: object to return if there is an error
+ :resp_type: what to return to the calling function
+ :return: API response or default on error
+ """
+ if params:
+ url = url + '?' + urlencode(params)
+ logger.debug('Calling URL "{}"'.format(url))
+ req = Request(url, headers=HEADERS)
+ try:
+ response = urlopen(req)
+ except URLError as e:
+ if hasattr(e, 'reason'):
+ logger.debug(
+ 'failed to reach the remote site\nReason: {}'.format(e.reason))
+ elif hasattr(e, 'code'):
+ logger.debug(
+ 'remote site unable to fulfill the request\nError code: {}'.format(e.code))
+ response = None
+ if response is None:
+ resp = default
+ elif resp_type.lower() == 'json':
+ try:
+ resp = json.loads(response.read().decode('utf-8'))
+ except json.decoder.JSONDecodeError:
+ logger.debug('remote site sent back bad JSON')
+ resp = default
+ else:
+ resp = response.read().decode('utf-8')
+ if verboselog:
+ logger.debug('the api response:\n{}'.format(pformat(resp)))
+ return resp
diff --git a/addons/metadata.tvshows.themoviedb.org.python/libs/cache.py b/addons/metadata.tvshows.themoviedb.org.python/libs/cache.py
new file mode 100644
index 0000000..272f339
--- /dev/null
+++ b/addons/metadata.tvshows.themoviedb.org.python/libs/cache.py
@@ -0,0 +1,81 @@
+# -*- coding: UTF-8 -*-
+#
+# Copyright (C) 2020, Team Kodi
+#
+# This program 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.
+#
+# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
+# pylint: disable=missing-docstring
+#
+# This is based on the metadata.tvmaze scrapper by Roman Miroshnychenko aka Roman V.M.
+
+"""Cache-related functionality"""
+
+from __future__ import absolute_import, unicode_literals
+
+import os
+import pickle
+import xbmc
+import xbmcvfs
+
+from .utils import ADDON, logger
+
+try:
+ from typing import Optional, Text, Dict, Any # pylint: disable=unused-import
+except ImportError:
+ pass
+
+
+def _get_cache_directory(): # pylint: disable=missing-docstring
+ # type: () -> Text
+ temp_dir = xbmcvfs.translatePath('special://temp')
+ cache_dir = os.path.join(temp_dir, 'scrapers', ADDON.getAddonInfo('id'))
+ if not xbmcvfs.exists(cache_dir):
+ xbmcvfs.mkdir(cache_dir)
+ logger.debug('the cache dir is ' + cache_dir)
+ return cache_dir
+
+
+CACHE_DIR = _get_cache_directory() # type: Text
+
+
+def cache_show_info(show_info):
+ # type: (Dict[Text, Any]) -> None
+ """
+ Save show_info dict to cache
+ """
+ file_name = str(show_info['id']) + '.pickle'
+ cache = {
+ 'show_info': show_info
+ }
+ with open(os.path.join(CACHE_DIR, file_name), 'wb') as fo:
+ pickle.dump(cache, fo, protocol=2)
+
+
+def load_show_info_from_cache(show_id):
+ # type: (Text) -> Optional[Dict[Text, Any]]
+ """
+ Load show info from a local cache
+
+ :param show_id: show ID on TVmaze
+ :return: show_info dict or None
+ """
+ file_name = str(show_id) + '.pickle'
+ try:
+ with open(os.path.join(CACHE_DIR, file_name), 'rb') as fo:
+ load_kwargs = {}
+ load_kwargs['encoding'] = 'bytes'
+ cache = pickle.load(fo, **load_kwargs)
+ return cache['show_info']
+ except (IOError, pickle.PickleError) as exc:
+ logger.debug('Cache message: {} {}'.format(type(exc), exc))
+ return None
diff --git a/addons/metadata.tvshows.themoviedb.org.python/libs/data_utils.py b/addons/metadata.tvshows.themoviedb.org.python/libs/data_utils.py
new file mode 100644
index 0000000..779789a
--- /dev/null
+++ b/addons/metadata.tvshows.themoviedb.org.python/libs/data_utils.py
@@ -0,0 +1,451 @@
+# -*- coding: UTF-8 -*-
+#
+# Copyright (C) 2020, Team Kodi
+#
+# This program 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.
+#
+# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
+# pylint: disable=missing-docstring
+#
+# This is based on the metadata.tvmaze scrapper by Roman Miroshnychenko aka Roman V.M.
+
+"""Functions to process data"""
+
+from __future__ import absolute_import, unicode_literals
+
+import re
+import json
+from xbmc import Actor, VideoStreamDetail
+from collections import namedtuple
+from .utils import safe_get, logger
+from . import settings, api_utils
+
+try:
+ from typing import Optional, Tuple, Text, Dict, List, Any # pylint: disable=unused-import
+ from xbmcgui import ListItem # pylint: disable=unused-import
+ InfoType = Dict[Text, Any] # pylint: disable=invalid-name
+except ImportError:
+ pass
+
+TMDB_PARAMS = {'api_key': settings.TMDB_CLOWNCAR, 'language': settings.LANG}
+BASE_URL = 'https://api.themoviedb.org/3/{}'
+FIND_URL = BASE_URL.format('find/{}')
+TAG_RE = re.compile(r'<[^>]+>')
+
+# Regular expressions are listed in order of priority.
+# "TMDB" provider is preferred than other providers (IMDB and TheTVDB),
+# because external providers IDs need to be converted to TMDB_ID.
+SHOW_ID_REGEXPS = (
+ r'(themoviedb)\.org/tv/(\d+).*/episode_group/(.*)', # TMDB_http_link
+ r'(themoviedb)\.org/tv/(\d+)', # TMDB_http_link
+ r'(themoviedb)\.org/./tv/(\d+)', # TMDB_http_link
+ r'(tmdb)\.org/./tv/(\d+)', # TMDB_http_link
+ r'(imdb)\.com/.+/(tt\d+)', # IMDB_http_link
+ r'(thetvdb)\.com.+&id=(\d+)', # TheTVDB_http_link
+ r'(thetvdb)\.com/series/(\d+)', # TheTVDB_http_link
+ r'(thetvdb)\.com/api/.*series/(\d+)', # TheTVDB_http_link
+ r'(thetvdb)\.com/.*?"id":(\d+)', # TheTVDB_http_link
+ r'<uniqueid.+?type="(tvdb|imdb)".*?>([t\d]+?)</uniqueid>'
+)
+
+
+SUPPORTED_ARTWORK_TYPES = {'poster', 'banner'}
+IMAGE_SIZES = ('large', 'original', 'medium')
+CLEAN_PLOT_REPLACEMENTS = (
+ ('<b>', '[B]'),
+ ('</b>', '[/B]'),
+ ('<i>', '[I]'),
+ ('</i>', '[/I]'),
+ ('</p><p>', '[CR]'),
+)
+VALIDEXTIDS = ['tmdb_id', 'imdb_id', 'tvdb_id']
+
+UrlParseResult = namedtuple(
+ 'UrlParseResult', ['provider', 'show_id', 'ep_grouping'])
+
+
+def _clean_plot(plot):
+ # type: (Text) -> Text
+ """Replace HTML tags with Kodi skin tags"""
+ for repl in CLEAN_PLOT_REPLACEMENTS:
+ plot = plot.replace(repl[0], repl[1])
+ plot = TAG_RE.sub('', plot)
+ return plot
+
+
+def _set_cast(cast_info, vtag):
+ # type: (InfoType, ListItem) -> ListItem
+ """Save cast info to list item"""
+ cast = []
+ for item in cast_info:
+ actor = {
+ 'name': item['name'],
+ 'role': item.get('character', item.get('character_name', '')),
+ 'order': item['order'],
+ }
+ thumb = None
+ if safe_get(item, 'profile_path') is not None:
+ thumb = settings.IMAGEROOTURL + item['profile_path']
+ cast.append(Actor(actor['name'], actor['role'], actor['order'], thumb))
+ vtag.setCast(cast)
+
+
+def _get_credits(show_info):
+ # type: (InfoType) -> List[Text]
+ """Extract show creator(s) and writer(s) from show info"""
+ credits = []
+ for item in show_info.get('created_by', []):
+ credits.append(item['name'])
+ for item in show_info.get('credits', {}).get('crew', []):
+ isWriter = item.get('job', '').lower() == 'writer' or item.get(
+ 'department', '').lower() == 'writing'
+ if isWriter and item.get('name') not in credits:
+ credits.append(item['name'])
+ return credits
+
+
+def _get_directors(episode_info):
+ # type: (InfoType) -> List[Text]
+ """Extract episode writer(s) from episode info"""
+ directors_ = []
+ for item in episode_info.get('credits', {}).get('crew', []):
+ if item.get('job') == 'Director':
+ directors_.append(item['name'])
+ return directors_
+
+
+def _set_unique_ids(ext_ids, vtag):
+ # type: (Dict, ListItem) -> ListItem
+ """Extract unique ID in various online databases"""
+ return_ids = {}
+ for key, value in ext_ids.items():
+ if key in VALIDEXTIDS and value:
+ if key == 'tmdb_id':
+ isTMDB = True
+ else:
+ isTMDB = False
+ shortkey = key[:-3]
+ str_value = str(value)
+ vtag.setUniqueID(str_value, type=shortkey, isdefault=isTMDB)
+ return_ids[shortkey] = str_value
+ return return_ids
+
+
+def _set_rating(the_info, vtag):
+ # type: (InfoType, ListItem) -> None
+ """Set show/episode rating"""
+ first = True
+ for rating_type in settings.RATING_TYPES:
+ logger.debug('adding rating type of %s' % rating_type)
+ rating = float(the_info.get('ratings', {}).get(
+ rating_type, {}).get('rating', '0'))
+ votes = int(the_info.get('ratings', {}).get(
+ rating_type, {}).get('votes', '0'))
+ logger.debug("adding rating of %s and votes of %s" %
+ (str(rating), str(votes)))
+ if rating > 0:
+ vtag.setRating(rating, votes=votes,
+ type=rating_type, isdefault=first)
+ first = False
+
+
+def _add_season_info(show_info, vtag):
+ # type: (InfoType, ListItem) -> None
+ """Add info for show seasons"""
+ for season in show_info['seasons']:
+ logger.debug('adding information for season %s to list item' %
+ season['season_number'])
+ vtag.addSeason(season['season_number'],
+ safe_get(season, 'name', ''))
+ for image_type, image_list in season.get('images', {}).items():
+ if image_type == 'posters':
+ destination = 'poster'
+ else:
+ destination = image_type
+ for image in image_list:
+ theurl, previewurl = get_image_urls(image)
+ if theurl:
+ vtag.addAvailableArtwork(
+ theurl, arttype=destination, preview=previewurl, season=season['season_number'])
+
+
+def get_image_urls(image):
+ # type: (Dict) -> Tuple[Text, Text]
+ """Get image URLs from image information"""
+ if image.get('file_path', '').endswith('.svg'):
+ return None, None
+ if image.get('type') == 'fanarttv':
+ theurl = image['file_path']
+ previewurl = theurl.replace(
+ '.fanart.tv/fanart/', '.fanart.tv/preview/')
+ else:
+ theurl = settings.IMAGEROOTURL + image['file_path']
+ previewurl = settings.PREVIEWROOTURL + image['file_path']
+ return theurl, previewurl
+
+
+def set_show_artwork(show_info, list_item):
+ # type: (InfoType, ListItem) -> ListItem
+ """Set available images for a show"""
+ vtag = list_item.getVideoInfoTag()
+ for image_type, image_list in show_info.get('images', {}).items():
+ if image_type == 'backdrops':
+ fanart_list = []
+ for image in image_list:
+ theurl, previewurl = get_image_urls(image)
+ if image.get('iso_639_1') != None and settings.CATLANDSCAPE and theurl:
+ vtag.addAvailableArtwork(
+ theurl, arttype="landscape", preview=previewurl)
+ elif theurl:
+ fanart_list.append({'image': theurl})
+ if fanart_list:
+ list_item.setAvailableFanart(fanart_list)
+ else:
+ if image_type == 'posters':
+ destination = 'poster'
+ elif image_type == 'logos':
+ destination = 'clearlogo'
+ else:
+ destination = image_type
+ for image in image_list:
+ theurl, previewurl = get_image_urls(image)
+ if theurl:
+ vtag.addAvailableArtwork(
+ theurl, arttype=destination, preview=previewurl)
+ return list_item
+
+
+def add_main_show_info(list_item, show_info, full_info=True):
+ # type: (ListItem, InfoType, bool) -> ListItem
+ """Add main show info to a list item"""
+ vtag = list_item.getVideoInfoTag()
+ original_name = show_info.get('original_name')
+ if settings.KEEPTITLE and original_name:
+ showname = original_name
+ else:
+ showname = show_info['name']
+ plot = _clean_plot(safe_get(show_info, 'overview', ''))
+ vtag.setTitle(showname)
+ vtag.setOriginalTitle(original_name)
+ vtag.setTvShowTitle(showname)
+ vtag.setPlot(plot)
+ vtag.setPlotOutline(plot)
+ vtag.setMediaType('tvshow')
+ ext_ids = {'tmdb_id': show_info['id']}
+ ext_ids.update(show_info.get('external_ids', {}))
+ epguide_ids = _set_unique_ids(ext_ids, vtag)
+ vtag.setEpisodeGuide(json.dumps(epguide_ids))
+ if show_info.get('first_air_date'):
+ vtag.setYear(int(show_info['first_air_date'][:4]))
+ vtag.setPremiered(show_info['first_air_date'])
+ if full_info:
+ vtag.setTvShowStatus(safe_get(show_info, 'status', ''))
+ genre_list = safe_get(show_info, 'genres', {})
+ genres = []
+ for genre in genre_list:
+ genres.append(genre['name'])
+ vtag.setGenres(genres)
+ networks = show_info.get('networks', [])
+ if networks:
+ network = networks[0]
+ country = network.get('origin_country', '')
+ else:
+ network = None
+ country = None
+ if network and country and settings.STUDIOCOUNTRY:
+ vtag.setStudios(['{0} ({1})'.format(network['name'], country)])
+ elif network:
+ vtag.setStudios([network['name']])
+ if country:
+ vtag.setCountries([country])
+ content_ratings = show_info.get(
+ 'content_ratings', {}).get('results', {})
+ if content_ratings:
+ mpaa = ''
+ mpaa_backup = ''
+ for content_rating in content_ratings:
+ iso = content_rating.get('iso_3166_1', '').lower()
+ if iso == 'us':
+ mpaa_backup = content_rating.get('rating')
+ if iso == settings.CERT_COUNTRY.lower():
+ mpaa = content_rating.get('rating', '')
+ if not mpaa:
+ mpaa = mpaa_backup
+ if mpaa:
+ vtag.setMpaa(settings.CERT_PREFIX + mpaa)
+ vtag.setWriters(_get_credits(show_info))
+ if settings.ENABTRAILER:
+ trailer = _parse_trailer(show_info.get(
+ 'videos', {}).get('results', {}))
+ if trailer:
+ vtag.setTrailer(trailer)
+ list_item = set_show_artwork(show_info, list_item)
+ _add_season_info(show_info, vtag)
+ _set_cast(show_info['credits']['cast'], vtag)
+ _set_rating(show_info, vtag)
+ else:
+ image = show_info.get('poster_path', '')
+ if image and not image.endswith('.svg'):
+ theurl = settings.IMAGEROOTURL + image
+ previewurl = settings.PREVIEWROOTURL + image
+ vtag.addAvailableArtwork(
+ theurl, arttype='poster', preview=previewurl)
+ logger.debug('adding tv show information for %s to list item' % showname)
+ return list_item
+
+
+def add_episode_info(list_item, episode_info, full_info=True):
+ # type: (ListItem, InfoType, bool) -> ListItem
+ """Add episode info to a list item"""
+ title = episode_info.get('name', 'Episode ' +
+ str(episode_info['episode_number']))
+ vtag = list_item.getVideoInfoTag()
+ vtag.setTitle(title)
+ vtag.setSeason(episode_info['season_number'])
+ vtag.setEpisode(episode_info['episode_number'])
+ vtag.setMediaType('episode')
+ if safe_get(episode_info, 'air_date') is not None:
+ vtag.setFirstAired(episode_info['air_date'])
+ if full_info:
+ summary = safe_get(episode_info, 'overview')
+ if summary is not None:
+ plot = _clean_plot(summary)
+ vtag.setPlot(plot)
+ vtag.setPlotOutline(plot)
+ if safe_get(episode_info, 'air_date') is not None:
+ vtag.setPremiered(episode_info['air_date'])
+ duration = episode_info.get('runtime')
+ if duration:
+ videostream = VideoStreamDetail(duration=int(duration)*60)
+ vtag.addVideoStream(videostream)
+ _set_cast(
+ episode_info['season_cast'] + episode_info['credits']['guest_stars'], vtag)
+ ext_ids = {'tmdb_id': episode_info['id']}
+ ext_ids.update(episode_info.get('external_ids', {}))
+ _set_unique_ids(ext_ids, vtag)
+ _set_rating(episode_info, vtag)
+ for image in episode_info.get('images', {}).get('stills', []):
+ theurl, previewurl = get_image_urls(image)
+ if theurl:
+ vtag.addAvailableArtwork(
+ theurl, arttype='thumb', preview=previewurl)
+ vtag.setWriters(_get_credits(episode_info))
+ vtag.setDirectors(_get_directors(episode_info))
+ logger.debug('adding episode information for S%sE%s - %s to list item' %
+ (episode_info['season_number'], episode_info['episode_number'], title))
+ return list_item
+
+
+def parse_nfo_url(nfo):
+ # type: (Text) -> Optional[UrlParseResult]
+ """Extract show ID and named seasons from NFO file contents"""
+ # work around for xbmcgui.ListItem.addSeason overwriting named seasons from NFO files
+ ns_regex = r'<namedseason number="(.*)">(.*)</namedseason>'
+ ns_match = re.findall(ns_regex, nfo, re.I)
+ sid_match = None
+ ep_grouping = None
+ for regexp in SHOW_ID_REGEXPS:
+ logger.debug('trying regex to match service from parsing nfo:')
+ logger.debug(regexp)
+ show_id_match = re.search(regexp, nfo, re.I)
+ if show_id_match:
+ logger.debug('match group 1: ' + show_id_match.group(1))
+ logger.debug('match group 2: ' + show_id_match.group(2))
+ if show_id_match.group(1) == "themoviedb" or show_id_match.group(1) == "tmdb":
+ try:
+ ep_grouping = show_id_match.group(3)
+ except IndexError:
+ pass
+ tmdb_id = show_id_match.group(2)
+ else:
+ tmdb_id = _convert_ext_id(
+ show_id_match.group(1), show_id_match.group(2))
+ if tmdb_id:
+ logger.debug('match group 3: ' + str(ep_grouping))
+ sid_match = UrlParseResult('tmdb', tmdb_id, ep_grouping)
+ break
+ return sid_match, ns_match
+
+
+def _convert_ext_id(ext_provider, ext_id):
+ # type: (Text, Text) -> Text
+ """get a TMDb ID from an external ID"""
+ providers_dict = {'imdb': 'imdb_id',
+ 'thetvdb': 'tvdb_id',
+ 'tvdb': 'tvdb_id'}
+ show_url = FIND_URL.format(ext_id)
+ params = TMDB_PARAMS.copy()
+ provider = providers_dict.get(ext_provider)
+ if provider:
+ params['external_source'] = provider
+ show_info = api_utils.load_info(show_url, params=params)
+ else:
+ show_info = None
+ if show_info:
+ tv_results = show_info.get('tv_results')
+ if tv_results:
+ return tv_results[0].get('id')
+ return None
+
+
+def parse_media_id(title):
+ # type: (Text) -> Dict
+ """get the ID from a title and return with the type"""
+ title = title.lower()
+ if title.startswith('tt') and title[2:].isdigit():
+ # IMDB ID works alone because it is clear
+ return {'type': 'imdb_id', 'title': title}
+ # IMDB ID with prefix to match
+ elif title.startswith('imdb/tt') and title[7:].isdigit():
+ # IMDB ID works alone because it is clear
+ return {'type': 'imdb_id', 'title': title[5:]}
+ elif title.startswith('tmdb/') and title[5:].isdigit(): # TMDB ID
+ return {'type': 'tmdb_id', 'title': title[5:]}
+ elif title.startswith('tvdb/') and title[5:].isdigit(): # TVDB ID
+ return {'type': 'tvdb_id', 'title': title[5:]}
+ return None
+
+
+def _parse_trailer(results):
+ # type: (Text) -> Text
+ """create a valid Tubed or YouTube plugin trailer URL"""
+ if results:
+ if settings.PLAYERSOPT == 'tubed':
+ addon_player = 'plugin://plugin.video.tubed/?mode=play&video_id='
+ elif settings.PLAYERSOPT == 'youtube':
+ addon_player = 'plugin://plugin.video.youtube/?action=play_video&videoid='
+ backup_keys = []
+ for video_lang in [settings.LANG[0:2], 'en']:
+ for result in results:
+ if result.get('site') == 'YouTube' and result.get('iso_639_1') == video_lang:
+ key = result.get('key')
+ if result.get('type') == 'Trailer':
+ if _check_youtube(key):
+ # video is available and is defined as "Trailer" by TMDB. Perfect link!
+ return addon_player+key
+ else:
+ # video is available, but NOT defined as "Trailer" by TMDB. Saving it as backup in case it doesn't find any perfect link.
+ backup_keys.append(key)
+ for keybackup in backup_keys:
+ if _check_youtube(keybackup):
+ return addon_player+keybackup
+ return None
+
+
+def _check_youtube(key):
+ # type: (Text) -> bool
+ """check to see if the YouTube key returns a valid link"""
+ chk_link = "https://www.youtube.com/watch?v="+key
+ check = api_utils.load_info(chk_link, resp_type='not_json')
+ if not check or "Video unavailable" in check: # video not available
+ return False
+ return True
diff --git a/addons/metadata.tvshows.themoviedb.org.python/libs/debugger.py b/addons/metadata.tvshows.themoviedb.org.python/libs/debugger.py
new file mode 100644
index 0000000..6414722
--- /dev/null
+++ b/addons/metadata.tvshows.themoviedb.org.python/libs/debugger.py
@@ -0,0 +1,115 @@
+# -*- coding: UTF-8 -*-
+#
+# Copyright (C) 2020, Team Kodi
+#
+# This program 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.
+#
+# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
+# pylint: disable=missing-docstring
+#
+# This is based on the metadata.tvmaze scrapper by Roman Miroshnychenko aka Roman V.M.
+
+"""
+Provides a context manager that writes extended debugging info
+in the Kodi log on unhandled exceptions
+"""
+from __future__ import absolute_import, unicode_literals
+
+import inspect
+from contextlib import contextmanager
+from platform import uname
+from pprint import pformat
+
+import xbmc
+
+from .utils import logger
+
+try:
+ from typing import Text, Generator, Callable, Dict, Any # pylint: disable=unused-import
+except ImportError:
+ pass
+
+
+def _format_vars(variables):
+ # type: (Dict[Text, Any]) -> Text
+ """
+ Format variables dictionary
+
+ :param variables: variables dict
+ :type variables: dict
+ :return: formatted string with sorted ``var = val`` pairs
+ :rtype: str
+ """
+ var_list = [(var, val) for var, val in variables.items()
+ if not (var.startswith('__') or var.endswith('__'))]
+ var_list.sort(key=lambda i: i[0])
+ lines = []
+ for var, val in var_list:
+ lines.append('{0} = {1}'.format(var, pformat(val)))
+ return '\n'.join(lines)
+
+
+@contextmanager
+def debug_exception(logger_func=logger.error):
+ # type: (Callable[[Text], None]) -> Generator[None]
+ """
+ Diagnostic helper context manager
+
+ It controls execution within its context and writes extended
+ diagnostic info to the Kodi log if an unhandled exception
+ happens within the context. The info includes the following items:
+
+ - System info
+ - Kodi version
+ - Module path.
+ - Code fragment where the exception has happened.
+ - Global variables.
+ - Local variables.
+
+ After logging the diagnostic info the exception is re-raised.
+
+ Example::
+
+ with debug_exception():
+ # Some risky code
+ raise RuntimeError('Fatal error!')
+
+ :param logger_func: logger function which must accept a single argument
+ which is a log message.
+ """
+ try:
+ yield
+ except Exception as exc:
+ frame_info = inspect.trace(5)[-1]
+ logger_func(
+ '*** Unhandled exception detected: {} {} ***'.format(type(exc), exc))
+ logger_func('*** Start diagnostic info ***')
+ logger_func('System info: {0}'.format(uname()))
+ logger_func('OS info: {0}'.format(
+ xbmc.getInfoLabel('System.OSVersionInfo')))
+ logger_func('Kodi version: {0}'.format(
+ xbmc.getInfoLabel('System.BuildVersion')))
+ logger_func('File: {0}'.format(frame_info[1]))
+ context = ''
+ if frame_info[4] is not None:
+ for i, line in enumerate(frame_info[4], frame_info[2] - frame_info[5]):
+ if i == frame_info[2]:
+ context += '{0}:>{1}'.format(str(i).rjust(5), line)
+ else:
+ context += '{0}: {1}'.format(str(i).rjust(5), line)
+ logger_func('Code context:\n' + context)
+ logger_func('Global variables:\n' +
+ _format_vars(frame_info[0].f_globals))
+ logger_func('Local variables:\n' +
+ _format_vars(frame_info[0].f_locals))
+ logger_func('**** End diagnostic info ****')
+ raise exc
diff --git a/addons/metadata.tvshows.themoviedb.org.python/libs/imdbratings.py b/addons/metadata.tvshows.themoviedb.org.python/libs/imdbratings.py
new file mode 100644
index 0000000..f74f0a4
--- /dev/null
+++ b/addons/metadata.tvshows.themoviedb.org.python/libs/imdbratings.py
@@ -0,0 +1,72 @@
+# -*- coding: UTF-8 -*-
+#
+# Copyright (C) 2020, Team Kodi
+#
+# This program 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.
+#
+# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
+#
+# IMDb ratings based on code in metadata.themoviedb.org.python by Team Kodi
+# pylint: disable=missing-docstring
+
+
+import re
+import json
+from . import api_utils
+from . import settings
+try:
+ from typing import Optional, Tuple, Text, Dict, List, Any # pylint: disable=unused-import
+except ImportError:
+ pass
+
+IMDB_RATINGS_URL = 'https://www.imdb.com/title/{}/'
+IMDB_JSON_REGEX = re.compile(
+ r'<script type="application\/ld\+json">(.*?)<\/script>')
+
+
+def get_details(imdb_id):
+ # type: (Text) -> Dict
+ """get the IMDB ratings details"""
+ if not imdb_id:
+ return {}
+ votes, rating = _get_ratinginfo(imdb_id)
+ return _assemble_imdb_result(votes, rating)
+
+
+def _get_ratinginfo(imdb_id):
+ # type: (Text) -> Tuple[Text, Text]
+ """get the IMDB ratings details"""
+ response = api_utils.load_info(IMDB_RATINGS_URL.format(
+ imdb_id), default='', resp_type='text', verboselog=settings.VERBOSELOG)
+ return _parse_imdb_result(response)
+
+
+def _assemble_imdb_result(votes, rating):
+ # type: (Text, Text) -> Dict
+ """assemble to IMDB ratings into a Dict"""
+ result = {}
+ if votes and rating:
+ result['ratings'] = {'imdb': {'votes': votes, 'rating': rating}}
+ return result
+
+
+def _parse_imdb_result(input_html):
+ # type: (Text) -> Tuple[Text, Text]
+ """parse the IMDB ratings from the JSON in the raw HTML"""
+ match = re.search(IMDB_JSON_REGEX, input_html)
+ if not match:
+ return None, None
+ imdb_json = json.loads(match.group(1))
+ imdb_ratings = imdb_json.get("aggregateRating", {})
+ rating = imdb_ratings.get("ratingValue", None)
+ votes = imdb_ratings.get("ratingCount", None)
+ return votes, rating
diff --git a/addons/metadata.tvshows.themoviedb.org.python/libs/settings.py b/addons/metadata.tvshows.themoviedb.org.python/libs/settings.py
new file mode 100644
index 0000000..3d6cf7d
--- /dev/null
+++ b/addons/metadata.tvshows.themoviedb.org.python/libs/settings.py
@@ -0,0 +1,111 @@
+# -*- coding: UTF-8 -*-
+#
+# Copyright (C) 2020, Team Kodi
+#
+# This program 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.
+#
+# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
+# pylint: disable=missing-docstring
+
+import json
+import sys
+import urllib.parse
+from .utils import logger
+from . import api_utils
+from xbmcaddon import Addon
+from datetime import datetime, timedelta
+
+
+def _get_date_numeric(datetime_):
+ return (datetime_ - datetime(1970, 1, 1)).total_seconds()
+
+
+def _get_configuration():
+ logger.debug('getting configuration details')
+ return api_utils.load_info('https://api.themoviedb.org/3/configuration', params={'api_key': TMDB_CLOWNCAR}, verboselog=VERBOSELOG)
+
+
+def _load_base_urls():
+ image_root_url = ADDON.getSettingString('originalUrl')
+ preview_root_url = ADDON.getSettingString('previewUrl')
+ last_updated = ADDON.getSettingString('lastUpdated')
+ if not image_root_url or not preview_root_url or not last_updated or \
+ float(last_updated) < _get_date_numeric(datetime.now() - timedelta(days=30)):
+ conf = _get_configuration()
+ if conf:
+ image_root_url = conf['images']['secure_base_url'] + 'original'
+ preview_root_url = conf['images']['secure_base_url'] + 'w780'
+ ADDON.setSetting('originalUrl', image_root_url)
+ ADDON.setSetting('previewUrl', preview_root_url)
+ ADDON.setSetting('lastUpdated', str(
+ _get_date_numeric(datetime.now())))
+ return image_root_url, preview_root_url
+
+
+ADDON = Addon()
+TMDB_CLOWNCAR = 'af3a53eb387d57fc935e9128468b1899'
+FANARTTV_CLOWNCAR = 'b018086af0e1478479adfc55634db97d'
+TRAKT_CLOWNCAR = '90901c6be3b2de5a4fa0edf9ab5c75e9a5a0fef2b4ee7373d8b63dcf61f95697'
+MAXIMAGES = 200
+FANARTTV_MAPPING = {'showbackground': 'backdrops',
+ 'tvposter': 'posters',
+ 'tvbanner': 'banner',
+ 'hdtvlogo': 'clearlogo',
+ 'clearlogo': 'clearlogo',
+ 'hdclearart': 'clearart',
+ 'clearart': 'clearart',
+ 'tvthumb': 'landscape',
+ 'characterart': 'characterart',
+ 'seasonposter': 'seasonposters',
+ 'seasonbanner': 'seasonbanner',
+ 'seasonthumb': 'seasonlandscape'
+ }
+
+try:
+ source_params = dict(urllib.parse.parse_qsl(sys.argv[2]))
+except IndexError:
+ source_params = {}
+source_settings = json.loads(source_params.get('pathSettings', '{}'))
+
+KEEPTITLE = source_settings.get(
+ 'keeporiginaltitle', ADDON.getSettingBool('keeporiginaltitle'))
+CATLANDSCAPE = source_settings.get('cat_landscape', True)
+STUDIOCOUNTRY = source_settings.get('studio_country', False)
+ENABTRAILER = source_settings.get(
+ 'enab_trailer', ADDON.getSettingBool('enab_trailer'))
+PLAYERSOPT = source_settings.get(
+ 'players_opt', ADDON.getSettingString('players_opt')).lower()
+VERBOSELOG = source_settings.get(
+ 'verboselog', ADDON.getSettingBool('verboselog'))
+LANG = source_settings.get('language', ADDON.getSettingString('language'))
+CERT_COUNTRY = source_settings.get(
+ 'tmdbcertcountry', ADDON.getSettingString('tmdbcertcountry')).lower()
+IMAGEROOTURL, PREVIEWROOTURL = _load_base_urls()
+
+if source_settings.get('usecertprefix', ADDON.getSettingBool('usecertprefix')):
+ CERT_PREFIX = source_settings.get(
+ 'certprefix', ADDON.getSettingString('certprefix'))
+else:
+ CERT_PREFIX = ''
+primary_rating = source_settings.get(
+ 'ratings', ADDON.getSettingString('ratings')).lower()
+RATING_TYPES = [primary_rating]
+if source_settings.get('imdbanyway', ADDON.getSettingBool('imdbanyway')) and primary_rating != 'imdb':
+ RATING_TYPES.append('imdb')
+if source_settings.get('traktanyway', ADDON.getSettingBool('traktanyway')) and primary_rating != 'trakt':
+ RATING_TYPES.append('trakt')
+if source_settings.get('tmdbanyway', ADDON.getSettingBool('tmdbanyway')) and primary_rating != 'tmdb':
+ RATING_TYPES.append('tmdb')
+FANARTTV_ENABLE = source_settings.get(
+ 'enable_fanarttv', ADDON.getSettingBool('enable_fanarttv'))
+FANARTTV_CLIENTKEY = source_settings.get(
+ 'fanarttv_clientkey', ADDON.getSettingString('fanarttv_clientkey'))
diff --git a/addons/metadata.tvshows.themoviedb.org.python/libs/tmdb.py b/addons/metadata.tvshows.themoviedb.org.python/libs/tmdb.py
new file mode 100644
index 0000000..3b5019f
--- /dev/null
+++ b/addons/metadata.tvshows.themoviedb.org.python/libs/tmdb.py
@@ -0,0 +1,466 @@
+# -*- coding: UTF-8 -*-
+#
+# Copyright (C) 2020, Team Kodi
+#
+# This program 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.
+#
+# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
+# pylint: disable=missing-docstring
+
+"""Functions to interact with TMDb API"""
+
+from __future__ import absolute_import, unicode_literals
+
+import unicodedata
+from math import floor
+from pprint import pformat
+from . import cache, data_utils, api_utils, settings, imdbratings, traktratings
+from .utils import logger
+try:
+ from typing import Text, Optional, Union, List, Dict, Any # pylint: disable=unused-import
+ InfoType = Dict[Text, Any] # pylint: disable=invalid-name
+except ImportError:
+ pass
+
+HEADERS = (
+ ('User-Agent', 'Kodi TV Show scraper by Team Kodi; contact pkscout@kodi.tv'),
+ ('Accept', 'application/json'),
+)
+api_utils.set_headers(dict(HEADERS))
+
+TMDB_PARAMS = {'api_key': settings.TMDB_CLOWNCAR, 'language': settings.LANG}
+BASE_URL = 'https://api.themoviedb.org/3/{}'
+EPISODE_GROUP_URL = BASE_URL.format('tv/episode_group/{}')
+SEARCH_URL = BASE_URL.format('search/tv')
+FIND_URL = BASE_URL.format('find/{}')
+SHOW_URL = BASE_URL.format('tv/{}')
+SEASON_URL = BASE_URL.format('tv/{}/season/{}')
+EPISODE_URL = BASE_URL.format('tv/{}/season/{}/episode/{}')
+FANARTTV_URL = 'https://webservice.fanart.tv/v3/tv/{}'
+FANARTTV_PARAMS = {'api_key': settings.FANARTTV_CLOWNCAR}
+if settings.FANARTTV_CLIENTKEY:
+ FANARTTV_PARAMS['client_key'] = settings.FANARTTV_CLIENTKEY
+
+
+def search_show(title, year=None):
+ # type: (Text, Text) -> List
+ """
+ Search for a single TV show
+
+ :param title: TV show title to search
+ : param year: the year to search (optional)
+ :return: a list with found TV shows
+ """
+ params = TMDB_PARAMS.copy()
+ results = []
+ ext_media_id = data_utils.parse_media_id(title)
+ if ext_media_id:
+ logger.debug('using %s of %s to find show' %
+ (ext_media_id['type'], ext_media_id['title']))
+ if ext_media_id['type'] == 'tmdb_id':
+ search_url = SHOW_URL.format(ext_media_id['title'])
+ else:
+ search_url = FIND_URL.format(ext_media_id['title'])
+ params['external_source'] = ext_media_id['type']
+ else:
+ logger.debug('using title of %s to find show' % title)
+ search_url = SEARCH_URL
+ params['query'] = unicodedata.normalize('NFKC', title)
+ if year:
+ params['first_air_date_year'] = str(year)
+ resp = api_utils.load_info(
+ search_url, params=params, verboselog=settings.VERBOSELOG)
+ if resp is not None:
+ if ext_media_id:
+ if ext_media_id['type'] == 'tmdb_id':
+ if resp.get('success') == 'false':
+ results = []
+ else:
+ results = [resp]
+ else:
+ results = resp.get('tv_results', [])
+ else:
+ results = resp.get('results', [])
+ return results
+
+
+def load_episode_list(show_info, season_map, ep_grouping):
+ # type: (InfoType, Dict, Text) -> Optional[InfoType]
+ """get the IMDB ratings details"""
+ """Load episode list from themoviedb.org API"""
+ episode_list = []
+ if ep_grouping is not None:
+ logger.debug(
+ 'Getting episodes with episode grouping of ' + ep_grouping)
+ episode_group_url = EPISODE_GROUP_URL.format(ep_grouping)
+ custom_order = api_utils.load_info(
+ episode_group_url, params=TMDB_PARAMS, verboselog=settings.VERBOSELOG)
+ if custom_order is not None:
+ show_info['seasons'] = []
+ for custom_season in custom_order.get('groups', []):
+ season_episodes = []
+ try:
+ current_season = season_map.get(
+ str(custom_season['episodes'][0]['season_number']), {}).copy()
+ except IndexError:
+ continue
+ current_season['name'] = custom_season['name']
+ current_season['season_number'] = custom_season['order']
+ for episode in custom_season['episodes']:
+ episode['org_seasonnum'] = episode['season_number']
+ episode['org_epnum'] = episode['episode_number']
+ episode['season_number'] = custom_season['order']
+ episode['episode_number'] = episode['order'] + 1
+ season_episodes.append(episode)
+ episode_list.append(episode)
+ current_season['episodes'] = season_episodes
+ show_info['seasons'].append(current_season)
+ else:
+ logger.debug('Getting episodes from standard season list')
+ show_info['seasons'] = []
+ for key, value in season_map.items():
+ show_info['seasons'].append(value)
+ for season in show_info.get('seasons', []):
+ for episode in season.get('episodes', []):
+ episode['org_seasonnum'] = episode['season_number']
+ episode['org_epnum'] = episode['episode_number']
+ episode_list.append(episode)
+ show_info['episodes'] = episode_list
+ return show_info
+
+
+def load_show_info(show_id, ep_grouping=None, named_seasons=None):
+ # type: (Text, Text, Dict) -> Optional[InfoType]
+ """
+ Get full info for a single show
+
+ :param show_id: themoviedb.org show ID
+ :param ep_grouping: the episode group from TMDb
+ :param named_seasons: the named seasons from the NFO file
+ :return: show info or None
+ """
+ if named_seasons == None:
+ named_seasons = []
+ show_info = cache.load_show_info_from_cache(show_id)
+ if show_info is None:
+ logger.debug('no cache file found, loading from scratch')
+ show_url = SHOW_URL.format(show_id)
+ params = TMDB_PARAMS.copy()
+ params['append_to_response'] = 'credits,content_ratings,external_ids,images,videos'
+ params['include_image_language'] = '%s,en,null' % settings.LANG[0:2]
+ params['include_video_language'] = '%s,en,null' % settings.LANG[0:2]
+ show_info = api_utils.load_info(
+ show_url, params=params, verboselog=settings.VERBOSELOG)
+ if show_info is None:
+ return None
+ if show_info['overview'] == '' and settings.LANG != 'en-US':
+ params['language'] = 'en-US'
+ del params['append_to_response']
+ show_info_backup = api_utils.load_info(
+ show_url, params=params, verboselog=settings.VERBOSELOG)
+ if show_info_backup is not None:
+ show_info['overview'] = show_info_backup.get('overview', '')
+ params['language'] = settings.LANG
+ season_map = {}
+ params['append_to_response'] = 'credits,images'
+ for season in show_info.get('seasons', []):
+ season_url = SEASON_URL.format(
+ show_id, season.get('season_number', 0))
+ season_info = api_utils.load_info(
+ season_url, params=params, default={}, verboselog=settings.VERBOSELOG)
+ if (season_info.get('overview', '') == '' or season_info.get('name', '').lower().startswith('season')) and settings.LANG != 'en-US':
+ params['language'] = 'en-US'
+ season_info_backup = api_utils.load_info(
+ season_url, params=params, default={}, verboselog=settings.VERBOSELOG)
+ params['language'] = settings.LANG
+ if season_info.get('overview', '') == '':
+ season_info['overview'] = season_info_backup.get(
+ 'overview', '')
+ if season_info.get('name', '').lower().startswith('season'):
+ season_info['name'] = season_info_backup.get('name', '')
+ # this is part of a work around for xbmcgui.ListItem.addSeasons() not respecting NFO file information
+ for named_season in named_seasons:
+ if str(named_season[0]) == str(season.get('season_number')):
+ logger.debug('adding season name of %s from named seasons in NFO for season %s' % (
+ named_season[1], season['season_number']))
+ season_info['name'] = named_season[1]
+ break
+ # end work around
+ season_info['images'] = _sort_image_types(
+ season_info.get('images', {}))
+ season_map[str(season.get('season_number', 0))] = season_info
+ show_info = load_episode_list(show_info, season_map, ep_grouping)
+ show_info['ratings'] = load_ratings(show_info)
+ show_info = load_fanarttv_art(show_info)
+ show_info['images'] = _sort_image_types(show_info.get('images', {}))
+ show_info = trim_artwork(show_info)
+ cast_check = []
+ cast = []
+ for season in reversed(show_info.get('seasons', [])):
+ for cast_member in season.get('credits', {}).get('cast', []):
+ if cast_member.get('name', '') not in cast_check:
+ cast.append(cast_member)
+ cast_check.append(cast_member.get('name', ''))
+ show_info['credits']['cast'] = cast
+ logger.debug('saving show info to the cache')
+ if settings.VERBOSELOG:
+ logger.debug(format(pformat(show_info)))
+ cache.cache_show_info(show_info)
+ else:
+ logger.debug('using cached show info')
+ return show_info
+
+
+def load_episode_info(show_id, episode_id):
+ # type: (Text, Text) -> Optional[InfoType]
+ """
+ Load episode info
+
+ :param show_id:
+ :param episode_id:
+ :return: episode info or None
+ """
+ show_info = load_show_info(show_id)
+ if show_info is not None:
+ try:
+ episode_info = show_info['episodes'][int(episode_id)]
+ except KeyError:
+ return None
+ # this ensures we are using the season/ep from the episode grouping if provided
+ ep_url = EPISODE_URL.format(
+ show_info['id'], episode_info['org_seasonnum'], episode_info['org_epnum'])
+ params = TMDB_PARAMS.copy()
+ params['append_to_response'] = 'credits,external_ids,images'
+ params['include_image_language'] = '%s,en,null' % settings.LANG[0:2]
+ ep_return = api_utils.load_info(
+ ep_url, params=params, verboselog=settings.VERBOSELOG)
+ if ep_return is None:
+ return None
+ bad_return_name = False
+ bad_return_overview = False
+ check_name = ep_return.get('name')
+ if check_name == None:
+ bad_return_name = True
+ ep_return['name'] = 'Episode ' + \
+ str(episode_info['episode_number'])
+ elif check_name.lower().startswith('episode') or check_name == '':
+ bad_return_name = True
+ if ep_return.get('overview', '') == '':
+ bad_return_overview = True
+ if (bad_return_overview or bad_return_name) and settings.LANG != 'en-US':
+ params['language'] = 'en-US'
+ del params['append_to_response']
+ ep_return_backup = api_utils.load_info(
+ ep_url, params=params, verboselog=settings.VERBOSELOG)
+ if ep_return_backup is not None:
+ if bad_return_overview:
+ ep_return['overview'] = ep_return_backup.get(
+ 'overview', '')
+ if bad_return_name:
+ ep_return['name'] = ep_return_backup.get(
+ 'name', 'Episode ' + str(episode_info['episode_number']))
+ ep_return['images'] = _sort_image_types(ep_return.get('images', {}))
+ ep_return['season_number'] = episode_info['season_number']
+ ep_return['episode_number'] = episode_info['episode_number']
+ ep_return['org_seasonnum'] = episode_info['org_seasonnum']
+ ep_return['org_epnum'] = episode_info['org_epnum']
+ ep_return['ratings'] = load_ratings(
+ ep_return, show_imdb_id=show_info.get('external_ids', {}).get('imdb_id'))
+ for season in show_info.get('seasons', []):
+ if season.get('season_number') == episode_info['season_number']:
+ ep_return['season_cast'] = season.get(
+ 'credits', {}).get('cast', [])
+ break
+ show_info['episodes'][int(episode_id)] = ep_return
+ cache.cache_show_info(show_info)
+ return ep_return
+ return None
+
+
+def load_ratings(the_info, show_imdb_id=''):
+ # type: (InfoType, Text) -> Dict
+ """
+ Load the ratings for the show/episode
+
+ :param the_info: show or episode info
+ :param show_imdb_id: show IMDB
+ :return: ratings or empty dict
+ """
+ ratings = {}
+ imdb_id = the_info.get('external_ids', {}).get('imdb_id')
+ for rating_type in settings.RATING_TYPES:
+ logger.debug('setting rating using %s' % rating_type)
+ if rating_type == 'tmdb':
+ ratings['tmdb'] = {'votes': the_info['vote_count'],
+ 'rating': the_info['vote_average']}
+ elif rating_type == 'imdb' and imdb_id:
+ imdb_rating = imdbratings.get_details(imdb_id).get('ratings')
+ if imdb_rating:
+ ratings.update(imdb_rating)
+ elif rating_type == 'trakt':
+ if show_imdb_id:
+ season = the_info['org_seasonnum']
+ episode = the_info['org_epnum']
+ resp = traktratings.get_details(
+ show_imdb_id, season=season, episode=episode)
+ else:
+ resp = traktratings.get_details(imdb_id)
+ trakt_rating = resp.get('ratings')
+ if trakt_rating:
+ ratings.update(trakt_rating)
+ logger.debug('returning ratings of\n{}'.format(pformat(ratings)))
+ return ratings
+
+
+def load_fanarttv_art(show_info):
+ # type: (InfoType) -> Optional[InfoType]
+ """
+ Add fanart.tv images for a show
+
+ :param show_info: the current show info
+ :return: show info
+ """
+ tvdb_id = show_info.get('external_ids', {}).get('tvdb_id')
+ if tvdb_id and settings.FANARTTV_ENABLE:
+ fanarttv_url = FANARTTV_URL.format(tvdb_id)
+ artwork = api_utils.load_info(
+ fanarttv_url, params=FANARTTV_PARAMS, verboselog=settings.VERBOSELOG)
+ if artwork is None:
+ return show_info
+ for fanarttv_type, tmdb_type in settings.FANARTTV_MAPPING.items():
+ if not show_info['images'].get(tmdb_type) and not tmdb_type.startswith('season'):
+ show_info['images'][tmdb_type] = []
+ for item in artwork.get(fanarttv_type, []):
+ lang = item.get('lang')
+ if lang == '' or lang == '00':
+ lang = None
+ filepath = ''
+ if lang is None or lang == settings.LANG[0:2] or lang == 'en':
+ filepath = item.get('url')
+ if filepath:
+ if tmdb_type.startswith('season'):
+ image_type = tmdb_type[6:]
+ for s in range(len(show_info.get('seasons', []))):
+ season_num = show_info['seasons'][s]['season_number']
+ artseason = item.get('season', '')
+ if not show_info['seasons'][s].get('images'):
+ show_info['seasons'][s]['images'] = {}
+ if not show_info['seasons'][s]['images'].get(image_type):
+ show_info['seasons'][s]['images'][image_type] = []
+ if artseason == '' or artseason == str(season_num):
+ show_info['seasons'][s]['images'][image_type].append(
+ {'file_path': filepath, 'type': 'fanarttv', 'iso_639_1': lang})
+ else:
+ show_info['images'][tmdb_type].append(
+ {'file_path': filepath, 'type': 'fanarttv', 'iso_639_1': lang})
+ return show_info
+
+
+def trim_artwork(show_info):
+ # type: (InfoType) -> Optional[InfoType]
+ """
+ Trim artwork to keep the text blob below 65K characters
+
+ :param show_info: the current show info
+ :return: show info
+ """
+ image_counts = {}
+ image_total = 0
+ backdrops_total = 0
+ for image_type, image_list in show_info.get('images', {}).items():
+ total = len(image_list)
+ if image_type == 'backdrops':
+ backdrops_total = backdrops_total + total
+ else:
+ image_counts[image_type] = {'total': total}
+ image_total = image_total + total
+ for season in show_info.get('seasons', []):
+ for image_type, image_list in season.get('images', {}).items():
+ total = len(image_list)
+ thetype = '%s_%s' % (str(season['season_number']), image_type)
+ image_counts[thetype] = {'total': total}
+ image_total = image_total + total
+ if image_total <= settings.MAXIMAGES and backdrops_total <= settings.MAXIMAGES:
+ return show_info
+ if backdrops_total > settings.MAXIMAGES:
+ logger.error('there are %s fanart images' % str(backdrops_total))
+ logger.error('that is more than the max of %s, image results will be trimmed to the max' % str(
+ settings.MAXIMAGES))
+ reduce = -1 * (backdrops_total - settings.MAXIMAGES)
+ del show_info['images']['backdrops'][reduce:]
+ if image_total > settings.MAXIMAGES:
+ reduction = (image_total - settings.MAXIMAGES)/image_total
+ logger.error('there are %s non-fanart images' % str(image_total))
+ logger.error('that is more than the max of %s, image results will be trimmed by %s' % (
+ str(settings.MAXIMAGES), str(reduction)))
+ for key, value in image_counts.items():
+ image_counts[key]['reduce'] = -1 * \
+ int(floor(value['total'] * reduction))
+ logger.debug('%s: %s' % (key, pformat(image_counts[key])))
+ for image_type, image_list in show_info.get('images', {}).items():
+ if image_type == 'backdrops':
+ continue # already handled backdrops above
+ reduce = image_counts[image_type]['reduce']
+ if reduce != 0:
+ del show_info['images'][image_type][reduce:]
+ for s in range(len(show_info.get('seasons', []))):
+ for image_type, image_list in show_info['seasons'][s].get('images', {}).items():
+ thetype = '%s_%s' % (
+ str(show_info['seasons'][s]['season_number']), image_type)
+ reduce = image_counts[thetype]['reduce']
+ if reduce != 0:
+ del show_info['seasons'][s]['images'][image_type][reduce:]
+ return show_info
+
+
+def _sort_image_types(imagelist):
+ # type: (Dict) -> Dict
+ """
+ sort the images by language
+
+ :param imagelist:
+ :return: imagelist
+ """
+ for image_type, images in imagelist.items():
+ imagelist[image_type] = _image_sort(images, image_type)
+ return imagelist
+
+
+def _image_sort(images, image_type):
+ # type: (List, Text) -> List
+ """
+ sort the images by language
+
+ :param images:
+ :param image_type:
+ :return: list of images
+ """
+ lang_pref = []
+ lang_null = []
+ lang_en = []
+ firstimage = True
+ for image in images:
+ image_lang = image.get('iso_639_1')
+ if image_lang == settings.LANG[0:2]:
+ lang_pref.append(image)
+ elif image_lang == 'en':
+ lang_en.append(image)
+ else:
+ if firstimage:
+ lang_pref.append(image)
+ else:
+ lang_null.append(image)
+ firstimage = False
+ if image_type == 'posters':
+ return lang_pref + lang_en + lang_null
+ else:
+ return lang_pref + lang_null + lang_en
diff --git a/addons/metadata.tvshows.themoviedb.org.python/libs/traktratings.py b/addons/metadata.tvshows.themoviedb.org.python/libs/traktratings.py
new file mode 100644
index 0000000..48091b5
--- /dev/null
+++ b/addons/metadata.tvshows.themoviedb.org.python/libs/traktratings.py
@@ -0,0 +1,66 @@
+# -*- coding: UTF-8 -*-
+#
+# Copyright (C) 2020, Team Kodi
+#
+# This program 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.
+#
+# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
+# pylint: disable=missing-docstring
+
+"""Functions to interact with Trakt API"""
+
+from __future__ import absolute_import, unicode_literals
+
+from . import api_utils, settings
+try:
+ from typing import Text, Optional, Union, List, Dict, Any # pylint: disable=unused-import
+except ImportError:
+ pass
+
+
+HEADERS = (
+ ('User-Agent', 'Kodi TV Show scraper by Team Kodi; contact pkscout@kodi.tv'),
+ ('Accept', 'application/json'),
+ ('trakt-api-key', settings.TRAKT_CLOWNCAR),
+ ('trakt-api-version', '2'),
+ ('Content-Type', 'application/json'),
+)
+api_utils.set_headers(dict(HEADERS))
+
+SHOW_URL = 'https://api.trakt.tv/shows/{}'
+EP_URL = SHOW_URL + '/seasons/{}/episodes/{}/ratings'
+
+
+def get_details(imdb_id, season=None, episode=None):
+ # type: (Text, Text, Text) -> Dict
+ """
+ get the Trakt ratings
+
+ :param imdb_id:
+ :param season:
+ :param episode:
+ :return: trackt ratings
+ """
+ result = {}
+ if season and episode:
+ url = EP_URL.format(imdb_id, season, episode)
+ params = None
+ else:
+ url = SHOW_URL.format(imdb_id)
+ params = {'extended': 'full'}
+ resp = api_utils.load_info(
+ url, params=params, default={}, verboselog=settings.VERBOSELOG)
+ rating = resp.get('rating')
+ votes = resp.get('votes')
+ if votes and rating:
+ result['ratings'] = {'trakt': {'votes': votes, 'rating': rating}}
+ return result
diff --git a/addons/metadata.tvshows.themoviedb.org.python/libs/utils.py b/addons/metadata.tvshows.themoviedb.org.python/libs/utils.py
new file mode 100644
index 0000000..a9dbfbb
--- /dev/null
+++ b/addons/metadata.tvshows.themoviedb.org.python/libs/utils.py
@@ -0,0 +1,72 @@
+# -*- coding: UTF-8 -*-
+#
+# Copyright (C) 2020, Team Kodi
+#
+# This program 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.
+#
+# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
+# pylint: disable=missing-docstring
+
+"""Misc utils"""
+
+from __future__ import absolute_import, unicode_literals
+
+import xbmc
+from xbmcaddon import Addon
+
+try:
+ from typing import Text, Optional, Any, Dict # pylint: disable=unused-import
+except ImportError:
+ pass
+
+ADDON_ID = 'metadata.tvshows.themoviedb.org.python'
+ADDON = Addon()
+
+
+class logger:
+ log_message_prefix = '[{} ({})]: '.format(
+ ADDON_ID, ADDON.getAddonInfo('version'))
+
+ @staticmethod
+ def log(message, level=xbmc.LOGDEBUG):
+ # type: (Text, int) -> None
+ if isinstance(message, bytes):
+ message = message.decode('utf-8')
+ message = logger.log_message_prefix + message
+ xbmc.log(message, level)
+
+ @staticmethod
+ def info(message):
+ # type: (Text) -> None
+ logger.log(message, xbmc.LOGINFO)
+
+ @staticmethod
+ def error(message):
+ # type: (Text) -> None
+ logger.log(message, xbmc.LOGERROR)
+
+ @staticmethod
+ def debug(message):
+ # type: (Text) -> None
+ logger.log(message, xbmc.LOGDEBUG)
+
+
+def safe_get(dct, key, default=None):
+ # type: (Dict[Text, Any], Text, Any) -> Any
+ """
+ Get a key from dict
+
+ Returns the respective value or default if key is missing or the value is None.
+ """
+ if key in dct and dct[key] is not None:
+ return dct[key]
+ return default