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