1#!/usr/bin/env python
2# encoding:utf-8
3# author:dbr/Ben
4# project:tvdb_api
5# repository:http://github.com/dbr/tvdb_api
6# license:unlicense (http://unlicense.org/)
7
8"""Simple-to-use Python interface to The TVDB's API (thetvdb.com)
9
10Example usage:
11
12>>> from tvdb_api import Tvdb
13>>> t = Tvdb()
14>>> t['Lost'][4][11]['episodeName']
15u'Cabin Fever'
16"""
17
18__author__ = "dbr/Ben"
19__version__ = "3.1.0"
20
21
22import sys
23import os
24import time
25import types
26import getpass
27import tempfile
28import warnings
29import logging
30import hashlib
31
32import requests
33import requests_cache
34from requests_cache.backends.base import _to_bytes, _DEFAULT_HEADERS
35
36
37IS_PY2 = sys.version_info[0] == 2
38
39if IS_PY2:
40    user_input = raw_input  # noqa
41    from urllib import quote as url_quote  # noqa
42else:
43    from urllib.parse import quote as url_quote
44
45    user_input = input
46
47
48if IS_PY2:
49    int_types = (int, long)  # noqa
50    text_type = unicode  # noqa
51else:
52    int_types = int
53    text_type = str
54
55
56LOG = logging.getLogger("tvdb_api")
57
58
59# Exceptions
60
61
62class TvdbBaseException(Exception):
63    """Base exception for all tvdb_api errors
64    """
65
66    pass
67
68
69class TvdbError(TvdbBaseException):
70    """An error with thetvdb.com (Cannot connect, for example)
71    """
72
73    pass
74
75
76class TvdbUserAbort(TvdbBaseException):
77    """User aborted the interactive selection (via
78    the q command, ^c etc)
79    """
80
81    pass
82
83
84class TvdbNotAuthorized(TvdbBaseException):
85    """An authorization error with thetvdb.com
86    """
87
88    pass
89
90
91class TvdbDataNotFound(TvdbBaseException):
92    """Base for all attribute-not-found errors (e.gg missing show/season/episode/data)
93    """
94
95    pass
96
97
98class TvdbShowNotFound(TvdbDataNotFound):
99    """Show cannot be found on thetvdb.com (non-existant show)
100    """
101
102    pass
103
104
105class TvdbSeasonNotFound(TvdbDataNotFound):
106    """Season cannot be found on thetvdb.com
107    """
108
109    pass
110
111
112class TvdbEpisodeNotFound(TvdbDataNotFound):
113    """Episode cannot be found on thetvdb.com
114    """
115
116    pass
117
118
119class TvdbResourceNotFound(TvdbDataNotFound):
120    """Resource cannot be found on thetvdb.com
121    """
122
123    pass
124
125
126class TvdbAttributeNotFound(TvdbDataNotFound):
127    """Raised if an episode does not have the requested
128    attribute (such as a episode name)
129    """
130
131    pass
132
133
134# Backwards compatability re-bindings
135tvdb_exception = TvdbBaseException  # Deprecated, for backwards compatability
136tvdb_error = TvdbError  # Deprecated, for backwards compatability
137tvdb_userabort = TvdbUserAbort  # Deprecated, for backwards compatability
138tvdb_notauthorized = TvdbNotAuthorized  # Deprecated, for backwards compatability
139tvdb_shownotfound = TvdbShowNotFound  # Deprecated, for backwards compatability
140tvdb_seasonnotfound = TvdbSeasonNotFound  # Deprecated, for backwards compatability
141tvdb_episodenotfound = TvdbEpisodeNotFound  # Deprecated, for backwards compatability
142tvdb_resourcenotfound = TvdbResourceNotFound  # Deprecated, for backwards compatability
143tvdb_attributenotfound = TvdbAttributeNotFound  # Deprecated, for backwards compatability
144
145tvdb_invalidlanguage = TvdbError  # Unused/removed. This exists for backwards compatability.
146
147
148# UI
149
150
151class BaseUI(object):
152    """Base user interface for Tvdb show selection.
153
154    Selects first show.
155
156    A UI is a callback. A class, it's __init__ function takes two arguments:
157
158    - config, which is the Tvdb config dict, setup in tvdb_api.py
159    - log, which is Tvdb's logger instance (which uses the logging module). You can
160    call log.info() log.warning() etc
161
162    It must have a method "selectSeries", this is passed a list of dicts, each dict
163    contains the the keys "name" (human readable show name), and "sid" (the shows
164    ID as on thetvdb.com). For example:
165
166    [{'name': u'Lost', 'sid': u'73739'},
167     {'name': u'Lost Universe', 'sid': u'73181'}]
168
169    The "selectSeries" method must return the appropriate dict, or it can raise
170    tvdb_userabort (if the selection is aborted), tvdb_shownotfound (if the show
171    cannot be found).
172
173    A simple example callback, which returns a random series:
174
175    >>> import random
176    >>> from tvdb_ui import BaseUI
177    >>> class RandomUI(BaseUI):
178    ...    def selectSeries(self, allSeries):
179    ...            import random
180    ...            return random.choice(allSeries)
181
182    Then to use it..
183
184    >>> from tvdb_api import Tvdb
185    >>> t = Tvdb(custom_ui = RandomUI)
186    >>> random_matching_series = t['Lost']
187    >>> type(random_matching_series)
188    <class 'tvdb_api.Show'>
189    """
190
191    def __init__(self, config, log=None):
192        self.config = config
193        if log is not None:
194            warnings.warn(
195                "the UI's log parameter is deprecated, instead use\n"
196                "use import logging; logging.getLogger('ui').info('blah')\n"
197                "The self.log attribute will be removed in the next version"
198            )
199            self.log = logging.getLogger(__name__)
200
201    def selectSeries(self, allSeries):
202        return allSeries[0]
203
204
205class ConsoleUI(BaseUI):
206    """Interactively allows the user to select a show from a console based UI
207    """
208
209    def _displaySeries(self, allSeries, limit=6):
210        """Helper function, lists series with corresponding ID
211        """
212        if limit is not None:
213            toshow = allSeries[:limit]
214        else:
215            toshow = allSeries
216
217        print("TVDB Search Results:")
218        for i, cshow in enumerate(toshow):
219            i_show = i + 1  # Start at more human readable number 1 (not 0)
220            LOG.debug('Showing allSeries[%s], series %s)' % (i_show, allSeries[i]['seriesName']))
221            if i == 0:
222                extra = " (default)"
223            else:
224                extra = ""
225
226            output = "%s -> %s [%s] # http://thetvdb.com/?tab=series&id=%s%s" % (
227                i_show,
228                cshow['seriesName'],
229                cshow['language'],
230                str(cshow['id']),
231                extra,
232            )
233            if IS_PY2:
234                print(output.encode("UTF-8", "ignore"))
235            else:
236                print(output)
237
238    def selectSeries(self, allSeries):
239        self._displaySeries(allSeries)
240
241        if len(allSeries) == 1:
242            # Single result, return it!
243            print("Automatically selecting only result")
244            return allSeries[0]
245
246        if self.config['select_first'] is True:
247            print("Automatically returning first search result")
248            return allSeries[0]
249
250        while True:  # return breaks this loop
251            try:
252                print("Enter choice (first number, return for default, 'all', ? for help):")
253                ans = user_input()
254            except KeyboardInterrupt:
255                raise tvdb_userabort("User aborted (^c keyboard interupt)")
256            except EOFError:
257                raise tvdb_userabort("User aborted (EOF received)")
258
259            LOG.debug('Got choice of: %s' % (ans))
260            try:
261                selected_id = int(ans) - 1  # The human entered 1 as first result, not zero
262            except ValueError:  # Input was not number
263                if len(ans.strip()) == 0:
264                    # Default option
265                    LOG.debug('Default option, returning first series')
266                    return allSeries[0]
267                if ans == "q":
268                    LOG.debug('Got quit command (q)')
269                    raise tvdb_userabort("User aborted ('q' quit command)")
270                elif ans == "?":
271                    print("## Help")
272                    print("# Enter the number that corresponds to the correct show.")
273                    print("# a - display all results")
274                    print("# all - display all results")
275                    print("# ? - this help")
276                    print("# q - abort tvnamer")
277                    print("# Press return with no input to select first result")
278                elif ans.lower() in ["a", "all"]:
279                    self._displaySeries(allSeries, limit=None)
280                else:
281                    LOG.debug('Unknown keypress %s' % (ans))
282            else:
283                LOG.debug('Trying to return ID: %d' % (selected_id))
284                try:
285                    return allSeries[selected_id]
286                except IndexError:
287                    LOG.debug('Invalid show number entered!')
288                    print("Invalid number (%s) selected!")
289                    self._displaySeries(allSeries)
290
291
292# Main API
293
294
295class ShowContainer(dict):
296    """Simple dict that holds a series of Show instances
297    """
298
299    def __init__(self):
300        self._stack = []
301        self._lastgc = time.time()
302
303    def __setitem__(self, key, value):
304        self._stack.append(key)
305
306        # keep only the 100th latest results
307        if time.time() - self._lastgc > 20:
308            for o in self._stack[:-100]:
309                del self[o]
310            self._stack = self._stack[-100:]
311
312            self._lastgc = time.time()
313
314        super(ShowContainer, self).__setitem__(key, value)
315
316
317class Show(dict):
318    """Holds a dict of seasons, and show data.
319    """
320
321    def __init__(self):
322        dict.__init__(self)
323        self.data = {}
324
325    def __repr__(self):
326        return "<Show %r (containing %s seasons)>" % (
327            self.data.get(u'seriesName', 'instance'),
328            len(self),
329        )
330
331    def __getitem__(self, key):
332        if key in self:
333            # Key is an episode, return it
334            return dict.__getitem__(self, key)
335
336        if key in self.data:
337            # Non-numeric request is for show-data
338            return dict.__getitem__(self.data, key)
339
340        # Data wasn't found, raise appropriate error
341        if isinstance(key, int) or key.isdigit():
342            # Episode number x was not found
343            raise tvdb_seasonnotfound("Could not find season %s" % (repr(key)))
344        else:
345            # If it's not numeric, it must be an attribute name, which
346            # doesn't exist, so attribute error.
347            raise tvdb_attributenotfound("Cannot find attribute %s" % (repr(key)))
348
349    def aired_on(self, date):
350        ret = self.search(str(date), 'firstAired')
351        if len(ret) == 0:
352            raise tvdb_episodenotfound("Could not find any episodes that aired on %s" % date)
353        return ret
354
355    def search(self, term=None, key=None):
356        """
357        Search all episodes in show. Can search all data, or a specific key
358        (for example, episodename)
359
360        Always returns an array (can be empty). First index contains the first
361        match, and so on.
362
363        Each array index is an Episode() instance, so doing
364        search_results[0]['episodename'] will retrieve the episode name of the
365        first match.
366
367        Search terms are converted to lower case (unicode) strings.
368
369        # Examples
370
371        These examples assume t is an instance of Tvdb():
372
373        >>> t = Tvdb()
374        >>>
375
376        To search for all episodes of Scrubs with a bit of data
377        containing "my first day":
378
379        >>> t['Scrubs'].search("my first day")
380        [<Episode 01x01 - u'My First Day'>]
381        >>>
382
383        Search for "My Name Is Earl" episode named "Faked His Own Death":
384
385        >>> t['My Name Is Earl'].search('Faked My Own Death', key='episodeName')
386        [<Episode 01x04 - u'Faked My Own Death'>]
387        >>>
388
389        To search Scrubs for all episodes with "mentor" in the episode name:
390
391        >>> t['scrubs'].search('mentor', key='episodeName')
392        [<Episode 01x02 - u'My Mentor'>, <Episode 03x15 - u'My Tormented Mentor'>]
393        >>>
394
395        # Using search results
396
397        >>> results = t['Scrubs'].search("my first")
398        >>> print results[0]['episodeName']
399        My First Day
400        >>> for x in results: print x['episodeName']
401        My First Day
402        My First Step
403        My First Kill
404        >>>
405        """
406        results = []
407        for cur_season in self.values():
408            searchresult = cur_season.search(term=term, key=key)
409            if len(searchresult) != 0:
410                results.extend(searchresult)
411
412        return results
413
414
415class Season(dict):
416    def __init__(self, show=None):
417        """The show attribute points to the parent show
418        """
419        self.show = show
420
421    def __repr__(self):
422        return "<Season instance (containing %s episodes)>" % (len(self.keys()))
423
424    def __getitem__(self, episode_number):
425        if episode_number not in self:
426            raise tvdb_episodenotfound("Could not find episode %s" % (repr(episode_number)))
427        else:
428            return dict.__getitem__(self, episode_number)
429
430    def search(self, term=None, key=None):
431        """Search all episodes in season, returns a list of matching Episode
432        instances.
433
434        >>> t = Tvdb()
435        >>> t['scrubs'][1].search('first day')
436        [<Episode 01x01 - u'My First Day'>]
437        >>>
438
439        See Show.search documentation for further information on search
440        """
441        results = []
442        for ep in self.values():
443            searchresult = ep.search(term=term, key=key)
444            if searchresult is not None:
445                results.append(searchresult)
446        return results
447
448
449class Episode(dict):
450    def __init__(self, season=None):
451        """The season attribute points to the parent season
452        """
453        self.season = season
454
455    def __repr__(self):
456        seasno = self.get(u'airedSeason', 0)
457        epno = self.get(u'airedEpisodeNumber', 0)
458        epname = self.get(u'episodeName')
459        if epname is not None:
460            return "<Episode %02dx%02d - %r>" % (seasno, epno, epname)
461        else:
462            return "<Episode %02dx%02d>" % (seasno, epno)
463
464    def __getitem__(self, key):
465        try:
466            return dict.__getitem__(self, key)
467        except KeyError:
468            raise tvdb_attributenotfound("Cannot find attribute %s" % (repr(key)))
469
470    def search(self, term=None, key=None):
471        """Search episode data for term, if it matches, return the Episode (self).
472        The key parameter can be used to limit the search to a specific element,
473        for example, episodename.
474
475        This primarily for use use by Show.search and Season.search. See
476        Show.search for further information on search
477
478        Simple example:
479
480        >>> e = Episode()
481        >>> e['episodeName'] = "An Example"
482        >>> e.search("examp")
483        <Episode 00x00 - 'An Example'>
484        >>>
485
486        Limiting by key:
487
488        >>> e.search("examp", key = "episodeName")
489        <Episode 00x00 - 'An Example'>
490        >>>
491        """
492        if term is None:
493            raise TypeError("must supply string to search for (contents)")
494
495        term = text_type(term).lower()
496        for cur_key, cur_value in self.items():
497            cur_key = text_type(cur_key)
498            cur_value = text_type(cur_value).lower()
499            if key is not None and cur_key != key:
500                # Do not search this key
501                continue
502            if cur_value.find(text_type(term)) > -1:
503                return self
504
505
506class Actors(list):
507    """Holds all Actor instances for a show
508    """
509
510    pass
511
512
513class Actor(dict):
514    """Represents a single actor. Should contain..
515
516    id,
517    image,
518    name,
519    role,
520    sortorder
521    """
522
523    def __repr__(self):
524        return "<Actor %r>" % self.get("name")
525
526
527def create_key(self, request):
528    """A new cache_key algo is required as the authentication token
529    changes with each run. Also there are other header params which
530    also change with each request (e.g. timestamp). Excluding all
531    headers means that Accept-Language is excluded which means
532    different language requests will return the cached response from
533    the wrong language.
534
535    The _loadurl part checks the cache before a get is performed so
536    that an auth token can be obtained. If the response is already in
537    the cache, the auth token is not required. This prevents the need
538    to do a get which may access the host and fail because the session
539    is not yet not authorized. It is not necessary to authorize if the
540    cache is to be used thus saving host and network traffic.
541    """
542
543    if self._ignored_parameters:
544        url, body = self._remove_ignored_parameters(request)
545    else:
546        url, body = request.url, request.body
547    key = hashlib.sha256()
548    key.update(_to_bytes(request.method.upper()))
549    key.update(_to_bytes(url))
550    if request.body:
551        key.update(_to_bytes(body))
552    else:
553        if self._include_get_headers and request.headers != _DEFAULT_HEADERS:
554            for name, value in sorted(request.headers.items()):
555                # include only Accept-Language as it is important for context
556                if name in ['Accept-Language']:
557                    key.update(_to_bytes(name))
558                    key.update(_to_bytes(value))
559    return key.hexdigest()
560
561
562class Tvdb:
563    """Create easy-to-use interface to name of season/episode name
564    >>> t = Tvdb()
565    >>> t['Scrubs'][1][24]['episodeName']
566    u'My Last Day'
567    """
568
569    def __init__(
570        self,
571        interactive=False,
572        select_first=False,
573        cache=True,
574        banners=False,
575        actors=False,
576        custom_ui=None,
577        language=None,
578        search_all_languages=False,
579        apikey=None,
580        username=None,
581        userkey=None,
582        forceConnect=None,  # noqa
583        dvdorder=False,
584    ):
585
586        """interactive (True/False):
587            When True, uses built-in console UI is used to select the correct show.
588            When False, the first search result is used.
589
590        select_first (True/False):
591            Automatically selects the first series search result (rather
592            than showing the user a list of more than one series).
593            Is overridden by interactive = False, or specifying a custom_ui
594
595        cache (True/False/str/requests_cache.CachedSession):
596
597            Retrieved URLs can be persisted to to disc.
598
599            True/False enable or disable default caching. Passing
600            string specifies the directory where to store the
601            "tvdb.sqlite3" cache file. Alternatively a custom
602            requests.Session instance can be passed (e.g maybe a
603            customised instance of `requests_cache.CachedSession`)
604
605        banners (True/False):
606            Retrieves the banners for a show. These are accessed
607            via the _banners key of a Show(), for example:
608
609            >>> Tvdb(banners=True)['scrubs']['_banners'].keys()
610            [u'fanart', u'poster', u'seasonwide', u'season', u'series']
611
612        actors (True/False):
613            Retrieves a list of the actors for a show. These are accessed
614            via the _actors key of a Show(), for example:
615
616            >>> t = Tvdb(actors=True)
617            >>> t['scrubs']['_actors'][0]['name']
618            u'John C. McGinley'
619
620        custom_ui (tvdb_ui.BaseUI subclass):
621            A callable subclass of tvdb_ui.BaseUI (overrides interactive option)
622
623        language (2 character language abbreviation):
624            The 2 digit language abbreviation used for the returned data,
625            and is also used when searching. For a complete list, call
626            the `Tvdb.available_languages` method.
627            Default is "en" (English).
628
629        search_all_languages (True/False):
630            By default, Tvdb will only search in the language specified using
631            the language option. When this is True, it will search for the
632            show in and language
633
634        apikey (str/unicode):
635            Your API key for TheTVDB. You can easily register a key with in
636            a few minutes:
637            https://thetvdb.com/api-information
638
639        username (str/unicode or None):
640            Specify a user account to use for actions which require
641            authentication (e.g marking a series as favourite, submitting ratings)
642
643        userkey (str/unicode, or None):
644            User authentication key relating to "username".
645
646        forceConnect:
647            DEPRECATED. Disabled the timeout-throttling logic. Now has no function
648        """
649
650        if forceConnect is not None:
651            warnings.warn(
652                "forceConnect argument is deprecated and will be removed soon",
653                category=DeprecationWarning,
654            )
655
656        self.shows = ShowContainer()  # Holds all Show classes
657        self.corrections = {}  # Holds show-name to show_id mapping
658
659        self.config = {}
660
661        # Ability to pull key form env-var mostly for unit-tests
662        _test_key = os.getenv('TVDB_API_KEY')
663        if apikey is None and _test_key is not None:
664            apikey = _test_key
665
666        if apikey is None:
667            raise ValueError(
668                (
669                    "apikey argument is now required - an API key can be easily registered "
670                    "at https://thetvdb.com/api-information"
671                )
672            )
673        self.config['auth_payload'] = {
674            "apikey": apikey,
675            "username": username or "",
676            "userkey": userkey or "",
677        }
678
679        self.config['custom_ui'] = custom_ui
680
681        self.config['interactive'] = interactive  # prompt for correct series?
682
683        self.config['select_first'] = select_first
684
685        self.config['search_all_languages'] = search_all_languages
686
687        self.config['dvdorder'] = dvdorder
688
689        if cache is True:
690            cache_dir = self._getTempDir()
691            LOG.debug("Caching using requests_cache to %s" % cache_dir)
692            self.session = requests_cache.CachedSession(
693                expire_after=21600,  # 6 hours
694                backend='sqlite',
695                cache_name=cache_dir,
696                include_get_headers=True,
697            )
698            self.session.cache.create_key = types.MethodType(create_key, self.session.cache)
699            self.session.remove_expired_responses()
700            self.config['cache_enabled'] = True
701        elif cache is False:
702            LOG.debug("Caching disabled")
703            self.session = requests.Session()
704            self.config['cache_enabled'] = False
705        elif isinstance(cache, str):
706            LOG.debug("Caching using requests_cache to specified directory %s" % cache)
707            # Specified cache path
708            self.session = requests_cache.CachedSession(
709                expire_after=21600,  # 6 hours
710                backend='sqlite',
711                cache_name=os.path.join(cache, "tvdb_api"),
712                include_get_headers=True,
713            )
714            self.session.cache.create_key = types.MethodType(create_key, self.session.cache)
715            self.session.remove_expired_responses()
716        else:
717            LOG.debug("Using specified requests.Session")
718            self.session = cache
719            try:
720                self.session.get
721            except AttributeError:
722                raise ValueError(
723                    (
724                        "cache argument must be True/False, string as cache path "
725                        "or requests.Session-type object (e.g from requests_cache.CachedSession)"
726                    )
727                )
728
729        self.config['banners_enabled'] = banners
730        self.config['actors_enabled'] = actors
731
732        if language is None:
733            self.config['language'] = 'en'
734        else:
735            self.config['language'] = language
736
737        # The following url_ configs are based of the
738        # https://api.thetvdb.com/swagger
739        self.config['base_url'] = "http://thetvdb.com"
740        self.config['api_url'] = "https://api.thetvdb.com"
741
742        self.config['url_getSeries'] = u"%(api_url)s/search/series?name=%%s" % self.config
743
744        self.config['url_epInfo'] = u"%(api_url)s/series/%%s/episodes" % self.config
745
746        self.config['url_seriesInfo'] = u"%(api_url)s/series/%%s" % self.config
747        self.config['url_actorsInfo'] = u"%(api_url)s/series/%%s/actors" % self.config
748
749        self.config['url_seriesBanner'] = u"%(api_url)s/series/%%s/images" % self.config
750        self.config['url_seriesBannerInfo'] = (
751            u"%(api_url)s/series/%%s/images/query?keyType=%%s" % self.config
752        )
753        self.config['url_artworkPrefix'] = u"%(base_url)s/banners/%%s" % self.config
754
755        self.__authorized = False
756        self.headers = {
757            'Content-Type': 'application/json',
758            'Accept': 'application/json',
759            'Accept-Language': self.config['language'],
760        }
761
762    def _getTempDir(self):
763        """Returns the [system temp dir]/tvdb_api-u501 (or
764        tvdb_api-myuser)
765        """
766        py_major = sys.version_info[
767            0
768        ]  # Prefix with major version as 2 and 3 write incompatible caches
769        if hasattr(os, 'getuid'):
770            uid = "-u%d" % (os.getuid())
771        else:
772            # For Windows
773            uid = getpass.getuser()
774
775        return os.path.join(tempfile.gettempdir(), "tvdb_api-%s-py%s" % (uid, py_major))
776
777    def _loadUrl(self, url, data=None, recache=False, language=None):
778        """Return response from The TVDB API"""
779
780        if not language:
781            language = self.config['language']
782        self.headers['Accept-Language'] = language
783
784        # TODO: Handle Exceptions
785        # TODO: Update Token
786
787        # encoded url is used for hashing in the cache so
788        # python 2 and 3 generate the same hash
789        if not self.__authorized:
790            # only authorize of we haven't before and we
791            # don't have the url in the cache
792            fake_session_for_key = requests.Session()
793            fake_session_for_key.headers['Accept-Language'] = language
794            cache_key = None
795            try:
796                # in case the session class has no cache object, fail gracefully
797                cache_key = self.session.cache.create_key(
798                    fake_session_for_key.prepare_request(requests.Request('GET', url))
799                )
800            except Exception:
801                # FIXME: Can this just check for hasattr(self.session, "cache") instead?
802                pass
803
804            # fmt: off
805            # No fmt because mangles noqa comment - https://github.com/psf/black/issues/195
806            if not cache_key or not self.session.cache.has_key(cache_key): # noqa: not a dict, has_key is part of requests-cache API
807                self.authorize()
808            # fmt: on
809
810        response = self.session.get(url, headers=self.headers)
811        r = response.json()
812        LOG.debug("loadurl: %s language=%s" % (url, language))
813        LOG.debug("response:")
814        LOG.debug(r)
815        error = r.get('Error')
816        errors = r.get('errors')
817        r_data = r.get('data')
818        links = r.get('links')
819
820        if error:
821            if error == u'Resource not found':
822                # raise(tvdb_resourcenotfound)
823                # handle no data at a different level so it is more specific
824                pass
825            elif error.lower() == u'not authorized':
826                # Note: Error string sometimes comes back as "Not authorized" or "Not Authorized"
827                raise tvdb_notauthorized()
828            elif error.startswith(u"ID: ") and error.endswith("not found"):
829                # FIXME: Refactor error out of in this method
830                raise tvdb_shownotfound("%s" % error)
831            else:
832                raise tvdb_error("%s" % error)
833
834        if errors:
835            if errors and u'invalidLanguage' in errors:
836                # raise(tvdb_invalidlanguage(errors[u'invalidLanguage']))
837                # invalidLanguage does not mean there is no data
838                # there is just less data (missing translations)
839                pass
840
841        if data and isinstance(data, list):
842            data.extend(r_data)
843        else:
844            data = r_data
845
846        if links and links['next']:
847            url = url.split('?')[0]
848            _url = url + "?page=%s" % links['next']
849            self._loadUrl(_url, data)
850
851        return data
852
853    def authorize(self):
854        LOG.debug("auth")
855        r = self.session.post(
856            'https://api.thetvdb.com/login', json=self.config['auth_payload'], headers=self.headers
857        )
858        r_json = r.json()
859        error = r_json.get('Error')
860        if error:
861            if error == u'Not Authorized':
862                raise (tvdb_notauthorized)
863        token = r_json.get('token')
864        self.headers['Authorization'] = "Bearer %s" % text_type(token)
865        self.__authorized = True
866
867    def _getetsrc(self, url, language=None):
868        """Loads a URL using caching, returns an ElementTree of the source
869        """
870        src = self._loadUrl(url, language=language)
871
872        return src
873
874    def _setItem(self, sid, seas, ep, attrib, value):
875        """Creates a new episode, creating Show(), Season() and
876        Episode()s as required. Called by _getShowData to populate show
877
878        Since the nice-to-use tvdb[1][24]['name] interface
879        makes it impossible to do tvdb[1][24]['name] = "name"
880        and still be capable of checking if an episode exists
881        so we can raise tvdb_shownotfound, we have a slightly
882        less pretty method of setting items.. but since the API
883        is supposed to be read-only, this is the best way to
884        do it!
885        The problem is that calling tvdb[1][24]['episodename'] = "name"
886        calls __getitem__ on tvdb[1], there is no way to check if
887        tvdb.__dict__ should have a key "1" before we auto-create it
888        """
889        if sid not in self.shows:
890            self.shows[sid] = Show()
891        if seas not in self.shows[sid]:
892            self.shows[sid][seas] = Season(show=self.shows[sid])
893        if ep not in self.shows[sid][seas]:
894            self.shows[sid][seas][ep] = Episode(season=self.shows[sid][seas])
895        self.shows[sid][seas][ep][attrib] = value
896
897    def _setShowData(self, sid, key, value):
898        """Sets self.shows[sid] to a new Show instance, or sets the data
899        """
900        if sid not in self.shows:
901            self.shows[sid] = Show()
902        self.shows[sid].data[key] = value
903
904    def search(self, series):
905        """This searches TheTVDB.com for the series name
906        and returns the result list
907        """
908        series = url_quote(series.encode("utf-8"))
909        LOG.debug("Searching for show %s" % series)
910        series_resp = self._getetsrc(self.config['url_getSeries'] % (series))
911        if not series_resp:
912            LOG.debug('Series result returned zero')
913            raise tvdb_shownotfound(
914                "Show-name search returned zero results (cannot find show on TVDB)"
915            )
916
917        all_series = []
918        for series in series_resp:
919            series['language'] = self.config['language']
920            LOG.debug('Found series %(seriesName)s' % series)
921            all_series.append(series)
922
923        return all_series
924
925    def _getSeries(self, series):
926        """This searches TheTVDB.com for the series name,
927        If a custom_ui UI is configured, it uses this to select the correct
928        series. If not, and interactive == True, ConsoleUI is used, if not
929        BaseUI is used to select the first result.
930        """
931        all_series = self.search(series)
932
933        if self.config['custom_ui'] is not None:
934            LOG.debug("Using custom UI %s" % (repr(self.config['custom_ui'])))
935            ui = self.config['custom_ui'](config=self.config)
936        else:
937            if not self.config['interactive']:
938                LOG.debug('Auto-selecting first search result using BaseUI')
939                ui = BaseUI(config=self.config)
940            else:
941                LOG.debug('Interactively selecting show using ConsoleUI')
942                ui = ConsoleUI(config=self.config)
943
944        return ui.selectSeries(all_series)
945
946    def available_languages(self):
947        """Returns a list of the available language abbreviations
948        which can be used in Tvdb(language="...") etc
949        """
950        et = self._getetsrc("https://api.thetvdb.com/languages")
951        languages = [x['abbreviation'] for x in et]
952        return sorted(languages)
953
954    def _parseBanners(self, sid):
955        """Parses banners XML, from
956        http://thetvdb.com/api/[APIKEY]/series/[SERIES ID]/banners.xml
957
958        Banners are retrieved using t['show name]['_banners'], for example:
959
960        >>> t = Tvdb(banners = True)
961        >>> t['scrubs']['_banners'].keys()
962        [u'fanart', u'poster', u'seasonwide', u'season', u'series']
963        >>> t['scrubs']['_banners']['poster']['680x1000'][35308]['_bannerpath']
964        u'http://thetvdb.com/banners/posters/76156-2.jpg'
965        >>>
966
967        Any key starting with an underscore has been processed (not the raw
968        data from the XML)
969
970        This interface will be improved in future versions.
971        """
972        LOG.debug('Getting season banners for %s' % (sid))
973        banners_resp = self._getetsrc(self.config['url_seriesBanner'] % sid)
974        banners = {}
975        for cur_banner in banners_resp.keys():
976            banners_info = self._getetsrc(self.config['url_seriesBannerInfo'] % (sid, cur_banner))
977            for banner_info in banners_info:
978                bid = banner_info.get('id')
979                btype = banner_info.get('keyType')
980                btype2 = banner_info.get('resolution')
981                if btype is None or btype2 is None:
982                    continue
983
984                if btype not in banners:
985                    banners[btype] = {}
986                if btype2 not in banners[btype]:
987                    banners[btype][btype2] = {}
988                if bid not in banners[btype][btype2]:
989                    banners[btype][btype2][bid] = {}
990
991                banners[btype][btype2][bid]['bannerpath'] = banner_info['fileName']
992                banners[btype][btype2][bid]['resolution'] = banner_info['resolution']
993                banners[btype][btype2][bid]['subKey'] = banner_info['subKey']
994
995                for k, v in list(banners[btype][btype2][bid].items()):
996                    if k.endswith("path"):
997                        new_key = "_%s" % k
998                        LOG.debug("Transforming %s to %s" % (k, new_key))
999                        new_url = self.config['url_artworkPrefix'] % v
1000                        banners[btype][btype2][bid][new_key] = new_url
1001
1002            banners[btype]['raw'] = banners_info
1003            self._setShowData(sid, "_banners", banners)
1004
1005    def _parseActors(self, sid):
1006        """Parsers actors XML, from
1007        http://thetvdb.com/api/[APIKEY]/series/[SERIES ID]/actors.xml
1008
1009        Actors are retrieved using t['show name]['_actors'], for example:
1010
1011        >>> t = Tvdb(actors = True)
1012        >>> actors = t['scrubs']['_actors']
1013        >>> type(actors)
1014        <class 'tvdb_api.Actors'>
1015        >>> type(actors[0])
1016        <class 'tvdb_api.Actor'>
1017        >>> actors[0]
1018        <Actor u'John C. McGinley'>
1019        >>> sorted(actors[0].keys())
1020        [u'id', u'image', u'imageAdded', u'imageAuthor', u'lastUpdated', u'name', u'role',
1021        u'seriesId', u'sortOrder']
1022        >>> actors[0]['name']
1023        u'John C. McGinley'
1024        >>> actors[0]['image']
1025        u'http://thetvdb.com/banners/actors/43638.jpg'
1026
1027        Any key starting with an underscore has been processed (not the raw
1028        data from the XML)
1029        """
1030        LOG.debug("Getting actors for %s" % (sid))
1031        actors_resp = self._getetsrc(self.config['url_actorsInfo'] % (sid))
1032
1033        cur_actors = Actors()
1034        for cur_actor_item in actors_resp:
1035            cur_actor = Actor()
1036            for tag, value in cur_actor_item.items():
1037                if value is not None:
1038                    if tag == "image":
1039                        value = self.config['url_artworkPrefix'] % (value)
1040                cur_actor[tag] = value
1041            cur_actors.append(cur_actor)
1042        self._setShowData(sid, '_actors', cur_actors)
1043
1044    def _getShowData(self, sid, language):
1045        """Takes a series ID, gets the epInfo URL and parses the TVDB
1046        XML file into the shows dict in layout:
1047        shows[series_id][season_number][episode_number]
1048        """
1049
1050        if self.config['language'] is None:
1051            LOG.debug('Config language is none, using show language')
1052            if language is None:
1053                raise tvdb_error("config['language'] was None, this should not happen")
1054        else:
1055            LOG.debug(
1056                'Configured language %s override show language of %s'
1057                % (self.config['language'], language)
1058            )
1059
1060        # Parse show information
1061        LOG.debug('Getting all series data for %s' % (sid))
1062        series_info_resp = self._getetsrc(self.config['url_seriesInfo'] % sid)
1063        for tag, value in series_info_resp.items():
1064            if value is not None:
1065                if tag in ['banner', 'fanart', 'poster']:
1066                    value = self.config['url_artworkPrefix'] % (value)
1067
1068            self._setShowData(sid, tag, value)
1069        # set language
1070        self._setShowData(sid, u'language', self.config['language'])
1071
1072        # Parse banners
1073        if self.config['banners_enabled']:
1074            self._parseBanners(sid)
1075
1076        # Parse actors
1077        if self.config['actors_enabled']:
1078            self._parseActors(sid)
1079
1080        # Parse episode data
1081        LOG.debug('Getting all episodes of %s' % (sid))
1082
1083        url = self.config['url_epInfo'] % sid
1084
1085        eps_resp = self._getetsrc(url, language=language)
1086
1087        for cur_ep in eps_resp:
1088
1089            if self.config['dvdorder']:
1090                LOG.debug('Using DVD ordering.')
1091                use_dvd = (
1092                    cur_ep.get('dvdSeason') is not None
1093                    and cur_ep.get('dvdEpisodeNumber') is not None
1094                )
1095            else:
1096                use_dvd = False
1097
1098            if use_dvd:
1099                elem_seasnum, elem_epno = cur_ep.get('dvdSeason'), cur_ep.get('dvdEpisodeNumber')
1100            else:
1101                elem_seasnum, elem_epno = cur_ep['airedSeason'], cur_ep['airedEpisodeNumber']
1102
1103            if elem_seasnum is None or elem_epno is None:
1104                LOG.warning(
1105                    "An episode has incomplete season/episode number (season: %r, episode: %r)"
1106                    % (elem_seasnum, elem_epno)
1107                )
1108                # TODO: Should this happen?
1109                continue  # Skip to next episode
1110
1111            seas_no = elem_seasnum
1112            ep_no = elem_epno
1113
1114            for cur_item in cur_ep.keys():
1115                tag = cur_item
1116                value = cur_ep[cur_item]
1117                if value is not None:
1118                    if tag == 'filename':
1119                        value = self.config['url_artworkPrefix'] % (value)
1120                self._setItem(sid, seas_no, ep_no, tag, value)
1121
1122    def _nameToSid(self, name):
1123        """Takes show name, returns the correct series ID (if the show has
1124        already been grabbed), or grabs all episodes and returns
1125        the correct SID.
1126        """
1127        if name in self.corrections:
1128            LOG.debug('Correcting %s to %s' % (name, self.corrections[name]))
1129            sid = self.corrections[name]
1130        else:
1131            LOG.debug('Getting show %s' % name)
1132            selected_series = self._getSeries(name)
1133            sid = selected_series['id']
1134            LOG.debug('Got %(seriesName)s, id %(id)s' % selected_series)
1135
1136            self.corrections[name] = sid
1137            self._getShowData(selected_series['id'], self.config['language'])
1138
1139        return sid
1140
1141    def __getitem__(self, key):
1142        """Handles tvdb_instance['seriesname'] calls.
1143        The dict index should be the show id
1144        """
1145        if isinstance(key, int_types):
1146            sid = key
1147        else:
1148            sid = self._nameToSid(key)
1149            LOG.debug('Got series id %s' % sid)
1150
1151        if sid not in self.shows:
1152            self._getShowData(sid, self.config['language'])
1153        return self.shows[sid]
1154
1155    def __repr__(self):
1156        return repr(self.shows)
1157
1158
1159def main():
1160    """Simple example of using tvdb_api - it just
1161    grabs an episode name interactively.
1162    """
1163    import logging
1164
1165    logging.basicConfig(level=logging.DEBUG)
1166
1167    tvdb_instance = Tvdb(interactive=False, cache=False)
1168    print(tvdb_instance['Lost']['seriesname'])
1169    print(tvdb_instance['Lost'][1][4]['episodename'])
1170
1171
1172if __name__ == '__main__':
1173    main()
1174