├── MANIFEST.in ├── .gitignore ├── setup.py ├── tmdb3 ├── __init__.py ├── cache_null.py ├── cache_engine.py ├── tmdb_exceptions.py ├── pager.py ├── tmdb_auth.py ├── cache.py ├── request.py ├── cache_file.py ├── util.py ├── locales.py └── tmdb_api.py ├── LICENSE ├── scripts ├── pytmdb3.py └── populate_locale.py └── README.md /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # GVIM stuff # 2 | ############## 3 | .*.swp 4 | 5 | # Python stuff # 6 | ################ 7 | build 8 | dist 9 | tmdb3.egg-info 10 | *.pyc 11 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | try: 4 | from setuptools import setup 5 | except ImportError: 6 | from distutils.core import setup 7 | 8 | 9 | with open('README.md') as f: 10 | long_description = f.read() 11 | 12 | setup( 13 | name='tmdb3', 14 | version='0.7.2', 15 | description='TheMovieDB.org APIv3 interface', 16 | long_description=long_description, 17 | author='Raymond Wagner', 18 | author_email='raymond@wagnerrp.com', 19 | packages=['tmdb3'] 20 | ) 21 | -------------------------------------------------------------------------------- /tmdb3/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from tmdb_api import Configuration, searchMovie, searchMovieWithYear, \ 4 | searchPerson, searchStudio, searchList, searchCollection, \ 5 | searchSeries, Person, Movie, Collection, Genre, List, \ 6 | Series, Studio, Network, Episode, Season, __version__ 7 | from request import set_key, set_cache 8 | from locales import get_locale, set_locale 9 | from tmdb_auth import get_session, set_session 10 | from cache_engine import CacheEngine 11 | from tmdb_exceptions import * 12 | 13 | -------------------------------------------------------------------------------- /tmdb3/cache_null.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | #----------------------- 4 | # Name: cache_null.py 5 | # Python Library 6 | # Author: Raymond Wagner 7 | # Purpose: Null caching engine for debugging purposes 8 | #----------------------- 9 | 10 | from cache_engine import CacheEngine 11 | 12 | 13 | class NullEngine(CacheEngine): 14 | """Non-caching engine for debugging.""" 15 | name = 'null' 16 | 17 | def configure(self): 18 | pass 19 | 20 | def get(self, date): 21 | return [] 22 | 23 | def put(self, key, value, lifetime): 24 | return [] 25 | 26 | def expire(self, key): 27 | pass 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012-2013, Raymond Wagner 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of the nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /scripts/pytmdb3.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from optparse import OptionParser 4 | from tmdb3 import * 5 | 6 | import sys 7 | 8 | if __name__ == '__main__': 9 | # this key is registered to this library for testing purposes. 10 | # please register for your own key if you wish to use this 11 | # library in your own application. 12 | # http://help.themoviedb.org/kb/api/authentication-basics 13 | set_key('1acd79ff610c77f3040073d004f7f5b0') 14 | 15 | parser = OptionParser() 16 | parser.add_option('-v', "--version", action="store_true", default=False, 17 | dest="version", help="Display version.") 18 | parser.add_option('-d', "--debug", action="store_true", default=False, 19 | dest="debug", help="Enables verbose debugging.") 20 | parser.add_option('-c', "--no-cache", action="store_true", default=False, 21 | dest="nocache", help="Disables request cache.") 22 | opts, args = parser.parse_args() 23 | 24 | if opts.version: 25 | from tmdb3.tmdb_api import __title__, __purpose__, __version__, __author__ 26 | print __title__ 27 | print "" 28 | print __purpose__ 29 | print "Version: "+__version__ 30 | sys.exit(0) 31 | 32 | if opts.nocache: 33 | set_cache(engine='null') 34 | else: 35 | set_cache(engine='file', filename='/tmp/pytmdb3.cache') 36 | 37 | if opts.debug: 38 | request.DEBUG = True 39 | 40 | banner = 'PyTMDB3 Interactive Shell.' 41 | import code 42 | try: 43 | import readline, rlcompleter 44 | except ImportError: 45 | pass 46 | else: 47 | readline.parse_and_bind("tab: complete") 48 | banner += ' TAB completion available.' 49 | namespace = globals().copy() 50 | namespace.update(locals()) 51 | code.InteractiveConsole(namespace).interact(banner) 52 | -------------------------------------------------------------------------------- /scripts/populate_locale.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | #----------------------- 4 | # Name: populate_locale.py Helper for grabbing ISO639 and ISO3316 data 5 | # Python Library 6 | # Author: Raymond Wagner 7 | #----------------------- 8 | 9 | import lxml.html 10 | import sys 11 | import os 12 | 13 | 14 | def sanitize(name): 15 | name = ' '.join(name.split()) 16 | return name 17 | 18 | 19 | fpath = os.path.join(os.getcwd(), __file__) if not __file__.startswith('/') else __file__ 20 | fpath = os.path.join(fpath.rsplit('/', 2)[0], 'tmdb3/locales.py') 21 | 22 | fd = open(fpath, 'r') 23 | while True: 24 | line = fd.readline() 25 | if len(line) == 0: 26 | print "code endpoint not found, aborting!" 27 | sys.exit(1) 28 | if line.startswith('########'): 29 | endpt = fd.tell() 30 | break 31 | 32 | fd = open(fpath, 'a') 33 | fd.seek(endpt) 34 | fd.truncate() 35 | fd.write('\n') 36 | 37 | root = lxml.html.parse('http://www.loc.gov/standards/iso639-2/php/English_list.php') 38 | for row in root.getroot().getchildren()[3].getchildren()[2].getchildren()[0]\ 39 | .getchildren()[0].getchildren()[9]: 40 | if row.getchildren()[0].tag == "th": 41 | # skip header 42 | continue 43 | if row.getchildren()[-1].text == u"\xa0": 44 | # skip empty 639-1 code 45 | continue 46 | name, _, _, iso639_2, iso639_1 = [t.text for t in row] 47 | 48 | fd.write('Language("{0}", "{1}", u"{2}")\n'.format(iso639_1, iso639_2, sanitize(name).encode('utf8'))) 49 | 50 | root = lxml.html.parse('http://www.iso.org/iso/country_codes/iso_3166_code_lists/country_names_and_code_elements.htm').getroot() 51 | for row in root.get_element_by_id('tc_list'): 52 | if row.tag == 'thead': 53 | # skip header 54 | continue 55 | name, _, alpha2 = [t.text if t.text else t.getchildren()[0].tail for t in row] 56 | fd.write('Country("{0}", u"{1}")\n'.format(alpha2, sanitize(name).encode('utf8'))) 57 | -------------------------------------------------------------------------------- /tmdb3/cache_engine.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | #----------------------- 4 | # Name: cache_engine.py 5 | # Python Library 6 | # Author: Raymond Wagner 7 | # Purpose: Base cache engine class for collecting registered engines 8 | #----------------------- 9 | 10 | import time 11 | from weakref import ref 12 | 13 | 14 | class Engines(object): 15 | """ 16 | Static collector for engines to register against. 17 | """ 18 | def __init__(self): 19 | self._engines = {} 20 | 21 | def register(self, engine): 22 | self._engines[engine.__name__] = engine 23 | self._engines[engine.name] = engine 24 | 25 | def __getitem__(self, key): 26 | return self._engines[key] 27 | 28 | def __contains__(self, key): 29 | return self._engines.__contains__(key) 30 | 31 | Engines = Engines() 32 | 33 | 34 | class CacheEngineType(type): 35 | """ 36 | Cache Engine Metaclass that registers new engines against the cache 37 | for named selection and use. 38 | """ 39 | def __init__(cls, name, bases, attrs): 40 | super(CacheEngineType, cls).__init__(name, bases, attrs) 41 | if name != 'CacheEngine': 42 | # skip base class 43 | Engines.register(cls) 44 | 45 | 46 | class CacheEngine(object): 47 | __metaclass__ = CacheEngineType 48 | name = 'unspecified' 49 | 50 | def __init__(self, parent): 51 | self.parent = ref(parent) 52 | 53 | def configure(self): 54 | raise RuntimeError 55 | def get(self, date): 56 | raise RuntimeError 57 | def put(self, key, value, lifetime): 58 | raise RuntimeError 59 | def expire(self, key): 60 | raise RuntimeError 61 | 62 | 63 | class CacheObject(object): 64 | """ 65 | Cache object class, containing one stored record. 66 | """ 67 | 68 | def __init__(self, key, data, lifetime=0, creation=None): 69 | self.key = key 70 | self.data = data 71 | self.lifetime = lifetime 72 | self.creation = creation if creation is not None else time.time() 73 | 74 | def __len__(self): 75 | return len(self.data) 76 | 77 | @property 78 | def expired(self): 79 | return self.remaining == 0 80 | 81 | @property 82 | def remaining(self): 83 | return max((self.creation + self.lifetime) - time.time(), 0) 84 | 85 | -------------------------------------------------------------------------------- /tmdb3/tmdb_exceptions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | #----------------------- 4 | # Name: tmdb_exceptions.py Common exceptions used in tmdbv3 API library 5 | # Python Library 6 | # Author: Raymond Wagner 7 | #----------------------- 8 | 9 | 10 | class TMDBError(Exception): 11 | Error = 0 12 | KeyError = 10 13 | KeyMissing = 20 14 | KeyInvalid = 30 15 | KeyRevoked = 40 16 | RequestError = 50 17 | RequestInvalid = 51 18 | PagingIssue = 60 19 | CacheError = 70 20 | CacheReadError = 71 21 | CacheWriteError = 72 22 | CacheDirectoryError = 73 23 | ImageSizeError = 80 24 | HTTPError = 90 25 | Offline = 100 26 | LocaleError = 110 27 | 28 | def __init__(self, msg=None, errno=0): 29 | self.errno = errno 30 | if errno == 0: 31 | self.errno = getattr(self, 'TMDB'+self.__class__.__name__, errno) 32 | self.args = (msg,) 33 | 34 | 35 | class TMDBKeyError(TMDBError): 36 | pass 37 | 38 | 39 | class TMDBKeyMissing(TMDBKeyError): 40 | pass 41 | 42 | 43 | class TMDBKeyInvalid(TMDBKeyError): 44 | pass 45 | 46 | 47 | class TMDBKeyRevoked(TMDBKeyInvalid): 48 | pass 49 | 50 | 51 | class TMDBRequestError(TMDBError): 52 | pass 53 | 54 | 55 | class TMDBRequestInvalid(TMDBRequestError): 56 | pass 57 | 58 | 59 | class TMDBPagingIssue(TMDBRequestError): 60 | pass 61 | 62 | 63 | class TMDBCacheError(TMDBRequestError): 64 | pass 65 | 66 | 67 | class TMDBCacheReadError(TMDBCacheError): 68 | def __init__(self, filename): 69 | super(TMDBCacheReadError, self).__init__( 70 | "User does not have permission to access cache file: {0}."\ 71 | .format(filename)) 72 | self.filename = filename 73 | 74 | 75 | class TMDBCacheWriteError(TMDBCacheError): 76 | def __init__(self, filename): 77 | super(TMDBCacheWriteError, self).__init__( 78 | "User does not have permission to write cache file: {0}."\ 79 | .format(filename)) 80 | self.filename = filename 81 | 82 | 83 | class TMDBCacheDirectoryError(TMDBCacheError): 84 | def __init__(self, filename): 85 | super(TMDBCacheDirectoryError, self).__init__( 86 | "Directory containing cache file does not exist: {0}."\ 87 | .format(filename)) 88 | self.filename = filename 89 | 90 | 91 | class TMDBImageSizeError(TMDBError ): 92 | pass 93 | 94 | 95 | class TMDBHTTPError(TMDBError): 96 | def __init__(self, err): 97 | self.httperrno = err.code 98 | self.response = err.fp.read() 99 | super(TMDBHTTPError, self).__init__(str(err)) 100 | 101 | 102 | class TMDBOffline(TMDBError): 103 | pass 104 | 105 | 106 | class TMDBLocaleError(TMDBError): 107 | pass 108 | -------------------------------------------------------------------------------- /tmdb3/pager.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | #----------------------- 4 | # Name: pager.py List-like structure designed for handling paged results 5 | # Python Library 6 | # Author: Raymond Wagner 7 | #----------------------- 8 | 9 | from collections import Sequence, Iterator 10 | 11 | 12 | class PagedIterator(Iterator): 13 | def __init__(self, parent): 14 | self._parent = parent 15 | self._index = -1 16 | self._len = len(parent) 17 | 18 | def __iter__(self): 19 | return self 20 | 21 | def next(self): 22 | self._index += 1 23 | if self._index == self._len: 24 | raise StopIteration 25 | try: 26 | return self._parent[self._index] 27 | except IndexError: 28 | raise StopIteration 29 | 30 | 31 | class UnpagedData(object): 32 | def copy(self): 33 | return self.__class__() 34 | 35 | def __mul__(self, other): 36 | return (self.copy() for a in range(other)) 37 | 38 | def __rmul__(self, other): 39 | return (self.copy() for a in range(other)) 40 | 41 | 42 | class PagedList(Sequence): 43 | """ 44 | List-like object, with support for automatically grabbing 45 | additional pages from a data source. 46 | """ 47 | _iter_class = None 48 | 49 | def __iter__(self): 50 | if self._iter_class is None: 51 | self._iter_class = type(self.__class__.__name__ + 'Iterator', 52 | (PagedIterator,), {}) 53 | return self._iter_class(self) 54 | 55 | def __len__(self): 56 | try: 57 | return self._len 58 | except: 59 | return len(self._data) 60 | 61 | def __init__(self, iterable, pagesize=20): 62 | self._data = list(iterable) 63 | self._pagesize = pagesize 64 | 65 | def __getitem__(self, index): 66 | if isinstance(index, slice): 67 | return [self[x] for x in xrange(*index.indices(len(self)))] 68 | if index >= len(self): 69 | raise IndexError("list index outside range") 70 | if (index >= len(self._data)) \ 71 | or isinstance(self._data[index], UnpagedData): 72 | self._populatepage(index/self._pagesize + 1) 73 | return self._data[index] 74 | 75 | def __setitem__(self, index, value): 76 | raise NotImplementedError 77 | 78 | def __delitem__(self, index): 79 | raise NotImplementedError 80 | 81 | def __contains__(self, item): 82 | raise NotImplementedError 83 | 84 | def _populatepage(self, page): 85 | pagestart = (page-1) * self._pagesize 86 | if len(self._data) < pagestart: 87 | self._data.extend(UnpagedData()*(pagestart-len(self._data))) 88 | if len(self._data) == pagestart: 89 | self._data.extend(self._getpage(page)) 90 | else: 91 | for data in self._getpage(page): 92 | self._data[pagestart] = data 93 | pagestart += 1 94 | 95 | def _getpage(self, page): 96 | raise NotImplementedError("PagedList._getpage() must be provided " + 97 | "by subclass") 98 | 99 | 100 | class PagedRequest(PagedList): 101 | """ 102 | Derived PageList that provides a list-like object with automatic 103 | paging intended for use with search requests. 104 | """ 105 | def __init__(self, request, handler=None): 106 | self._request = request 107 | if handler: 108 | self._handler = handler 109 | super(PagedRequest, self).__init__(self._getpage(1), 20) 110 | 111 | def _getpage(self, page): 112 | req = self._request.new(page=page) 113 | res = req.readJSON() 114 | self._len = res['total_results'] 115 | for item in res['results']: 116 | if item is None: 117 | yield None 118 | else: 119 | yield self._handler(item) 120 | -------------------------------------------------------------------------------- /tmdb3/tmdb_auth.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | #----------------------- 4 | # Name: tmdb_auth.py 5 | # Python Library 6 | # Author: Raymond Wagner 7 | # Purpose: Provide authentication and session services for 8 | # calls against the TMDB v3 API 9 | #----------------------- 10 | 11 | from datetime import datetime as _pydatetime, \ 12 | tzinfo as _pytzinfo 13 | import re 14 | class datetime(_pydatetime): 15 | """Customized datetime class with ISO format parsing.""" 16 | _reiso = re.compile('(?P[0-9]{4})' 17 | '-(?P[0-9]{1,2})' 18 | '-(?P[0-9]{1,2})' 19 | '.' 20 | '(?P[0-9]{2})' 21 | ':(?P[0-9]{2})' 22 | '(:(?P[0-9]{2}))?' 23 | '(?PZ|' 24 | '(?P[-+])' 25 | '(?P[0-9]{1,2})' 26 | '(:)?' 27 | '(?P[0-9]{2})?' 28 | ')?') 29 | 30 | class _tzinfo(_pytzinfo): 31 | def __init__(self, direc='+', hr=0, min=0): 32 | if direc == '-': 33 | hr = -1*int(hr) 34 | self._offset = timedelta(hours=int(hr), minutes=int(min)) 35 | 36 | def utcoffset(self, dt): 37 | return self._offset 38 | 39 | def tzname(self, dt): 40 | return '' 41 | 42 | def dst(self, dt): 43 | return timedelta(0) 44 | 45 | @classmethod 46 | def fromIso(cls, isotime, sep='T'): 47 | match = cls._reiso.match(isotime) 48 | if match is None: 49 | raise TypeError("time data '%s' does not match ISO 8601 format" 50 | % isotime) 51 | 52 | dt = [int(a) for a in match.groups()[:5]] 53 | if match.group('sec') is not None: 54 | dt.append(int(match.group('sec'))) 55 | else: 56 | dt.append(0) 57 | if match.group('tz'): 58 | if match.group('tz') == 'Z': 59 | tz = cls._tzinfo() 60 | elif match.group('tzmin'): 61 | tz = cls._tzinfo(*match.group('tzdirec', 'tzhour', 'tzmin')) 62 | else: 63 | tz = cls._tzinfo(*match.group('tzdirec', 'tzhour')) 64 | dt.append(0) 65 | dt.append(tz) 66 | return cls(*dt) 67 | 68 | from request import Request 69 | from tmdb_exceptions import * 70 | 71 | syssession = None 72 | 73 | 74 | def set_session(sessionid): 75 | global syssession 76 | syssession = Session(sessionid) 77 | 78 | 79 | def get_session(sessionid=None): 80 | global syssession 81 | if sessionid: 82 | return Session(sessionid) 83 | elif syssession is not None: 84 | return syssession 85 | else: 86 | return Session.new() 87 | 88 | 89 | class Session(object): 90 | @classmethod 91 | def new(cls): 92 | return cls(None) 93 | 94 | def __init__(self, sessionid): 95 | self.sessionid = sessionid 96 | 97 | @property 98 | def sessionid(self): 99 | if self._sessionid is None: 100 | if self._authtoken is None: 101 | raise TMDBError("No Auth Token to produce Session for") 102 | # TODO: check authtoken expiration against current time 103 | req = Request('authentication/session/new', 104 | request_token=self._authtoken) 105 | req.lifetime = 0 106 | dat = req.readJSON() 107 | if not dat['success']: 108 | raise TMDBError("Session generation failed") 109 | self._sessionid = dat['session_id'] 110 | return self._sessionid 111 | 112 | @sessionid.setter 113 | def sessionid(self, value): 114 | self._sessionid = value 115 | self._authtoken = None 116 | self._authtokenexpiration = None 117 | if value is None: 118 | self.authenticated = False 119 | else: 120 | self.authenticated = True 121 | 122 | @property 123 | def authtoken(self): 124 | if self.authenticated: 125 | raise TMDBError("Session is already authenticated") 126 | if self._authtoken is None: 127 | req = Request('authentication/token/new') 128 | req.lifetime = 0 129 | dat = req.readJSON() 130 | if not dat['success']: 131 | raise TMDBError("Auth Token request failed") 132 | self._authtoken = dat['request_token'] 133 | self._authtokenexpiration = datetime.fromIso(dat['expires_at']) 134 | return self._authtoken 135 | 136 | @property 137 | def callbackurl(self): 138 | return "http://www.themoviedb.org/authenticate/"+self._authtoken 139 | -------------------------------------------------------------------------------- /tmdb3/cache.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | #----------------------- 4 | # Name: cache.py 5 | # Python Library 6 | # Author: Raymond Wagner 7 | # Purpose: Caching framework to store TMDb API results 8 | #----------------------- 9 | 10 | import time 11 | import os 12 | 13 | from tmdb_exceptions import * 14 | from cache_engine import Engines 15 | 16 | import cache_null 17 | import cache_file 18 | 19 | DEBUG = False 20 | 21 | class Cache(object): 22 | """ 23 | This class implements a cache framework, allowing selecting of a 24 | pluggable engine. The framework stores data in a key/value manner, 25 | along with a lifetime, after which data will be expired and 26 | pulled fresh next time it is requested from the cache. 27 | 28 | This class defines a wrapper to be used with query functions. The 29 | wrapper will automatically cache the inputs and outputs of the 30 | wrapped function, pulling the output from local storage for 31 | subsequent calls with those inputs. 32 | """ 33 | def __init__(self, engine=None, *args, **kwargs): 34 | self._engine = None 35 | self._data = {} 36 | self._age = 0 37 | self._rate_limiter = [] 38 | self.configure(engine, *args, **kwargs) 39 | 40 | def _import(self, data=None): 41 | if data is None: 42 | data = self._engine.get(self._age) 43 | for obj in sorted(data, key=lambda x: x.creation): 44 | self._rate_limiter.append(obj.creation) 45 | if len(self._rate_limiter) > 30: 46 | self._rate_limiter.pop(0) 47 | if not obj.expired: 48 | self._data[obj.key] = obj 49 | self._age = max(self._age, obj.creation) 50 | 51 | def _expire(self): 52 | for k, v in self._data.items(): 53 | if v.expired: 54 | del self._data[k] 55 | 56 | def configure(self, engine, *args, **kwargs): 57 | if engine is None: 58 | engine = 'file' 59 | elif engine not in Engines: 60 | raise TMDBCacheError("Invalid cache engine specified: "+engine) 61 | self._engine = Engines[engine](self) 62 | self._engine.configure(*args, **kwargs) 63 | 64 | def put(self, key, data, lifetime=60*60*12): 65 | # pull existing data, so cache will be fresh when written back out 66 | if self._engine is None: 67 | raise TMDBCacheError("No cache engine configured") 68 | self._expire() 69 | self._import(self._engine.put(key, data, lifetime)) 70 | 71 | def get(self, key): 72 | if self._engine is None: 73 | raise TMDBCacheError("No cache engine configured") 74 | self._expire() 75 | if key not in self._data: 76 | self._import() 77 | try: 78 | return self._data[key].data 79 | except: 80 | # no cache data, so we're going to query 81 | # wait to ensure proper rate limiting 82 | if len(self._rate_limiter) == 30: 83 | w = 10 - (time.time() - self._rate_limiter.pop(0)) 84 | if (w > 0): 85 | if DEBUG: 86 | print "rate limiting - waiting {0} seconds".format(w) 87 | time.sleep(w) 88 | return None 89 | 90 | def cached(self, callback): 91 | """ 92 | Returns a decorator that uses a callback to specify the key to use 93 | for caching the responses from the decorated function. 94 | """ 95 | return self.Cached(self, callback) 96 | 97 | class Cached( object ): 98 | def __init__(self, cache, callback, func=None, inst=None): 99 | self.cache = cache 100 | self.callback = callback 101 | self.func = func 102 | self.inst = inst 103 | 104 | if func: 105 | self.__module__ = func.__module__ 106 | self.__name__ = func.__name__ 107 | self.__doc__ = func.__doc__ 108 | 109 | def __call__(self, *args, **kwargs): 110 | if self.func is None: 111 | # decorator is waiting to be given a function 112 | if len(kwargs) or (len(args) != 1): 113 | raise TMDBCacheError( 114 | 'Cache.Cached decorator must be called a single ' + 115 | 'callable argument before it be used.') 116 | elif args[0] is None: 117 | raise TMDBCacheError( 118 | 'Cache.Cached decorator called before being given ' + 119 | 'a function to wrap.') 120 | elif not callable(args[0]): 121 | raise TMDBCacheError( 122 | 'Cache.Cached must be provided a callable object.') 123 | return self.__class__(self.cache, self.callback, args[0]) 124 | elif self.inst.lifetime == 0: 125 | # lifetime of zero means never cache 126 | return self.func(*args, **kwargs) 127 | else: 128 | key = self.callback() 129 | data = self.cache.get(key) 130 | if data is None: 131 | data = self.func(*args, **kwargs) 132 | if hasattr(self.inst, 'lifetime'): 133 | self.cache.put(key, data, self.inst.lifetime) 134 | else: 135 | self.cache.put(key, data) 136 | return data 137 | 138 | def __get__(self, inst, owner): 139 | if inst is None: 140 | return self 141 | func = self.func.__get__(inst, owner) 142 | callback = self.callback.__get__(inst, owner) 143 | return self.__class__(self.cache, callback, func, inst) 144 | -------------------------------------------------------------------------------- /tmdb3/request.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | #----------------------- 4 | # Name: tmdb_request.py 5 | # Python Library 6 | # Author: Raymond Wagner 7 | # Purpose: Wrapped urllib2.Request class pre-configured for accessing the 8 | # TMDb v3 API 9 | #----------------------- 10 | 11 | from tmdb_exceptions import * 12 | from locales import get_locale 13 | from cache import Cache 14 | 15 | from urllib import urlencode 16 | import urllib2 17 | import json 18 | import os 19 | 20 | DEBUG = False 21 | cache = Cache(filename='pytmdb3.cache') 22 | 23 | #DEBUG = True 24 | #cache = Cache(engine='null') 25 | 26 | 27 | def set_key(key): 28 | """ 29 | Specify the API key to use retrieving data from themoviedb.org. 30 | This key must be set before any calls will function. 31 | """ 32 | if len(key) != 32: 33 | raise TMDBKeyInvalid("Specified API key must be 128-bit hex") 34 | try: 35 | int(key, 16) 36 | except: 37 | raise TMDBKeyInvalid("Specified API key must be 128-bit hex") 38 | Request._api_key = key 39 | 40 | 41 | def set_cache(engine=None, *args, **kwargs): 42 | """Specify caching engine and properties.""" 43 | cache.configure(engine, *args, **kwargs) 44 | 45 | 46 | class Request(urllib2.Request): 47 | _api_key = None 48 | _base_url = "http://api.themoviedb.org/3/" 49 | 50 | @property 51 | def api_key(self): 52 | if self._api_key is None: 53 | raise TMDBKeyMissing("API key must be specified before " + 54 | "requests can be made") 55 | return self._api_key 56 | 57 | def __init__(self, url, **kwargs): 58 | """ 59 | Return a request object, using specified API path and 60 | arguments. 61 | """ 62 | kwargs['api_key'] = self.api_key 63 | self._url = url.lstrip('/') 64 | self._kwargs = dict([(kwa, kwv) for kwa, kwv in kwargs.items() 65 | if kwv is not None]) 66 | 67 | locale = get_locale() 68 | kwargs = {} 69 | for k, v in self._kwargs.items(): 70 | kwargs[k] = locale.encode(v) 71 | url = '{0}{1}?{2}'\ 72 | .format(self._base_url, self._url, urlencode(kwargs)) 73 | 74 | urllib2.Request.__init__(self, url) 75 | self.add_header('Accept', 'application/json') 76 | self.lifetime = 3600 # 1hr 77 | 78 | def new(self, **kwargs): 79 | """ 80 | Create a new instance of the request, with tweaked arguments. 81 | """ 82 | args = dict(self._kwargs) 83 | for k, v in kwargs.items(): 84 | if v is None: 85 | if k in args: 86 | del args[k] 87 | else: 88 | args[k] = v 89 | obj = self.__class__(self._url, **args) 90 | obj.lifetime = self.lifetime 91 | return obj 92 | 93 | def add_data(self, data): 94 | """Provide data to be sent with POST.""" 95 | urllib2.Request.add_data(self, urlencode(data)) 96 | 97 | def open(self): 98 | """Open a file object to the specified URL.""" 99 | try: 100 | if DEBUG: 101 | print 'loading '+self.get_full_url() 102 | if self.has_data(): 103 | print ' '+self.get_data() 104 | return urllib2.urlopen(self) 105 | except urllib2.HTTPError, e: 106 | raise TMDBHTTPError(e) 107 | 108 | def read(self): 109 | """Return result from specified URL as a string.""" 110 | return self.open().read() 111 | 112 | @cache.cached(urllib2.Request.get_full_url) 113 | def readJSON(self): 114 | """Parse result from specified URL as JSON data.""" 115 | url = self.get_full_url() 116 | try: 117 | # catch HTTP error from open() 118 | data = json.load(self.open()) 119 | except TMDBHTTPError, e: 120 | try: 121 | # try to load whatever was returned 122 | data = json.loads(e.response) 123 | except: 124 | # cannot parse json, just raise existing error 125 | raise e 126 | else: 127 | # response parsed, try to raise error from TMDB 128 | handle_status(data, url) 129 | # no error from TMDB, just raise existing error 130 | raise e 131 | handle_status(data, url) 132 | if DEBUG: 133 | import pprint 134 | pprint.PrettyPrinter().pprint(data) 135 | return data 136 | 137 | status_handlers = { 138 | 1: None, 139 | 2: TMDBRequestInvalid('Invalid service - This service does not exist.'), 140 | 3: TMDBRequestError('Authentication Failed - You do not have ' + 141 | 'permissions to access this service.'), 142 | 4: TMDBRequestInvalid("Invalid format - This service doesn't exist " + 143 | 'in that format.'), 144 | 5: TMDBRequestInvalid('Invalid parameters - Your request parameters ' + 145 | 'are incorrect.'), 146 | 6: TMDBRequestInvalid('Invalid id - The pre-requisite id is invalid ' + 147 | 'or not found.'), 148 | 7: TMDBKeyInvalid('Invalid API key - You must be granted a valid key.'), 149 | 8: TMDBRequestError('Duplicate entry - The data you tried to submit ' + 150 | 'already exists.'), 151 | 9: TMDBOffline('This service is tempirarily offline. Try again later.'), 152 | 10: TMDBKeyRevoked('Suspended API key - Access to your account has been ' + 153 | 'suspended, contact TMDB.'), 154 | 11: TMDBError('Internal error - Something went wrong. Contact TMDb.'), 155 | 12: None, 156 | 13: None, 157 | 14: TMDBRequestError('Authentication Failed.'), 158 | 15: TMDBError('Failed'), 159 | 16: TMDBError('Device Denied'), 160 | 17: TMDBError('Session Denied')} 161 | 162 | def handle_status(data, query): 163 | status = status_handlers[data.get('status_code', 1)] 164 | if status is not None: 165 | status.tmdberrno = data['status_code'] 166 | status.query = query 167 | raise status 168 | -------------------------------------------------------------------------------- /tmdb3/cache_file.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | #----------------------- 4 | # Name: cache_file.py 5 | # Python Library 6 | # Author: Raymond Wagner 7 | # Purpose: Persistant file-backed cache using /tmp/ to share data 8 | # using flock or msvcrt.locking to allow safe concurrent 9 | # access. 10 | #----------------------- 11 | 12 | import struct 13 | import errno 14 | import json 15 | import time 16 | import os 17 | import io 18 | 19 | from cStringIO import StringIO 20 | 21 | from tmdb_exceptions import * 22 | from cache_engine import CacheEngine, CacheObject 23 | 24 | #################### 25 | # Cache File Format 26 | #------------------ 27 | # cache version (2) unsigned short 28 | # slot count (2) unsigned short 29 | # slot 0: timestamp (8) double 30 | # slot 0: lifetime (4) unsigned int 31 | # slot 0: seek point (4) unsigned int 32 | # slot 1: timestamp 33 | # slot 1: lifetime index slots are IDd by their query date and 34 | # slot 1: seek point are filled incrementally forwards. lifetime 35 | # .... is how long after query date before the item 36 | # .... expires, and seek point is the location of the 37 | # slot N-2: timestamp start of data for that entry. 256 empty slots 38 | # slot N-2: lifetime are pre-allocated, allowing fast updates. 39 | # slot N-2: seek point when all slots are filled, the cache file is 40 | # slot N-1: timestamp rewritten from scrach to add more slots. 41 | # slot N-1: lifetime 42 | # slot N-1: seek point 43 | # block 1 (?) ASCII 44 | # block 2 45 | # .... blocks are just simple ASCII text, generated 46 | # .... as independent objects by the JSON encoder 47 | # block N-2 48 | # block N-1 49 | # 50 | #################### 51 | 52 | 53 | def _donothing(*args, **kwargs): 54 | pass 55 | 56 | try: 57 | import fcntl 58 | class Flock(object): 59 | """ 60 | Context manager to flock file for the duration the object 61 | exists. Referenced file will be automatically unflocked as the 62 | interpreter exits the context. 63 | Supports an optional callback to process the error and optionally 64 | suppress it. 65 | """ 66 | LOCK_EX = fcntl.LOCK_EX 67 | LOCK_SH = fcntl.LOCK_SH 68 | 69 | def __init__(self, fileobj, operation, callback=None): 70 | self.fileobj = fileobj 71 | self.operation = operation 72 | self.callback = callback 73 | 74 | def __enter__(self): 75 | fcntl.flock(self.fileobj, self.operation) 76 | 77 | def __exit__(self, exc_type, exc_value, exc_tb): 78 | suppress = False 79 | if callable(self.callback): 80 | suppress = self.callback(exc_type, exc_value, exc_tb) 81 | fcntl.flock(self.fileobj, fcntl.LOCK_UN) 82 | return suppress 83 | 84 | def parse_filename(filename): 85 | if '$' in filename: 86 | # replace any environmental variables 87 | filename = os.path.expandvars(filename) 88 | if filename.startswith('~'): 89 | # check for home directory 90 | return os.path.expanduser(filename) 91 | elif filename.startswith('/'): 92 | # check for absolute path 93 | return filename 94 | # return path with temp directory prepended 95 | return '/tmp/' + filename 96 | 97 | except ImportError: 98 | import msvcrt 99 | class Flock( object ): 100 | LOCK_EX = msvcrt.LK_LOCK 101 | LOCK_SH = msvcrt.LK_LOCK 102 | 103 | def __init__(self, fileobj, operation, callback=None): 104 | self.fileobj = fileobj 105 | self.operation = operation 106 | self.callback = callback 107 | 108 | def __enter__(self): 109 | self.size = os.path.getsize(self.fileobj.name) 110 | msvcrt.locking(self.fileobj.fileno(), self.operation, self.size) 111 | 112 | def __exit__(self, exc_type, exc_value, exc_tb): 113 | suppress = False 114 | if callable(self.callback): 115 | suppress = self.callback(exc_type, exc_value, exc_tb) 116 | msvcrt.locking(self.fileobj.fileno(), msvcrt.LK_UNLCK, self.size) 117 | return suppress 118 | 119 | def parse_filename(filename): 120 | if '%' in filename: 121 | # replace any environmental variables 122 | filename = os.path.expandvars(filename) 123 | if filename.startswith('~'): 124 | # check for home directory 125 | return os.path.expanduser(filename) 126 | elif (ord(filename[0]) in (range(65, 91) + range(99, 123))) \ 127 | and (filename[1:3] == ':\\'): 128 | # check for absolute drive path (e.g. C:\...) 129 | return filename 130 | elif (filename.count('\\') >= 3) and (filename.startswith('\\\\')): 131 | # check for absolute UNC path (e.g. \\server\...) 132 | return filename 133 | # return path with temp directory prepended 134 | return os.path.expandvars(os.path.join('%TEMP%', filename)) 135 | 136 | 137 | class FileCacheObject(CacheObject): 138 | _struct = struct.Struct('dII') # double and two ints 139 | # timestamp, lifetime, position 140 | 141 | @classmethod 142 | def fromFile(cls, fd): 143 | dat = cls._struct.unpack(fd.read(cls._struct.size)) 144 | obj = cls(None, None, dat[1], dat[0]) 145 | obj.position = dat[2] 146 | return obj 147 | 148 | def __init__(self, *args, **kwargs): 149 | self._key = None 150 | self._data = None 151 | self._size = None 152 | self._buff = StringIO() 153 | super(FileCacheObject, self).__init__(*args, **kwargs) 154 | 155 | @property 156 | def size(self): 157 | if self._size is None: 158 | self._buff.seek(0, 2) 159 | size = self._buff.tell() 160 | if size == 0: 161 | if (self._key is None) or (self._data is None): 162 | raise RuntimeError 163 | json.dump([self.key, self.data], self._buff) 164 | self._size = self._buff.tell() 165 | self._size = size 166 | return self._size 167 | 168 | @size.setter 169 | def size(self, value): 170 | self._size = value 171 | 172 | @property 173 | def key(self): 174 | if self._key is None: 175 | try: 176 | self._key, self._data = json.loads(self._buff.getvalue()) 177 | except: 178 | pass 179 | return self._key 180 | 181 | @key.setter 182 | def key(self, value): 183 | self._key = value 184 | 185 | @property 186 | def data(self): 187 | if self._data is None: 188 | self._key, self._data = json.loads(self._buff.getvalue()) 189 | return self._data 190 | 191 | @data.setter 192 | def data(self, value): 193 | self._data = value 194 | 195 | def load(self, fd): 196 | fd.seek(self.position) 197 | self._buff.seek(0) 198 | self._buff.write(fd.read(self.size)) 199 | 200 | def dumpslot(self, fd): 201 | pos = fd.tell() 202 | fd.write(self._struct.pack(self.creation, self.lifetime, self.position)) 203 | 204 | def dumpdata(self, fd): 205 | self.size 206 | fd.seek(self.position) 207 | fd.write(self._buff.getvalue()) 208 | 209 | 210 | class FileEngine( CacheEngine ): 211 | """Simple file-backed engine.""" 212 | name = 'file' 213 | _struct = struct.Struct('HH') # two shorts for version and count 214 | _version = 2 215 | 216 | def __init__(self, parent): 217 | super(FileEngine, self).__init__(parent) 218 | self.configure(None) 219 | 220 | def configure(self, filename, preallocate=256): 221 | self.preallocate = preallocate 222 | self.cachefile = filename 223 | self.size = 0 224 | self.free = 0 225 | self.age = 0 226 | 227 | def _init_cache(self): 228 | # only run this once 229 | self._init_cache = _donothing 230 | 231 | if self.cachefile is None: 232 | raise TMDBCacheError("No cache filename given.") 233 | self.cachefile = parse_filename(self.cachefile) 234 | 235 | try: 236 | # attempt to read existing cache at filename 237 | # handle any errors that occur 238 | self._open('r+b') 239 | # seems to have read fine, make sure we have write access 240 | if not os.access(self.cachefile, os.W_OK): 241 | raise TMDBCacheWriteError(self.cachefile) 242 | 243 | except IOError as e: 244 | if e.errno == errno.ENOENT: 245 | # file does not exist, create a new one 246 | try: 247 | self._open('w+b') 248 | self._write([]) 249 | except IOError as e: 250 | if e.errno == errno.ENOENT: 251 | # directory does not exist 252 | raise TMDBCacheDirectoryError(self.cachefile) 253 | elif e.errno == errno.EACCES: 254 | # user does not have rights to create new file 255 | raise TMDBCacheWriteError(self.cachefile) 256 | else: 257 | # let the unhandled error continue through 258 | raise 259 | elif e.errno == errno.EACCES: 260 | # file exists, but we do not have permission to access it 261 | raise TMDBCacheReadError(self.cachefile) 262 | else: 263 | # let the unhandled error continue through 264 | raise 265 | 266 | def get(self, date): 267 | self._init_cache() 268 | self._open('r+b') 269 | 270 | with Flock(self.cachefd, Flock.LOCK_SH): 271 | # return any new objects in the cache 272 | return self._read(date) 273 | 274 | def put(self, key, value, lifetime): 275 | self._init_cache() 276 | self._open('r+b') 277 | 278 | with Flock(self.cachefd, Flock.LOCK_EX): 279 | newobjs = self._read(self.age) 280 | newobjs.append(FileCacheObject(key, value, lifetime)) 281 | 282 | # this will cause a new file object to be opened with the proper 283 | # access mode, however the Flock should keep the old object open 284 | # and properly locked 285 | self._open('r+b') 286 | self._write(newobjs) 287 | return newobjs 288 | 289 | def _open(self, mode='r+b'): 290 | # enforce binary operation 291 | try: 292 | if self.cachefd.mode == mode: 293 | # already opened in requested mode, nothing to do 294 | self.cachefd.seek(0) 295 | return 296 | except: 297 | pass # catch issue of no cachefile yet opened 298 | self.cachefd = io.open(self.cachefile, mode) 299 | 300 | def _read(self, date): 301 | try: 302 | self.cachefd.seek(0) 303 | version, count = self._struct.unpack(\ 304 | self.cachefd.read(self._struct.size)) 305 | if version != self._version: 306 | # old version, break out and well rewrite when finished 307 | raise Exception 308 | 309 | self.size = count 310 | cache = [] 311 | while count: 312 | # loop through storage definitions 313 | obj = FileCacheObject.fromFile(self.cachefd) 314 | cache.append(obj) 315 | count -= 1 316 | 317 | except: 318 | # failed to read information, so just discard it and return empty 319 | self.size = 0 320 | self.free = 0 321 | return [] 322 | 323 | # get end of file 324 | self.cachefd.seek(0, 2) 325 | position = self.cachefd.tell() 326 | newobjs = [] 327 | emptycount = 0 328 | 329 | # walk backward through all, collecting new content and populating size 330 | while len(cache): 331 | obj = cache.pop() 332 | if obj.creation == 0: 333 | # unused slot, skip 334 | emptycount += 1 335 | elif obj.expired: 336 | # object has passed expiration date, no sense processing 337 | continue 338 | elif obj.creation > date: 339 | # used slot with new data, process 340 | obj.size, position = position - obj.position, obj.position 341 | newobjs.append(obj) 342 | # update age 343 | self.age = max(self.age, obj.creation) 344 | elif len(newobjs): 345 | # end of new data, break 346 | break 347 | 348 | # walk forward and load new content 349 | for obj in newobjs: 350 | obj.load(self.cachefd) 351 | 352 | self.free = emptycount 353 | return newobjs 354 | 355 | def _write(self, data): 356 | if self.free and (self.size != self.free): 357 | # we only care about the last data point, since the rest are 358 | # already stored in the file 359 | data = data[-1] 360 | 361 | # determine write position of data in cache 362 | self.cachefd.seek(0, 2) 363 | end = self.cachefd.tell() 364 | data.position = end 365 | 366 | # write incremental update to free slot 367 | self.cachefd.seek(4 + 16*(self.size-self.free)) 368 | data.dumpslot(self.cachefd) 369 | data.dumpdata(self.cachefd) 370 | 371 | else: 372 | # rewrite cache file from scratch 373 | # pull data from parent cache 374 | data.extend(self.parent()._data.values()) 375 | data.sort(key=lambda x: x.creation) 376 | # write header 377 | size = len(data) + self.preallocate 378 | self.cachefd.seek(0) 379 | self.cachefd.truncate() 380 | self.cachefd.write(self._struct.pack(self._version, size)) 381 | # write storage slot definitions 382 | prev = None 383 | for d in data: 384 | if prev == None: 385 | d.position = 4 + 16*size 386 | else: 387 | d.position = prev.position + prev.size 388 | d.dumpslot(self.cachefd) 389 | prev = d 390 | # fill in allocated slots 391 | for i in range(2**8): 392 | self.cachefd.write(FileCacheObject._struct.pack(0, 0, 0)) 393 | # write stored data 394 | for d in data: 395 | d.dumpdata(self.cachefd) 396 | 397 | self.cachefd.flush() 398 | 399 | def expire(self, key): 400 | pass 401 | -------------------------------------------------------------------------------- /tmdb3/util.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | #----------------------- 4 | # Name: util.py Assorted utilities used in tmdb_api 5 | # Python Library 6 | # Author: Raymond Wagner 7 | #----------------------- 8 | 9 | from copy import copy 10 | from locales import get_locale 11 | from tmdb_auth import get_session 12 | 13 | 14 | class NameRepr(object): 15 | """Mixin for __repr__ methods using 'name' attribute.""" 16 | def __repr__(self): 17 | return u"<{0.__class__.__name__} '{0.name}'>"\ 18 | .format(self).encode('utf-8') 19 | 20 | 21 | class SearchRepr(object): 22 | """ 23 | Mixin for __repr__ methods for classes with '_name' and 24 | '_request' attributes. 25 | """ 26 | def __repr__(self): 27 | name = self._name if self._name else self._request._kwargs['query'] 28 | return u"".format(name).encode('utf-8') 29 | 30 | 31 | class Poller(object): 32 | """ 33 | Wrapper for an optional callable to populate an Element derived 34 | class with raw data, or data from a Request. 35 | """ 36 | def __init__(self, func, lookup, inst=None): 37 | self.func = func 38 | self.lookup = lookup 39 | self.inst = inst 40 | if func: 41 | # with function, this allows polling data from the API 42 | self.__doc__ = func.__doc__ 43 | self.__name__ = func.__name__ 44 | self.__module__ = func.__module__ 45 | else: 46 | # without function, this is just a dummy poller used for applying 47 | # raw data to a new Element class with the lookup table 48 | self.__name__ = '_populate' 49 | 50 | def __get__(self, inst, owner): 51 | # normal decorator stuff 52 | # return self for a class 53 | # return instantiated copy of self for an object 54 | if inst is None: 55 | return self 56 | func = None 57 | if self.func: 58 | func = self.func.__get__(inst, owner) 59 | return self.__class__(func, self.lookup, inst) 60 | 61 | def __call__(self): 62 | # retrieve data from callable function, and apply 63 | if not callable(self.func): 64 | raise RuntimeError('Poller object called without a source function') 65 | req = self.func() 66 | if ('language' in req._kwargs) or ('country' in req._kwargs) \ 67 | and self.inst._locale.fallthrough: 68 | # request specifies a locale filter, and fallthrough is enabled 69 | # run a first pass with specified filter 70 | if not self.apply(req.readJSON(), False): 71 | return 72 | # if first pass results in missed data, run a second pass to 73 | # fill in the gaps 74 | self.apply(req.new(language=None, country=None).readJSON()) 75 | # re-apply the filtered first pass data over top the second 76 | # unfiltered set. this is to work around the issue that the 77 | # properties have no way of knowing when they should or 78 | # should not overwrite existing data. the cache engine will 79 | # take care of the duplicate query 80 | self.apply(req.readJSON()) 81 | 82 | def apply(self, data, set_nones=True): 83 | # apply data directly, bypassing callable function 84 | unfilled = False 85 | for k, v in self.lookup.items(): 86 | if (k in data) and (not callable(self.func) or data[k] is not None): 87 | # argument received data, populate it 88 | setattr(self.inst, v, data[k]) 89 | elif v in self.inst._data: 90 | # argument did not receive data, but Element already contains 91 | # some value, so skip this 92 | continue 93 | elif set_nones: 94 | # argument did not receive data, so fill it with None 95 | # to indicate such and prevent a repeat scan 96 | setattr(self.inst, v, None) 97 | else: 98 | # argument does not need data, so ignore it allowing it to 99 | # trigger a later poll. this is intended for use when 100 | # initializing a class with raw data, or when performing a 101 | # first pass through when performing locale fall through 102 | unfilled = True 103 | return unfilled 104 | 105 | 106 | class Data(object): 107 | """ 108 | Basic response definition class 109 | This maps to a single key in a JSON dictionary received from the API 110 | """ 111 | def __init__(self, field, initarg=None, handler=None, poller=None, 112 | raw=True, default=u'', lang=None, passthrough={}): 113 | """ 114 | This defines how the dictionary value is to be processed by the 115 | poller 116 | field -- defines the dictionary key that filters what data 117 | this uses 118 | initarg -- (optional) specifies that this field must be 119 | supplied when creating a new instance of the Element 120 | class this definition is mapped to. Takes an integer 121 | for the order it should be used in the input 122 | arguments 123 | handler -- (optional) callable used to process the received 124 | value before being stored in the Element object. 125 | poller -- (optional) callable to be used if data is requested 126 | and this value has not yet been defined. the 127 | callable should return a dictionary of data from a 128 | JSON query. many definitions may share a single 129 | poller, which will be and the data used to populate 130 | all referenced definitions based off their defined 131 | field 132 | raw -- (optional) if the specified handler is an Element 133 | class, the data will be passed into it using the 134 | 'raw' keyword attribute. setting this to false 135 | will force the data to instead be passed in as the 136 | first argument 137 | """ 138 | self.field = field 139 | self.initarg = initarg 140 | self.poller = poller 141 | self.raw = raw 142 | self.default = default 143 | self.sethandler(handler) 144 | self.passthrough = passthrough 145 | 146 | def __get__(self, inst, owner): 147 | if inst is None: 148 | return self 149 | if self.field not in inst._data: 150 | if self.poller is None: 151 | return None 152 | self.poller.__get__(inst, owner)() 153 | return inst._data[self.field] 154 | 155 | def __set__(self, inst, value): 156 | if (value is not None) and (value != ''): 157 | value = self.handler(value) 158 | else: 159 | value = self.default 160 | if isinstance(value, Element): 161 | value._locale = inst._locale 162 | value._session = inst._session 163 | 164 | for source, dest in self.passthrough: 165 | setattr(value, dest, getattr(inst, source)) 166 | inst._data[self.field] = value 167 | 168 | def sethandler(self, handler): 169 | # ensure handler is always callable, even for passthrough data 170 | if handler is None: 171 | self.handler = lambda x: x 172 | elif isinstance(handler, ElementType) and self.raw: 173 | self.handler = lambda x: handler(raw=x) 174 | else: 175 | self.handler = lambda x: handler(x) 176 | 177 | 178 | class Datapoint(Data): 179 | pass 180 | 181 | 182 | class Datalist(Data): 183 | """ 184 | Response definition class for list data 185 | This maps to a key in a JSON dictionary storing a list of data 186 | """ 187 | def __init__(self, field, handler=None, poller=None, sort=None, raw=True, passthrough={}): 188 | """ 189 | This defines how the dictionary value is to be processed by the 190 | poller 191 | field -- defines the dictionary key that filters what data 192 | this uses 193 | handler -- (optional) callable used to process the received 194 | value before being stored in the Element object. 195 | poller -- (optional) callable to be used if data is requested 196 | and this value has not yet been defined. the 197 | callable should return a dictionary of data from a 198 | JSON query. many definitions may share a single 199 | poller, which will be and the data used to populate 200 | all referenced definitions based off their defined 201 | field 202 | sort -- (optional) name of attribute in resultant data to be 203 | used to sort the list after processing. this 204 | effectively requires a handler be defined to process 205 | the data into something that has attributes 206 | raw -- (optional) if the specified handler is an Element 207 | class, the data will be passed into it using the 208 | 'raw' keyword attribute. setting this to false will 209 | force the data to instead be passed in as the first 210 | argument 211 | """ 212 | super(Datalist, self).__init__(field, None, handler, poller, raw, passthrough=passthrough) 213 | self.sort = sort 214 | 215 | def __set__(self, inst, value): 216 | data = [] 217 | if value: 218 | for val in value: 219 | val = self.handler(val) 220 | if isinstance(val, Element): 221 | val._locale = inst._locale 222 | val._session = inst._session 223 | 224 | for source, dest in self.passthrough.items(): 225 | setattr(val, dest, getattr(inst, source)) 226 | 227 | data.append(val) 228 | if self.sort: 229 | if self.sort is True: 230 | data.sort() 231 | else: 232 | data.sort(key=lambda x: getattr(x, self.sort)) 233 | inst._data[self.field] = data 234 | 235 | 236 | class Datadict(Data): 237 | """ 238 | Response definition class for dictionary data 239 | This maps to a key in a JSON dictionary storing a dictionary of data 240 | """ 241 | def __init__(self, field, handler=None, poller=None, raw=True, 242 | key=None, attr=None, passthrough={}): 243 | """ 244 | This defines how the dictionary value is to be processed by the 245 | poller 246 | field -- defines the dictionary key that filters what data 247 | this uses 248 | handler -- (optional) callable used to process the received 249 | value before being stored in the Element object. 250 | poller -- (optional) callable to be used if data is requested 251 | and this value has not yet been defined. the 252 | callable should return a dictionary of data from a 253 | JSON query. many definitions may share a single 254 | poller, which will be and the data used to populate 255 | all referenced definitions based off their defined 256 | field 257 | key -- (optional) name of key in resultant data to be used 258 | as the key in the stored dictionary. if this is not 259 | the field name from the source data is used instead 260 | attr -- (optional) name of attribute in resultant data to be 261 | used as the key in the stored dictionary. if this is 262 | not the field name from the source data is used 263 | instead 264 | raw -- (optional) if the specified handler is an Element 265 | class, the data will be passed into it using the 266 | 'raw' keyword attribute. setting this to false will 267 | force the data to instead be passed in as the first 268 | argument 269 | """ 270 | if key and attr: 271 | raise TypeError("`key` and `attr` cannot both be defined") 272 | super(Datadict, self).__init__(field, None, handler, poller, raw, passthrough=passthrough) 273 | if key: 274 | self.getkey = lambda x: x[key] 275 | elif attr: 276 | self.getkey = lambda x: getattr(x, attr) 277 | else: 278 | raise TypeError("Datadict requires `key` or `attr` be defined " + 279 | "for populating the dictionary") 280 | 281 | def __set__(self, inst, value): 282 | data = {} 283 | if value: 284 | for val in value: 285 | val = self.handler(val) 286 | if isinstance(val, Element): 287 | val._locale = inst._locale 288 | val._session = inst._session 289 | 290 | for source, dest in self.passthrough.items(): 291 | setattr(val, dest, getattr(inst, source)) 292 | 293 | data[self.getkey(val)] = val 294 | inst._data[self.field] = data 295 | 296 | class ElementType( type ): 297 | """ 298 | MetaClass used to pre-process Element-derived classes and set up the 299 | Data definitions 300 | """ 301 | def __new__(mcs, name, bases, attrs): 302 | # any Data or Poller object defined in parent classes must be cloned 303 | # and processed in this class to function properly 304 | # scan through available bases for all such definitions and insert 305 | # a copy into this class's attributes 306 | # run in reverse order so higher priority values overwrite lower ones 307 | data = {} 308 | pollers = {'_populate':None} 309 | 310 | for base in reversed(bases): 311 | if isinstance(base, mcs): 312 | for k, attr in base.__dict__.items(): 313 | if isinstance(attr, Data): 314 | # extract copies of each defined Data element from 315 | # parent classes 316 | attr = copy(attr) 317 | attr.poller = attr.poller.func 318 | data[k] = attr 319 | elif isinstance(attr, Poller): 320 | # extract copies of each defined Poller function 321 | # from parent classes 322 | pollers[k] = attr.func 323 | for k, attr in attrs.items(): 324 | if isinstance(attr, Data): 325 | data[k] = attr 326 | if '_populate' in attrs: 327 | pollers['_populate'] = attrs['_populate'] 328 | 329 | # process all defined Data attribues, testing for use as an initial 330 | # argument, and building a list of what Pollers are used to populate 331 | # which Data points 332 | pollermap = dict([(k, []) for k in pollers]) 333 | initargs = [] 334 | for k, v in data.items(): 335 | v.name = k 336 | if v.initarg: 337 | initargs.append(v) 338 | if v.poller: 339 | pn = v.poller.__name__ 340 | if pn not in pollermap: 341 | pollermap[pn] = [] 342 | if pn not in pollers: 343 | pollers[pn] = v.poller 344 | pollermap[pn].append(v) 345 | else: 346 | pollermap['_populate'].append(v) 347 | 348 | # wrap each used poller function with a Poller class, and push into 349 | # the new class attributes 350 | for k, v in pollermap.items(): 351 | if len(v) == 0: 352 | continue 353 | lookup = dict([(attr.field, attr.name) for attr in v]) 354 | poller = Poller(pollers[k], lookup) 355 | attrs[k] = poller 356 | # backfill wrapped Poller into each mapped Data object, and ensure 357 | # the data elements are defined for this new class 358 | for attr in v: 359 | attr.poller = poller 360 | attrs[attr.name] = attr 361 | 362 | # build sorted list of arguments used for intialization 363 | attrs['_InitArgs'] = tuple( 364 | [a.name for a in sorted(initargs, key=lambda x: x.initarg)]) 365 | return type.__new__(mcs, name, bases, attrs) 366 | 367 | def __call__(cls, *args, **kwargs): 368 | obj = cls.__new__(cls) 369 | if ('locale' in kwargs) and (kwargs['locale'] is not None): 370 | obj._locale = kwargs['locale'] 371 | else: 372 | obj._locale = get_locale() 373 | 374 | if 'session' in kwargs: 375 | obj._session = kwargs['session'] 376 | else: 377 | obj._session = get_session() 378 | 379 | obj._data = {} 380 | if 'raw' in kwargs: 381 | # if 'raw' keyword is supplied, create populate object manually 382 | if len(args) != 0: 383 | raise TypeError( 384 | '__init__() takes exactly 2 arguments (1 given)') 385 | obj._populate.apply(kwargs['raw'], False) 386 | else: 387 | # if not, the number of input arguments must exactly match that 388 | # defined by the Data definitions 389 | if len(args) != len(cls._InitArgs): 390 | raise TypeError( 391 | '__init__() takes exactly {0} arguments ({1} given)'\ 392 | .format(len(cls._InitArgs)+1, len(args)+1)) 393 | for a, v in zip(cls._InitArgs, args): 394 | setattr(obj, a, v) 395 | 396 | obj.__init__() 397 | return obj 398 | 399 | 400 | class Element( object ): 401 | __metaclass__ = ElementType 402 | _lang = 'en' 403 | -------------------------------------------------------------------------------- /tmdb3/locales.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | #----------------------- 4 | # Name: locales.py Stores locale information for filtering results 5 | # Python Library 6 | # Author: Raymond Wagner 7 | #----------------------- 8 | 9 | from tmdb_exceptions import * 10 | import locale 11 | 12 | syslocale = None 13 | 14 | 15 | class LocaleBase(object): 16 | __slots__ = ['__immutable'] 17 | _stored = {} 18 | fallthrough = False 19 | 20 | def __init__(self, *keys): 21 | for key in keys: 22 | self._stored[key.lower()] = self 23 | self.__immutable = True 24 | 25 | def __setattr__(self, key, value): 26 | if getattr(self, '__immutable', False): 27 | raise NotImplementedError(self.__class__.__name__ + 28 | ' does not support modification.') 29 | super(LocaleBase, self).__setattr__(key, value) 30 | 31 | def __delattr__(self, key): 32 | if getattr(self, '__immutable', False): 33 | raise NotImplementedError(self.__class__.__name__ + 34 | ' does not support modification.') 35 | super(LocaleBase, self).__delattr__(key) 36 | 37 | def __lt__(self, other): 38 | return (id(self) != id(other)) and (str(self) > str(other)) 39 | 40 | def __gt__(self, other): 41 | return (id(self) != id(other)) and (str(self) < str(other)) 42 | 43 | def __eq__(self, other): 44 | return (id(self) == id(other)) or (str(self) == str(other)) 45 | 46 | @classmethod 47 | def getstored(cls, key): 48 | if key is None: 49 | return None 50 | try: 51 | return cls._stored[key.lower()] 52 | except: 53 | raise TMDBLocaleError("'{0}' is not a known valid {1} code."\ 54 | .format(key, cls.__name__)) 55 | 56 | 57 | class Language(LocaleBase): 58 | __slots__ = ['ISO639_1', 'ISO639_2', 'ISO639_2B', 'englishname', 59 | 'nativename'] 60 | _stored = {} 61 | 62 | def __init__(self, iso1, iso2, ename): 63 | self.ISO639_1 = iso1 64 | self.ISO639_2 = iso2 65 | # self.ISO639_2B = iso2b 66 | self.englishname = ename 67 | # self.nativename = nname 68 | super(Language, self).__init__(iso1, iso2) 69 | 70 | def __str__(self): 71 | return self.ISO639_1 72 | 73 | def __repr__(self): 74 | return u"".format(self) 75 | 76 | 77 | class Country(LocaleBase): 78 | __slots__ = ['alpha2', 'name'] 79 | _stored = {} 80 | 81 | def __init__(self, alpha2, name): 82 | self.alpha2 = alpha2 83 | self.name = name 84 | super(Country, self).__init__(alpha2) 85 | 86 | def __str__(self): 87 | return self.alpha2 88 | 89 | def __repr__(self): 90 | return u"".format(self) 91 | 92 | 93 | class Locale(LocaleBase): 94 | __slots__ = ['language', 'country', 'encoding'] 95 | 96 | def __init__(self, language, country, encoding): 97 | self.language = Language.getstored(language) 98 | self.country = Country.getstored(country) 99 | self.encoding = encoding if encoding else 'latin-1' 100 | 101 | def __str__(self): 102 | return u"{0}_{1}".format(self.language, self.country) 103 | 104 | def __repr__(self): 105 | return u"".format(self) 106 | 107 | def encode(self, dat): 108 | """Encode using system default encoding for network/file output.""" 109 | try: 110 | return dat.encode(self.encoding) 111 | except AttributeError: 112 | # not a string type, pass along 113 | return dat 114 | except UnicodeDecodeError: 115 | # just return unmodified and hope for the best 116 | return dat 117 | 118 | def decode(self, dat): 119 | """Decode to system default encoding for internal use.""" 120 | try: 121 | return dat.decode(self.encoding) 122 | except AttributeError: 123 | # not a string type, pass along 124 | return dat 125 | except UnicodeEncodeError: 126 | # just return unmodified and hope for the best 127 | return dat 128 | 129 | 130 | def set_locale(language=None, country=None, fallthrough=False): 131 | global syslocale 132 | LocaleBase.fallthrough = fallthrough 133 | 134 | sysloc, sysenc = locale.getdefaultlocale() 135 | 136 | if (not language) or (not country): 137 | dat = None 138 | if syslocale is not None: 139 | dat = (str(syslocale.language), str(syslocale.country)) 140 | else: 141 | if (sysloc is None) or ('_' not in sysloc): 142 | dat = ('en', 'US') 143 | else: 144 | dat = sysloc.split('_') 145 | if language is None: 146 | language = dat[0] 147 | if country is None: 148 | country = dat[1] 149 | 150 | syslocale = Locale(language, country, sysenc) 151 | 152 | 153 | def get_locale(language=-1, country=-1): 154 | """Output locale using provided attributes, or return system locale.""" 155 | global syslocale 156 | # pull existing stored values 157 | if syslocale is None: 158 | loc = Locale(None, None, locale.getdefaultlocale()[1]) 159 | else: 160 | loc = syslocale 161 | 162 | # both options are default, return stored values 163 | if language == country == -1: 164 | return loc 165 | 166 | # supplement default option with stored values 167 | if language == -1: 168 | language = loc.language 169 | elif country == -1: 170 | country = loc.country 171 | return Locale(language, country, loc.encoding) 172 | 173 | ######## AUTOGENERATED LANGUAGE AND COUNTRY DATA BELOW HERE ######### 174 | 175 | Language("ab", "abk", u"Abkhazian") 176 | Language("aa", "aar", u"Afar") 177 | Language("af", "afr", u"Afrikaans") 178 | Language("ak", "aka", u"Akan") 179 | Language("sq", "alb/sqi", u"Albanian") 180 | Language("am", "amh", u"Amharic") 181 | Language("ar", "ara", u"Arabic") 182 | Language("an", "arg", u"Aragonese") 183 | Language("hy", "arm/hye", u"Armenian") 184 | Language("as", "asm", u"Assamese") 185 | Language("av", "ava", u"Avaric") 186 | Language("ae", "ave", u"Avestan") 187 | Language("ay", "aym", u"Aymara") 188 | Language("az", "aze", u"Azerbaijani") 189 | Language("bm", "bam", u"Bambara") 190 | Language("ba", "bak", u"Bashkir") 191 | Language("eu", "baq/eus", u"Basque") 192 | Language("be", "bel", u"Belarusian") 193 | Language("bn", "ben", u"Bengali") 194 | Language("bh", "bih", u"Bihari languages") 195 | Language("bi", "bis", u"Bislama") 196 | Language("nb", "nob", u"Bokmål, Norwegian") 197 | Language("bs", "bos", u"Bosnian") 198 | Language("br", "bre", u"Breton") 199 | Language("bg", "bul", u"Bulgarian") 200 | Language("my", "bur/mya", u"Burmese") 201 | Language("es", "spa", u"Castilian") 202 | Language("ca", "cat", u"Catalan") 203 | Language("km", "khm", u"Central Khmer") 204 | Language("ch", "cha", u"Chamorro") 205 | Language("ce", "che", u"Chechen") 206 | Language("ny", "nya", u"Chewa") 207 | Language("ny", "nya", u"Chichewa") 208 | Language("zh", "chi/zho", u"Chinese") 209 | Language("za", "zha", u"Chuang") 210 | Language("cu", "chu", u"Church Slavic") 211 | Language("cu", "chu", u"Church Slavonic") 212 | Language("cv", "chv", u"Chuvash") 213 | Language("kw", "cor", u"Cornish") 214 | Language("co", "cos", u"Corsican") 215 | Language("cr", "cre", u"Cree") 216 | Language("hr", "hrv", u"Croatian") 217 | Language("cs", "cze/ces", u"Czech") 218 | Language("da", "dan", u"Danish") 219 | Language("dv", "div", u"Dhivehi") 220 | Language("dv", "div", u"Divehi") 221 | Language("nl", "dut/nld", u"Dutch") 222 | Language("dz", "dzo", u"Dzongkha") 223 | Language("en", "eng", u"English") 224 | Language("eo", "epo", u"Esperanto") 225 | Language("et", "est", u"Estonian") 226 | Language("ee", "ewe", u"Ewe") 227 | Language("fo", "fao", u"Faroese") 228 | Language("fj", "fij", u"Fijian") 229 | Language("fi", "fin", u"Finnish") 230 | Language("nl", "dut/nld", u"Flemish") 231 | Language("fr", "fre/fra", u"French") 232 | Language("ff", "ful", u"Fulah") 233 | Language("gd", "gla", u"Gaelic") 234 | Language("gl", "glg", u"Galician") 235 | Language("lg", "lug", u"Ganda") 236 | Language("ka", "geo/kat", u"Georgian") 237 | Language("de", "ger/deu", u"German") 238 | Language("ki", "kik", u"Gikuyu") 239 | Language("el", "gre/ell", u"Greek, Modern (1453-)") 240 | Language("kl", "kal", u"Greenlandic") 241 | Language("gn", "grn", u"Guarani") 242 | Language("gu", "guj", u"Gujarati") 243 | Language("ht", "hat", u"Haitian") 244 | Language("ht", "hat", u"Haitian Creole") 245 | Language("ha", "hau", u"Hausa") 246 | Language("he", "heb", u"Hebrew") 247 | Language("hz", "her", u"Herero") 248 | Language("hi", "hin", u"Hindi") 249 | Language("ho", "hmo", u"Hiri Motu") 250 | Language("hu", "hun", u"Hungarian") 251 | Language("is", "ice/isl", u"Icelandic") 252 | Language("io", "ido", u"Ido") 253 | Language("ig", "ibo", u"Igbo") 254 | Language("id", "ind", u"Indonesian") 255 | Language("ia", "ina", u"Interlingua (International Auxiliary Language Association)") 256 | Language("ie", "ile", u"Interlingue") 257 | Language("iu", "iku", u"Inuktitut") 258 | Language("ik", "ipk", u"Inupiaq") 259 | Language("ga", "gle", u"Irish") 260 | Language("it", "ita", u"Italian") 261 | Language("ja", "jpn", u"Japanese") 262 | Language("jv", "jav", u"Javanese") 263 | Language("kl", "kal", u"Kalaallisut") 264 | Language("kn", "kan", u"Kannada") 265 | Language("kr", "kau", u"Kanuri") 266 | Language("ks", "kas", u"Kashmiri") 267 | Language("kk", "kaz", u"Kazakh") 268 | Language("ki", "kik", u"Kikuyu") 269 | Language("rw", "kin", u"Kinyarwanda") 270 | Language("ky", "kir", u"Kirghiz") 271 | Language("kv", "kom", u"Komi") 272 | Language("kg", "kon", u"Kongo") 273 | Language("ko", "kor", u"Korean") 274 | Language("kj", "kua", u"Kuanyama") 275 | Language("ku", "kur", u"Kurdish") 276 | Language("kj", "kua", u"Kwanyama") 277 | Language("ky", "kir", u"Kyrgyz") 278 | Language("lo", "lao", u"Lao") 279 | Language("la", "lat", u"Latin") 280 | Language("lv", "lav", u"Latvian") 281 | Language("lb", "ltz", u"Letzeburgesch") 282 | Language("li", "lim", u"Limburgan") 283 | Language("li", "lim", u"Limburger") 284 | Language("li", "lim", u"Limburgish") 285 | Language("ln", "lin", u"Lingala") 286 | Language("lt", "lit", u"Lithuanian") 287 | Language("lu", "lub", u"Luba-Katanga") 288 | Language("lb", "ltz", u"Luxembourgish") 289 | Language("mk", "mac/mkd", u"Macedonian") 290 | Language("mg", "mlg", u"Malagasy") 291 | Language("ms", "may/msa", u"Malay") 292 | Language("ml", "mal", u"Malayalam") 293 | Language("dv", "div", u"Maldivian") 294 | Language("mt", "mlt", u"Maltese") 295 | Language("gv", "glv", u"Manx") 296 | Language("mi", "mao/mri", u"Maori") 297 | Language("mr", "mar", u"Marathi") 298 | Language("mh", "mah", u"Marshallese") 299 | Language("ro", "rum/ron", u"Moldavian") 300 | Language("ro", "rum/ron", u"Moldovan") 301 | Language("mn", "mon", u"Mongolian") 302 | Language("na", "nau", u"Nauru") 303 | Language("nv", "nav", u"Navaho") 304 | Language("nv", "nav", u"Navajo") 305 | Language("nd", "nde", u"Ndebele, North") 306 | Language("nr", "nbl", u"Ndebele, South") 307 | Language("ng", "ndo", u"Ndonga") 308 | Language("ne", "nep", u"Nepali") 309 | Language("nd", "nde", u"North Ndebele") 310 | Language("se", "sme", u"Northern Sami") 311 | Language("no", "nor", u"Norwegian") 312 | Language("nb", "nob", u"Norwegian Bokmål") 313 | Language("nn", "nno", u"Norwegian Nynorsk") 314 | Language("ii", "iii", u"Nuosu") 315 | Language("ny", "nya", u"Nyanja") 316 | Language("nn", "nno", u"Nynorsk, Norwegian") 317 | Language("ie", "ile", u"Occidental") 318 | Language("oc", "oci", u"Occitan (post 1500)") 319 | Language("oj", "oji", u"Ojibwa") 320 | Language("cu", "chu", u"Old Bulgarian") 321 | Language("cu", "chu", u"Old Church Slavonic") 322 | Language("cu", "chu", u"Old Slavonic") 323 | Language("or", "ori", u"Oriya") 324 | Language("om", "orm", u"Oromo") 325 | Language("os", "oss", u"Ossetian") 326 | Language("os", "oss", u"Ossetic") 327 | Language("pi", "pli", u"Pali") 328 | Language("pa", "pan", u"Panjabi") 329 | Language("ps", "pus", u"Pashto") 330 | Language("fa", "per/fas", u"Persian") 331 | Language("pl", "pol", u"Polish") 332 | Language("pt", "por", u"Portuguese") 333 | Language("pa", "pan", u"Punjabi") 334 | Language("ps", "pus", u"Pushto") 335 | Language("qu", "que", u"Quechua") 336 | Language("ro", "rum/ron", u"Romanian") 337 | Language("rm", "roh", u"Romansh") 338 | Language("rn", "run", u"Rundi") 339 | Language("ru", "rus", u"Russian") 340 | Language("sm", "smo", u"Samoan") 341 | Language("sg", "sag", u"Sango") 342 | Language("sa", "san", u"Sanskrit") 343 | Language("sc", "srd", u"Sardinian") 344 | Language("gd", "gla", u"Scottish Gaelic") 345 | Language("sr", "srp", u"Serbian") 346 | Language("sn", "sna", u"Shona") 347 | Language("ii", "iii", u"Sichuan Yi") 348 | Language("sd", "snd", u"Sindhi") 349 | Language("si", "sin", u"Sinhala") 350 | Language("si", "sin", u"Sinhalese") 351 | Language("sk", "slo/slk", u"Slovak") 352 | Language("sl", "slv", u"Slovenian") 353 | Language("so", "som", u"Somali") 354 | Language("st", "sot", u"Sotho, Southern") 355 | Language("nr", "nbl", u"South Ndebele") 356 | Language("es", "spa", u"Spanish") 357 | Language("su", "sun", u"Sundanese") 358 | Language("sw", "swa", u"Swahili") 359 | Language("ss", "ssw", u"Swati") 360 | Language("sv", "swe", u"Swedish") 361 | Language("tl", "tgl", u"Tagalog") 362 | Language("ty", "tah", u"Tahitian") 363 | Language("tg", "tgk", u"Tajik") 364 | Language("ta", "tam", u"Tamil") 365 | Language("tt", "tat", u"Tatar") 366 | Language("te", "tel", u"Telugu") 367 | Language("th", "tha", u"Thai") 368 | Language("bo", "tib/bod", u"Tibetan") 369 | Language("ti", "tir", u"Tigrinya") 370 | Language("to", "ton", u"Tonga (Tonga Islands)") 371 | Language("ts", "tso", u"Tsonga") 372 | Language("tn", "tsn", u"Tswana") 373 | Language("tr", "tur", u"Turkish") 374 | Language("tk", "tuk", u"Turkmen") 375 | Language("tw", "twi", u"Twi") 376 | Language("ug", "uig", u"Uighur") 377 | Language("uk", "ukr", u"Ukrainian") 378 | Language("ur", "urd", u"Urdu") 379 | Language("ug", "uig", u"Uyghur") 380 | Language("uz", "uzb", u"Uzbek") 381 | Language("ca", "cat", u"Valencian") 382 | Language("ve", "ven", u"Venda") 383 | Language("vi", "vie", u"Vietnamese") 384 | Language("vo", "vol", u"Volapük") 385 | Language("wa", "wln", u"Walloon") 386 | Language("cy", "wel/cym", u"Welsh") 387 | Language("fy", "fry", u"Western Frisian") 388 | Language("wo", "wol", u"Wolof") 389 | Language("xh", "xho", u"Xhosa") 390 | Language("yi", "yid", u"Yiddish") 391 | Language("yo", "yor", u"Yoruba") 392 | Language("za", "zha", u"Zhuang") 393 | Language("zu", "zul", u"Zulu") 394 | Country("AF", u"AFGHANISTAN") 395 | Country("AX", u"ÅLAND ISLANDS") 396 | Country("AL", u"ALBANIA") 397 | Country("DZ", u"ALGERIA") 398 | Country("AS", u"AMERICAN SAMOA") 399 | Country("AD", u"ANDORRA") 400 | Country("AO", u"ANGOLA") 401 | Country("AI", u"ANGUILLA") 402 | Country("AQ", u"ANTARCTICA") 403 | Country("AG", u"ANTIGUA AND BARBUDA") 404 | Country("AR", u"ARGENTINA") 405 | Country("AM", u"ARMENIA") 406 | Country("AW", u"ARUBA") 407 | Country("AU", u"AUSTRALIA") 408 | Country("AT", u"AUSTRIA") 409 | Country("AZ", u"AZERBAIJAN") 410 | Country("BS", u"BAHAMAS") 411 | Country("BH", u"BAHRAIN") 412 | Country("BD", u"BANGLADESH") 413 | Country("BB", u"BARBADOS") 414 | Country("BY", u"BELARUS") 415 | Country("BE", u"BELGIUM") 416 | Country("BZ", u"BELIZE") 417 | Country("BJ", u"BENIN") 418 | Country("BM", u"BERMUDA") 419 | Country("BT", u"BHUTAN") 420 | Country("BO", u"BOLIVIA, PLURINATIONAL STATE OF") 421 | Country("BQ", u"BONAIRE, SINT EUSTATIUS AND SABA") 422 | Country("BA", u"BOSNIA AND HERZEGOVINA") 423 | Country("BW", u"BOTSWANA") 424 | Country("BV", u"BOUVET ISLAND") 425 | Country("BR", u"BRAZIL") 426 | Country("IO", u"BRITISH INDIAN OCEAN TERRITORY") 427 | Country("BN", u"BRUNEI DARUSSALAM") 428 | Country("BG", u"BULGARIA") 429 | Country("BF", u"BURKINA FASO") 430 | Country("BI", u"BURUNDI") 431 | Country("KH", u"CAMBODIA") 432 | Country("CM", u"CAMEROON") 433 | Country("CA", u"CANADA") 434 | Country("CV", u"CAPE VERDE") 435 | Country("KY", u"CAYMAN ISLANDS") 436 | Country("CF", u"CENTRAL AFRICAN REPUBLIC") 437 | Country("TD", u"CHAD") 438 | Country("CL", u"CHILE") 439 | Country("CN", u"CHINA") 440 | Country("CX", u"CHRISTMAS ISLAND") 441 | Country("CC", u"COCOS (KEELING) ISLANDS") 442 | Country("CO", u"COLOMBIA") 443 | Country("KM", u"COMOROS") 444 | Country("CG", u"CONGO") 445 | Country("CD", u"CONGO, THE DEMOCRATIC REPUBLIC OF THE") 446 | Country("CK", u"COOK ISLANDS") 447 | Country("CR", u"COSTA RICA") 448 | Country("CI", u"CÔTE D'IVOIRE") 449 | Country("HR", u"CROATIA") 450 | Country("CU", u"CUBA") 451 | Country("CW", u"CURAÇAO") 452 | Country("CY", u"CYPRUS") 453 | Country("CZ", u"CZECH REPUBLIC") 454 | Country("DK", u"DENMARK") 455 | Country("DJ", u"DJIBOUTI") 456 | Country("DM", u"DOMINICA") 457 | Country("DO", u"DOMINICAN REPUBLIC") 458 | Country("EC", u"ECUADOR") 459 | Country("EG", u"EGYPT") 460 | Country("SV", u"EL SALVADOR") 461 | Country("GQ", u"EQUATORIAL GUINEA") 462 | Country("ER", u"ERITREA") 463 | Country("EE", u"ESTONIA") 464 | Country("ET", u"ETHIOPIA") 465 | Country("FK", u"FALKLAND ISLANDS (MALVINAS)") 466 | Country("FO", u"FAROE ISLANDS") 467 | Country("FJ", u"FIJI") 468 | Country("FI", u"FINLAND") 469 | Country("FR", u"FRANCE") 470 | Country("GF", u"FRENCH GUIANA") 471 | Country("PF", u"FRENCH POLYNESIA") 472 | Country("TF", u"FRENCH SOUTHERN TERRITORIES") 473 | Country("GA", u"GABON") 474 | Country("GM", u"GAMBIA") 475 | Country("GE", u"GEORGIA") 476 | Country("DE", u"GERMANY") 477 | Country("GH", u"GHANA") 478 | Country("GI", u"GIBRALTAR") 479 | Country("GR", u"GREECE") 480 | Country("GL", u"GREENLAND") 481 | Country("GD", u"GRENADA") 482 | Country("GP", u"GUADELOUPE") 483 | Country("GU", u"GUAM") 484 | Country("GT", u"GUATEMALA") 485 | Country("GG", u"GUERNSEY") 486 | Country("GN", u"GUINEA") 487 | Country("GW", u"GUINEA-BISSAU") 488 | Country("GY", u"GUYANA") 489 | Country("HT", u"HAITI") 490 | Country("HM", u"HEARD ISLAND AND MCDONALD ISLANDS") 491 | Country("VA", u"HOLY SEE (VATICAN CITY STATE)") 492 | Country("HN", u"HONDURAS") 493 | Country("HK", u"HONG KONG") 494 | Country("HU", u"HUNGARY") 495 | Country("IS", u"ICELAND") 496 | Country("IN", u"INDIA") 497 | Country("ID", u"INDONESIA") 498 | Country("IR", u"IRAN, ISLAMIC REPUBLIC OF") 499 | Country("IQ", u"IRAQ") 500 | Country("IE", u"IRELAND") 501 | Country("IM", u"ISLE OF MAN") 502 | Country("IL", u"ISRAEL") 503 | Country("IT", u"ITALY") 504 | Country("JM", u"JAMAICA") 505 | Country("JP", u"JAPAN") 506 | Country("JE", u"JERSEY") 507 | Country("JO", u"JORDAN") 508 | Country("KZ", u"KAZAKHSTAN") 509 | Country("KE", u"KENYA") 510 | Country("KI", u"KIRIBATI") 511 | Country("KP", u"KOREA, DEMOCRATIC PEOPLE'S REPUBLIC OF") 512 | Country("KR", u"KOREA, REPUBLIC OF") 513 | Country("KW", u"KUWAIT") 514 | Country("KG", u"KYRGYZSTAN") 515 | Country("LA", u"LAO PEOPLE'S DEMOCRATIC REPUBLIC") 516 | Country("LV", u"LATVIA") 517 | Country("LB", u"LEBANON") 518 | Country("LS", u"LESOTHO") 519 | Country("LR", u"LIBERIA") 520 | Country("LY", u"LIBYA") 521 | Country("LI", u"LIECHTENSTEIN") 522 | Country("LT", u"LITHUANIA") 523 | Country("LU", u"LUXEMBOURG") 524 | Country("MO", u"MACAO") 525 | Country("MK", u"MACEDONIA, THE FORMER YUGOSLAV REPUBLIC OF") 526 | Country("MG", u"MADAGASCAR") 527 | Country("MW", u"MALAWI") 528 | Country("MY", u"MALAYSIA") 529 | Country("MV", u"MALDIVES") 530 | Country("ML", u"MALI") 531 | Country("MT", u"MALTA") 532 | Country("MH", u"MARSHALL ISLANDS") 533 | Country("MQ", u"MARTINIQUE") 534 | Country("MR", u"MAURITANIA") 535 | Country("MU", u"MAURITIUS") 536 | Country("YT", u"MAYOTTE") 537 | Country("MX", u"MEXICO") 538 | Country("FM", u"MICRONESIA, FEDERATED STATES OF") 539 | Country("MD", u"MOLDOVA, REPUBLIC OF") 540 | Country("MC", u"MONACO") 541 | Country("MN", u"MONGOLIA") 542 | Country("ME", u"MONTENEGRO") 543 | Country("MS", u"MONTSERRAT") 544 | Country("MA", u"MOROCCO") 545 | Country("MZ", u"MOZAMBIQUE") 546 | Country("MM", u"MYANMAR") 547 | Country("NA", u"NAMIBIA") 548 | Country("NR", u"NAURU") 549 | Country("NP", u"NEPAL") 550 | Country("NL", u"NETHERLANDS") 551 | Country("NC", u"NEW CALEDONIA") 552 | Country("NZ", u"NEW ZEALAND") 553 | Country("NI", u"NICARAGUA") 554 | Country("NE", u"NIGER") 555 | Country("NG", u"NIGERIA") 556 | Country("NU", u"NIUE") 557 | Country("NF", u"NORFOLK ISLAND") 558 | Country("MP", u"NORTHERN MARIANA ISLANDS") 559 | Country("NO", u"NORWAY") 560 | Country("OM", u"OMAN") 561 | Country("PK", u"PAKISTAN") 562 | Country("PW", u"PALAU") 563 | Country("PS", u"PALESTINIAN TERRITORY, OCCUPIED") 564 | Country("PA", u"PANAMA") 565 | Country("PG", u"PAPUA NEW GUINEA") 566 | Country("PY", u"PARAGUAY") 567 | Country("PE", u"PERU") 568 | Country("PH", u"PHILIPPINES") 569 | Country("PN", u"PITCAIRN") 570 | Country("PL", u"POLAND") 571 | Country("PT", u"PORTUGAL") 572 | Country("PR", u"PUERTO RICO") 573 | Country("QA", u"QATAR") 574 | Country("RE", u"RÉUNION") 575 | Country("RO", u"ROMANIA") 576 | Country("RU", u"RUSSIAN FEDERATION") 577 | Country("RW", u"RWANDA") 578 | Country("BL", u"SAINT BARTHÉLEMY") 579 | Country("SH", u"SAINT HELENA, ASCENSION AND TRISTAN DA CUNHA") 580 | Country("KN", u"SAINT KITTS AND NEVIS") 581 | Country("LC", u"SAINT LUCIA") 582 | Country("MF", u"SAINT MARTIN (FRENCH PART)") 583 | Country("PM", u"SAINT PIERRE AND MIQUELON") 584 | Country("VC", u"SAINT VINCENT AND THE GRENADINES") 585 | Country("WS", u"SAMOA") 586 | Country("SM", u"SAN MARINO") 587 | Country("ST", u"SAO TOME AND PRINCIPE") 588 | Country("SA", u"SAUDI ARABIA") 589 | Country("SN", u"SENEGAL") 590 | Country("RS", u"SERBIA") 591 | Country("SC", u"SEYCHELLES") 592 | Country("SL", u"SIERRA LEONE") 593 | Country("SG", u"SINGAPORE") 594 | Country("SX", u"SINT MAARTEN (DUTCH PART)") 595 | Country("SK", u"SLOVAKIA") 596 | Country("SI", u"SLOVENIA") 597 | Country("SB", u"SOLOMON ISLANDS") 598 | Country("SO", u"SOMALIA") 599 | Country("ZA", u"SOUTH AFRICA") 600 | Country("GS", u"SOUTH GEORGIA AND THE SOUTH SANDWICH ISLANDS") 601 | Country("SS", u"SOUTH SUDAN") 602 | Country("ES", u"SPAIN") 603 | Country("LK", u"SRI LANKA") 604 | Country("SD", u"SUDAN") 605 | Country("SR", u"SURINAME") 606 | Country("SJ", u"SVALBARD AND JAN MAYEN") 607 | Country("SZ", u"SWAZILAND") 608 | Country("SE", u"SWEDEN") 609 | Country("CH", u"SWITZERLAND") 610 | Country("SY", u"SYRIAN ARAB REPUBLIC") 611 | Country("TW", u"TAIWAN, PROVINCE OF CHINA") 612 | Country("TJ", u"TAJIKISTAN") 613 | Country("TZ", u"TANZANIA, UNITED REPUBLIC OF") 614 | Country("TH", u"THAILAND") 615 | Country("TL", u"TIMOR-LESTE") 616 | Country("TG", u"TOGO") 617 | Country("TK", u"TOKELAU") 618 | Country("TO", u"TONGA") 619 | Country("TT", u"TRINIDAD AND TOBAGO") 620 | Country("TN", u"TUNISIA") 621 | Country("TR", u"TURKEY") 622 | Country("TM", u"TURKMENISTAN") 623 | Country("TC", u"TURKS AND CAICOS ISLANDS") 624 | Country("TV", u"TUVALU") 625 | Country("UG", u"UGANDA") 626 | Country("UA", u"UKRAINE") 627 | Country("AE", u"UNITED ARAB EMIRATES") 628 | Country("GB", u"UNITED KINGDOM") 629 | Country("US", u"UNITED STATES") 630 | Country("UM", u"UNITED STATES MINOR OUTLYING ISLANDS") 631 | Country("UY", u"URUGUAY") 632 | Country("UZ", u"UZBEKISTAN") 633 | Country("VU", u"VANUATU") 634 | Country("VE", u"VENEZUELA, BOLIVARIAN REPUBLIC OF") 635 | Country("VN", u"VIET NAM") 636 | Country("VG", u"VIRGIN ISLANDS, BRITISH") 637 | Country("VI", u"VIRGIN ISLANDS, U.S.") 638 | Country("WF", u"WALLIS AND FUTUNA") 639 | Country("EH", u"WESTERN SAHARA") 640 | Country("YE", u"YEMEN") 641 | Country("ZM", u"ZAMBIA") 642 | Country("ZW", u"ZIMBABWE") 643 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | PyTMDB3 2 | ======= 3 | 4 | This Python module implements the v3 API for TheMovieDb.org, allowing access 5 | to movie and cast information, as well as related artwork. More information 6 | can be found at: 7 | 8 | http://help.themoviedb.org/kb/api/about-3 9 | 10 | Initial Access 11 | -------------- 12 | 13 | Access to the API requires a personal key. You can create one by signing up 14 | for an account on TheMovieDb.org, and generating one from your Account Details 15 | page. Once done, the PyTMDB3 module must be be given this key as follows: 16 | 17 | >>> from tmdb3 import set_key 18 | >>> set_key('your_api_key') 19 | 20 | Caching Engine 21 | -------------- 22 | 23 | In order to limit excessive usage against the online API server, the PyTMDB3 24 | module supports caching of requests. Cached data is keyed off the request URL, 25 | and is currently stored for one hour. API requests are limited to thirty (30) 26 | within ten (10) seconds. Requests beyond this limit are blocking until they 27 | can be processed. 28 | 29 | There are currently two engines available for use. The `null` engine merely 30 | discards all information, and is only intended for debugging use. The `file` 31 | engine is defualt, and will store to `/tmp/pytmdb3.cache` unless configured 32 | otherwise. The cache engine can be configured as follows. 33 | 34 | >>> from tmdb3 import set_cache 35 | >>> set_cache('null') 36 | >>> set_cache(filename='/full/path/to/cache') # the 'file' engine is assumed 37 | >>> set_cache(filename='tmdb3.cache') # relative paths are put in /tmp 38 | >>> set_cache(engine='file', filename='~/.tmdb3cache') 39 | 40 | Locale Configuration 41 | -------------------- 42 | 43 | The previous v2 API supported language selection, but would fall through to 44 | the defaults for any fields that did not have language-specific values. The 45 | v3 API no longer performs this fall through, leaving it up to clients to 46 | optionally implement it on their own. 47 | 48 | The PyTMDB3 module supports the use of locales in two separate manners. One 49 | can define a global locale that is automatically used if not specified 50 | otherwise, or a specific locale can be supplied directly to searches and 51 | data queries using the `locale=` keyword argument, which is then propogated 52 | through any subsequent queries made through those objects. 53 | 54 | Locale settings are controlled through two functions 55 | 56 | >>> from tmdb3 import get_locale, set_locale 57 | >>> get_locale() 58 | 59 | >>> set_locale() 60 | >>> get_locale() 61 | 62 | >>> set_locale('en', 'gb') 63 | >>> get_locale() 64 | 65 | >>> get_locale('fr', 'fr') 66 | 67 | 68 | * `set_locale()` is used to set the global default locale. It optionally accepts 69 | `language` and `country` keyword arguments. If not supplied, it will attempt 70 | to pull such information from your environment. It also accepts a 71 | `fallthrough` keyword argument, which is used to control the language and 72 | country filter fall through. This is disabled by default, meaning if a 73 | language is set, it will only return information specific to that language. 74 | 75 | * `get_locale()` also accepts optional `language` and `country` keyword arguments, 76 | and can be used to generate locales to use directly, overriding the global 77 | configuration. If none is given, this instead returns the global 78 | configuration. Note that fall through behavior is applied module-wide, and 79 | individual locales cannot be used to change that behavior. 80 | 81 | Authentication 82 | -------------- 83 | 84 | This is not yet supported. 85 | 86 | Searching 87 | --------- 88 | 89 | There are currently six search methods available for use: `movies`, `people`, 90 | `studios`, `lists`, `collections`, and `series`. Search results from TheMovieDb 91 | are sent iteratively, twenty results per page. The search methods provided by 92 | the PyTMDB3 module return list-like structures that will automatically grab 93 | new pages as needed. 94 | 95 | >>> from tmdb3 import searchMovie 96 | >>> res = searchMovie('A New Hope') 97 | >>> res 98 | 99 | >>> len(res) 100 | 4 101 | >>> res[0] 102 | 103 | 104 | The `movieSearch()` method accepts an 'adult' keyword to allow adult content 105 | to be returned. By default, this is set to False and such content is filtered 106 | out. The people search method behaves similarly. 107 | 108 | >>> from tmdb import searchPerson 109 | >>> res = searchPerson('Hanks') 110 | >>> res 111 | 112 | >>> res[0] 113 | 114 | 115 | >>> from tmdb import searchStudio 116 | >>> res = searchStudio('Sony Pictures') 117 | >>> res 118 | 119 | >>> res[0] 120 | 121 | 122 | The `movieSearch()` method accepts a `year` keyword, which tells TMDB to 123 | filter for movies of only that specific year. There is a helper method, 124 | `movieSearchWithYear()`, which will process the release year from movie 125 | names where the year is contained in parentheses, as in: 126 | 127 | >>> from tmdb import searchMovieWithYear 128 | >>> list(searchMovieWithYear('Star Wars (1977)')) 129 | [, ] 130 | 131 | 132 | Direct Queries 133 | -------------- 134 | 135 | There are currently four data types that support direct access: `Collection`s, 136 | `Movie`s, `Person`s, and `Studio`s. These each take a single integer ID as an 137 | argument. All data attributes are implemented as properties, and populated 138 | on-demand as used, rather than when the object is created. 139 | 140 | >>> from tmdb3 import Collection, Movie, Person, Studio 141 | >>> Collection(10) 142 | 143 | >>> Movie(11) 144 | 145 | >>> Person(2) 146 | 147 | >>> Studio(1) 148 | 149 | 150 | The `Genre` class cannot be called by id directly, however it does have a 151 | `getAll` classmethod, capable of returning all available genres for a specified 152 | language. 153 | 154 | Image Behavior 155 | -------------- 156 | 157 | TheMovieDb currently offers three types of artwork: backdrops, posters, and 158 | profiles. The three data queries above will each carry a default one of these 159 | and potentially a list of additionals to choose from. Each can be downloaded 160 | directly, or at one of several pre-scaled reduced resolutions. The PyTMDB3 161 | module provides a list of available sizes, and will generate a URL to download 162 | a requested size. Invalid sizes return an error. 163 | 164 | >>> from tmdb3 import Movie 165 | >>> p = Movie(11).poster 166 | >>> p 167 | 168 | >>> p.sizes() 169 | [u'w92', u'w154', u'w185', u'w342', u'w500', u'original'] 170 | >>> p.geturl() 171 | u'http://cf2.imgobject.com/t/p/original/tvSlBzAdRE29bZe5yYWrJ2ds137.jpg' 172 | >>> p.geturl('w342') 173 | u'http://cf2.imgobject.com/t/p/w342/tvSlBzAdRE29bZe5yYWrJ2ds137.jpg' 174 | >>> p.geturl('w300') 175 | Traceback (most recent call last): 176 | File "", line 1, in 177 | File "tmdb3/tmdb_api.py", line 101, in geturl 178 | raise TMDBImageSizeError 179 | tmdb3.tmdb_exceptions.TMDBImageSizeError: None 180 | 181 | Trailers 182 | -------- 183 | 184 | TheMovieDb offers access to trailers on Youtube and Apple, however their use 185 | is slightly different. Youtube trailers offer an individual file, while Apple 186 | trailers offer multiple sizes. 187 | 188 | >>> from tmdb3 import Movie 189 | >>> movie = Movie(27205) 190 | >>> movie.youtube_trailers 191 | [, ] 192 | >>> movie.youtube_trailers[0].geturl() 193 | 'http://www.youtube.com/watch?v=suIIHZqDR30' 194 | >>> movie.apple_trailers 195 | [, , ] 196 | >>> movie.apple_trailers[0].sizes() 197 | [u'480p', u'720p', u'1080p'] 198 | >>> movie.apple_trailers[0].geturl() 199 | u'http://pdl.warnerbros.com/wbmovies/inception/Inception_TRL1_1080.mov' 200 | >>> movie.apple_trailers[0].geturl() 201 | u'http://pdl.warnerbros.com/wbmovies/inception/Inception_TRL1_480.mov' 202 | 203 | List of Available Data 204 | ---------------------- 205 | 206 | #### Collection: 207 | 208 | | type | name | 209 | |-----------------------|--------------------| 210 | | integer | id | 211 | | string | name | 212 | | string | overview | 213 | | Backdrop | backdrop | 214 | | Poster | poster | 215 | | list(Movie) | members | 216 | | list(Backdrop) | backdrops | 217 | | list(Poster) | posters | 218 | 219 | #### Movie: 220 | | type | name | notes | 221 | |-----------------------|--------------------|----------------------------------------| 222 | | integer | id | | 223 | | string | title | language specific | 224 | | string | originaltitle | origin language | 225 | | string | tagline | | 226 | | string | overview | | 227 | | integer | runtime | | 228 | | integer | budget | | 229 | | integer | revenue | | 230 | | datetime | releasedate | | 231 | | string | homepage | | 232 | | string | IMDB reference id | 'ttXXXXXXX' | 233 | | Backdrop | backdrop | | 234 | | Poster | poster | | 235 | | float | popularity | | 236 | | float | userrating | | 237 | | integer | votes | | 238 | | boolean | adult | | 239 | | Collection | collection | | 240 | | list(Genre) | genres | | 241 | | list(Studio) | studios | | 242 | | list(Country) | countries | | 243 | | list(Language) | languages | | 244 | | list(AlternateTitle) | alternate_title | | 245 | | list(Cast) | cast | sorted by billing | 246 | | list(Crew) | crew | | 247 | | list(Backdrop) | backdrops | | 248 | | list(Poster) | posters | | 249 | | list(Keyword) | keywords | | 250 | | dict(Release) | releases | indexed by country | 251 | | list(Translation) | translations | | 252 | | list(Movie) | similar | | 253 | | list(List) | lists | | 254 | | list(Movie) | getSimilar() | | 255 | | None | setFavorite(bool) | mark favorite status for current user | 256 | | None | setRating(int) | rate movie by current user | 257 | | None | setWatchlist(bool) | mark watchlist status for current user | 258 | 259 | #### Movie classmethod: 260 | | type | name | notes | 261 | |-----------------------|--------------------|---------------------------------------------| 262 | | Movie | fromIMDB(imdbid) | special constructor for use with IMDb codes | 263 | | Movie | latest() | gets latest movie added to database | 264 | | list(Movie) | nowplaying() | content currently in theater | 265 | | list(Movie) | mostpopular() | based off themoviedb.org page view counts | 266 | | list(Movie) | toprated() | based off themoviedb.org user ratings | 267 | | list(Movie) | upcoming() | curated list, typically contains 100 movies | 268 | | list(Movie) | favorites() | current user's favorite movies | 269 | | list(Movie) | ratedmovies() | movies rated by current user | 270 | | list(Movie) | watchlist() | movies marked to watch by current user | 271 | 272 | #### Series: 273 | | type | name | 274 | |-----------------------|--------------------| 275 | | integer | id | 276 | | string | name | 277 | | string | original_name | 278 | | string | overview | 279 | | string | homepage | 280 | | integer | number_of_seasons | 281 | | integer | number_of_episodes | 282 | | float | popularity | 283 | | float | userrating | 284 | | integer | votes | 285 | | datetime | first_air_date | 286 | | datetime | last_air_date | 287 | | bool | inproduction | 288 | | string | status | 289 | | Backdrop | backdrop | 290 | | Poster | poster | 291 | | string | imdb_id | 292 | | string | freebase_id | 293 | | string | freebase_mid | 294 | | string | tvdb_id | 295 | | string | tvrage_id | 296 | | list(Person) | authors | 297 | | list(datetime) | episode_run_time | 298 | | list(Genre) | genres | 299 | | list(string) | languages | 300 | | list(string) | origin_countries | 301 | | list(Network) | networks | 302 | | list(Season) | seasons | 303 | | list(Cast) | cast | 304 | | list(Crew) | crew | 305 | | list(Backdrop) | backdrops | 306 | | list(Poster) | posters | 307 | | list(Series) | similar | 308 | | list(Keyword) | keywords | 309 | 310 | #### Season: 311 | | type | name | 312 | |-----------------------|--------------------| 313 | | integer | id | 314 | | string | name | 315 | | datetime | air_date | 316 | | string | overview | 317 | | integer | series_id | 318 | | integer | season_number | 319 | | Poster | poster | 320 | | string | freebase_id | 321 | | string | freebase_mid | 322 | | string | tvdb_id | 323 | | string | tvrage_id | 324 | | list(Poster) | posters | 325 | | list(Episode) | episodes | 326 | 327 | #### Episode: 328 | 329 | | type | name | 330 | |-----------------------|--------------------| 331 | | integer | id | 332 | | integer | series_id | 333 | | integer | season_number | 334 | | integer | episode_number | 335 | | string | name | 336 | | string | overview | 337 | | float | userrating | 338 | | integer | votes | 339 | | datetime | air_date | 340 | | string | production_code | 341 | | Backdrop | still | 342 | | string | freebase_id | 343 | | string | freebase_mid | 344 | | string | tvdb_id | 345 | | string | tvrage_id | 346 | | list(Backdrop) | stills | 347 | | list(Cast) | cast | 348 | | list(Cast) | guest_stars | 349 | | list(Crew) | crew | 350 | 351 | #### List: 352 | 353 | | type | name | notes | 354 | |-----------------------|--------------------|---------------------------------------| 355 | | hex string | id | | 356 | | string | name | | 357 | | string | author | | 358 | | string | description | | 359 | | integer | favorites | number of users that have marked list | 360 | | string | language | | 361 | | integer | count | | 362 | | Poster | poster | | 363 | | list(Movie) | members | | 364 | 365 | #### Person: 366 | 367 | | type | name | 368 | |-----------------------|--------------------| 369 | | integer | id | 370 | | string | name | 371 | | string | biography | 372 | | datetime | dayofbirth | 373 | | datetime | dayofdeath | 374 | | string | homepage | 375 | | Profile | profile | 376 | | boolean | adult | 377 | | list(string) | aliases | 378 | | list(ReverseCast) | roles | 379 | | list(ReverseCrew) | crew | 380 | | list(Profile) | profiles | 381 | 382 | #### Cast (derived from `Person`): 383 | 384 | | type | name | notes | 385 | |-----------------------|--------------------|-----------------------| 386 | | string | character | | 387 | | integer | order | as appears in credits | 388 | 389 | #### Crew (derived from `Person`): 390 | 391 | | type | name | 392 | |-----------------------|--------------------| 393 | | string | job | 394 | | string | department | 395 | 396 | #### ReverseCast (derived from `Movie`): 397 | 398 | | type | name | 399 | |-----------------------|--------------------| 400 | | string | character | 401 | 402 | #### ReverseCrew (derived from `Movie`): 403 | 404 | | type | name | 405 | |-----------------------|--------------------| 406 | | string | job | 407 | | string | department | 408 | 409 | #### Image: 410 | 411 | | type | name | notes | 412 | |-----------------------|-------------------------|----------------------------------| 413 | | string | filename | arbitrary alphanumeric code | 414 | | float | aspectratio | not available for default images | 415 | | integer | height | not available for default images | 416 | | integer | width | not available for default images | 417 | | integer | language | not available for default images | 418 | | float | userrating | | 419 | | integer | votes | | 420 | | list(string) | sizes() | | 421 | | string | geturl(size='original') | | 422 | 423 | Backdrop (derived from `Image`) 424 | Poster (derived from `Image`) 425 | Profile (derived from `Image`) 426 | Logo (derived from `Image`) 427 | 428 | #### AlternateTitle: 429 | 430 | | type | name | 431 | |-----------------------|--------------------| 432 | | string | country | 433 | | string | title | 434 | 435 | #### Release: 436 | 437 | | type | name | 438 | |-----------------------|--------------------| 439 | | string | certification | 440 | | string | country | 441 | | datetime | releasedate | 442 | 443 | #### Translation: 444 | 445 | | type | name | 446 | |-----------------------|--------------------| 447 | | string | name | 448 | | string | englishname | 449 | | string | language | 450 | 451 | #### Genre: 452 | 453 | | type | name | 454 | |-----------------------|--------------------| 455 | | integer | id | 456 | | string | name | 457 | | list(Movie) | movies | 458 | 459 | #### Genre classmethods: 460 | 461 | | type | name | notes | 462 | |-----------------------|--------------------|----------------------------| 463 | | list(Genre) | getAll(language) | returns list of all genres | 464 | 465 | #### Studio: 466 | 467 | | type | name | 468 | |-----------------------|--------------------| 469 | | integer | id | 470 | | string | name | 471 | | string | description | 472 | | string | headquarters | 473 | | Logo | logo | 474 | | Studio | parent | 475 | | list(Movie) | movies | 476 | 477 | #### Network: 478 | 479 | | type | name | 480 | |-----------------------|--------------------| 481 | | integer | id | 482 | | string | name | 483 | 484 | #### Country: 485 | 486 | | type | name | 487 | |-----------------------|--------------------| 488 | | string | code | 489 | | string | name | 490 | 491 | #### Language: 492 | 493 | | type | name | 494 | |-----------------------|--------------------| 495 | | string | code | 496 | | string | name | 497 | 498 | #### Trailer: 499 | 500 | | type | name | 501 | |-----------------------|--------------------| 502 | | string | name | 503 | | string | size | 504 | | string | source | 505 | 506 | #### YoutubeTrailer (derived from `Trailer`) 507 | 508 | | type | name | 509 | |-----------------------|--------------------| 510 | | string | geturl() | 511 | 512 | #### AppleTrailer 513 | 514 | | type | name | notes | 515 | |----------------------|---------------------|-----------------| 516 | | string | name | | 517 | | dict(Trailer) | sources | indexed by size | 518 | | list(string) | sizes() | | 519 | | string | geturl(size=None) | | 520 | -------------------------------------------------------------------------------- /tmdb3/tmdb_api.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | #----------------------- 4 | # Name: tmdb_api.py Simple-to-use Python interface to TMDB's API v3 5 | # Python Library 6 | # Author: Raymond Wagner 7 | # Purpose: This Python library is intended to provide a series of classes 8 | # and methods for search and retrieval of text metadata and image 9 | # URLs from TMDB. 10 | # Preliminary API specifications can be found at 11 | # http://help.themoviedb.org/kb/api/about-3 12 | # License: Creative Commons GNU GPL v2 13 | # (http://creativecommons.org/licenses/GPL/2.0/) 14 | #----------------------- 15 | 16 | __title__ = ("tmdb_api - Simple-to-use Python interface to TMDB's API v3 " + 17 | "(www.themoviedb.org)") 18 | __author__ = "Raymond Wagner" 19 | __purpose__ = """ 20 | This Python library is intended to provide a series of classes and methods 21 | for search and retrieval of text metadata and image URLs from TMDB. 22 | Preliminary API specifications can be found at 23 | http://help.themoviedb.org/kb/api/about-3""" 24 | 25 | __version__ = "v0.7.2" 26 | # 0.1.0 Initial development 27 | # 0.2.0 Add caching mechanism for API queries 28 | # 0.2.1 Temporary work around for broken search paging 29 | # 0.3.0 Rework backend machinery for managing OO interface to results 30 | # 0.3.1 Add collection support 31 | # 0.3.2 Remove MythTV key from results.py 32 | # 0.3.3 Add functional language support 33 | # 0.3.4 Re-enable search paging 34 | # 0.3.5 Add methods for grabbing current, popular, and top rated movies 35 | # 0.3.6 Rework paging mechanism 36 | # 0.3.7 Generalize caching mechanism, and allow controllability 37 | # 0.4.0 Add full locale support (language and country) and optional fall through 38 | # 0.4.1 Add custom classmethod for dealing with IMDB movie IDs 39 | # 0.4.2 Improve cache file selection for Windows systems 40 | # 0.4.3 Add a few missed Person properties 41 | # 0.4.4 Add support for additional Studio information 42 | # 0.4.5 Add locale fallthrough for images and alternate titles 43 | # 0.4.6 Add slice support for search results 44 | # 0.5.0 Rework cache framework and improve file cache performance 45 | # 0.6.0 Add user authentication support 46 | # 0.6.1 Add adult filtering for people searches 47 | # 0.6.2 Add similar movie search for Movie objects 48 | # 0.6.3 Add Studio search 49 | # 0.6.4 Add Genre list and associated Movie search 50 | # 0.6.5 Prevent data from being blanked out by subsequent queries 51 | # 0.6.6 Turn date processing errors into mutable warnings 52 | # 0.6.7 Add support for searching by year 53 | # 0.6.8 Add support for collection images 54 | # 0.6.9 Correct Movie image language filtering 55 | # 0.6.10 Add upcoming movie classmethod 56 | # 0.6.11 Fix URL for top rated Movie query 57 | # 0.6.12 Add support for Movie watchlist query and editing 58 | # 0.6.13 Fix URL for rating Movies 59 | # 0.6.14 Add support for Lists 60 | # 0.6.15 Add ability to search Collections 61 | # 0.6.16 Make absent primary images return None (previously u'') 62 | # 0.6.17 Add userrating/votes to Image, add overview to Collection, remove 63 | # releasedate sorting from Collection Movies 64 | # 0.7.0 Add support for television series data 65 | # 0.7.1 Add rate limiter to cache engine 66 | # 0.7.2 Add similar and keywords to TV Series 67 | # Fix unicode issues with search result object names 68 | # Temporary fix for youtube videos with malformed URLs 69 | 70 | from request import set_key, Request 71 | from util import Datapoint, Datalist, Datadict, Element, NameRepr, SearchRepr 72 | from pager import PagedRequest 73 | from locales import get_locale, set_locale 74 | from tmdb_auth import get_session, set_session 75 | from tmdb_exceptions import * 76 | 77 | import json 78 | import urllib 79 | import urllib2 80 | import datetime 81 | 82 | DEBUG = False 83 | 84 | 85 | def process_date(datestr): 86 | try: 87 | return datetime.date(*[int(x) for x in datestr.split('-')]) 88 | except (TypeError, ValueError): 89 | import sys 90 | import warnings 91 | import traceback 92 | _,_,tb = sys.exc_info() 93 | f,l,_,_ = traceback.extract_tb(tb)[-1] 94 | warnings.warn_explicit(('"{0}" is not a supported date format. ' + 95 | 'Please fix upstream data at ' + 96 | 'http://www.themoviedb.org.' 97 | ).format(datestr), Warning, f, l) 98 | return None 99 | 100 | 101 | class Configuration(Element): 102 | images = Datapoint('images') 103 | 104 | def _populate(self): 105 | return Request('configuration') 106 | 107 | Configuration = Configuration() 108 | 109 | 110 | class Account(NameRepr, Element): 111 | def _populate(self): 112 | return Request('account', session_id=self._session.sessionid) 113 | 114 | id = Datapoint('id') 115 | adult = Datapoint('include_adult') 116 | country = Datapoint('iso_3166_1') 117 | language = Datapoint('iso_639_1') 118 | name = Datapoint('name') 119 | username = Datapoint('username') 120 | 121 | @property 122 | def locale(self): 123 | return get_locale(self.language, self.country) 124 | 125 | 126 | def searchMovie(query, locale=None, adult=False, year=None): 127 | kwargs = {'query': query, 'include_adult': adult} 128 | if year is not None: 129 | try: 130 | kwargs['year'] = year.year 131 | except AttributeError: 132 | kwargs['year'] = year 133 | return MovieSearchResult(Request('search/movie', **kwargs), locale=locale) 134 | 135 | 136 | def searchMovieWithYear(query, locale=None, adult=False): 137 | year = None 138 | if (len(query) > 6) and (query[-1] == ')') and (query[-6] == '('): 139 | # simple syntax check, no need for regular expression 140 | try: 141 | year = int(query[-5:-1]) 142 | except ValueError: 143 | pass 144 | else: 145 | if 1885 < year < 2050: 146 | # strip out year from search 147 | query = query[:-7] 148 | else: 149 | # sanity check on resolved year failed, pass through 150 | year = None 151 | return searchMovie(query, locale, adult, year) 152 | 153 | 154 | class MovieSearchResult(SearchRepr, PagedRequest): 155 | """Stores a list of search matches.""" 156 | _name = None 157 | def __init__(self, request, locale=None): 158 | if locale is None: 159 | locale = get_locale() 160 | super(MovieSearchResult, self).__init__( 161 | request.new(language=locale.language), 162 | lambda x: Movie(raw=x, locale=locale)) 163 | 164 | def searchSeries(query, first_air_date_year=None, search_type=None, locale=None): 165 | return SeriesSearchResult( 166 | Request('search/tv', query=query, first_air_date_year=first_air_date_year, search_type=search_type), 167 | locale=locale) 168 | 169 | 170 | class SeriesSearchResult(SearchRepr, PagedRequest): 171 | """Stores a list of search matches.""" 172 | _name = None 173 | def __init__(self, request, locale=None): 174 | if locale is None: 175 | locale = get_locale() 176 | super(SeriesSearchResult, self).__init__( 177 | request.new(language=locale.language), 178 | lambda x: Series(raw=x, locale=locale)) 179 | 180 | def searchPerson(query, adult=False): 181 | return PeopleSearchResult(Request('search/person', query=query, 182 | include_adult=adult)) 183 | 184 | 185 | class PeopleSearchResult(SearchRepr, PagedRequest): 186 | """Stores a list of search matches.""" 187 | _name = None 188 | def __init__(self, request): 189 | super(PeopleSearchResult, self).__init__( 190 | request, lambda x: Person(raw=x)) 191 | 192 | 193 | def searchStudio(query): 194 | return StudioSearchResult(Request('search/company', query=query)) 195 | 196 | 197 | class StudioSearchResult(SearchRepr, PagedRequest): 198 | """Stores a list of search matches.""" 199 | _name = None 200 | def __init__(self, request): 201 | super(StudioSearchResult, self).__init__( 202 | request, lambda x: Studio(raw=x)) 203 | 204 | 205 | def searchList(query, adult=False): 206 | ListSearchResult(Request('search/list', query=query, include_adult=adult)) 207 | 208 | 209 | class ListSearchResult(SearchRepr, PagedRequest): 210 | """Stores a list of search matches.""" 211 | _name = None 212 | def __init__(self, request): 213 | super(ListSearchResult, self).__init__( 214 | request, lambda x: List(raw=x)) 215 | 216 | 217 | def searchCollection(query, locale=None): 218 | return CollectionSearchResult(Request('search/collection', query=query), 219 | locale=locale) 220 | 221 | 222 | class CollectionSearchResult(SearchRepr, PagedRequest): 223 | """Stores a list of search matches.""" 224 | _name=None 225 | def __init__(self, request, locale=None): 226 | if locale is None: 227 | locale = get_locale() 228 | super(CollectionSearchResult, self).__init__( 229 | request.new(language=locale.language), 230 | lambda x: Collection(raw=x, locale=locale)) 231 | 232 | 233 | class Image(Element): 234 | filename = Datapoint('file_path', initarg=1, 235 | handler=lambda x: x.lstrip('/')) 236 | aspectratio = Datapoint('aspect_ratio') 237 | height = Datapoint('height') 238 | width = Datapoint('width') 239 | language = Datapoint('iso_639_1') 240 | userrating = Datapoint('vote_average') 241 | votes = Datapoint('vote_count') 242 | 243 | def sizes(self): 244 | return ['original'] 245 | 246 | def geturl(self, size='original'): 247 | if size not in self.sizes(): 248 | raise TMDBImageSizeError 249 | url = Configuration.images['base_url'].rstrip('/') 250 | return url+u'/{0}/{1}'.format(size, self.filename) 251 | 252 | # sort preferring locale's language, but keep remaining ordering consistent 253 | def __lt__(self, other): 254 | if not isinstance(other, Image): 255 | return False 256 | return (self.language == self._locale.language) \ 257 | and (self.language != other.language) 258 | 259 | def __gt__(self, other): 260 | if not isinstance(other, Image): 261 | return True 262 | return (self.language != other.language) \ 263 | and (other.language == self._locale.language) 264 | 265 | # direct match for comparison 266 | def __eq__(self, other): 267 | if not isinstance(other, Image): 268 | return False 269 | return self.filename == other.filename 270 | 271 | # special handling for boolean to see if exists 272 | def __nonzero__(self): 273 | if len(self.filename) == 0: 274 | return False 275 | return True 276 | 277 | def __repr__(self): 278 | # BASE62 encoded filename, no need to worry about unicode 279 | return u"<{0.__class__.__name__} '{0.filename}'>".format(self) 280 | 281 | 282 | class Backdrop(Image): 283 | def sizes(self): 284 | return Configuration.images['backdrop_sizes'] 285 | 286 | 287 | class Poster(Image): 288 | def sizes(self): 289 | return Configuration.images['poster_sizes'] 290 | 291 | 292 | class Profile(Image): 293 | def sizes(self): 294 | return Configuration.images['profile_sizes'] 295 | 296 | 297 | class Logo(Image): 298 | def sizes(self): 299 | return Configuration.images['logo_sizes'] 300 | 301 | 302 | class AlternateTitle(Element): 303 | country = Datapoint('iso_3166_1') 304 | title = Datapoint('title') 305 | 306 | # sort preferring locale's country, but keep remaining ordering consistent 307 | def __lt__(self, other): 308 | return (self.country == self._locale.country) \ 309 | and (self.country != other.country) 310 | 311 | def __gt__(self, other): 312 | return (self.country != other.country) \ 313 | and (other.country == self._locale.country) 314 | 315 | def __eq__(self, other): 316 | return self.country == other.country 317 | 318 | def __repr__(self): 319 | return u"<{0.__class__.__name__} '{0.title}' ({0.country})>"\ 320 | .format(self).encode('utf-8') 321 | 322 | 323 | class Person(Element): 324 | id = Datapoint('id', initarg=1) 325 | name = Datapoint('name') 326 | biography = Datapoint('biography') 327 | dayofbirth = Datapoint('birthday', default=None, handler=process_date) 328 | dayofdeath = Datapoint('deathday', default=None, handler=process_date) 329 | homepage = Datapoint('homepage') 330 | birthplace = Datapoint('place_of_birth') 331 | profile = Datapoint('profile_path', handler=Profile, 332 | raw=False, default=None) 333 | adult = Datapoint('adult') 334 | aliases = Datalist('also_known_as') 335 | 336 | def __repr__(self): 337 | return u"<{0.__class__.__name__} '{0.name}'>"\ 338 | .format(self).encode('utf-8') 339 | 340 | def _populate(self): 341 | return Request('person/{0}'.format(self.id)) 342 | 343 | def _populate_credits(self): 344 | return Request('person/{0}/credits'.format(self.id), 345 | language=self._locale.language) 346 | def _populate_images(self): 347 | return Request('person/{0}/images'.format(self.id)) 348 | 349 | roles = Datalist('cast', handler=lambda x: ReverseCast(raw=x), 350 | poller=_populate_credits) 351 | crew = Datalist('crew', handler=lambda x: ReverseCrew(raw=x), 352 | poller=_populate_credits) 353 | profiles = Datalist('profiles', handler=Profile, poller=_populate_images) 354 | 355 | 356 | class Cast(Person): 357 | character = Datapoint('character') 358 | order = Datapoint('order') 359 | 360 | def __repr__(self): 361 | return u"<{0.__class__.__name__} '{0.name}' as '{0.character}'>"\ 362 | .format(self).encode('utf-8') 363 | 364 | 365 | class Crew(Person): 366 | job = Datapoint('job') 367 | department = Datapoint('department') 368 | 369 | def __repr__(self): 370 | return u"<{0.__class__.__name__} '{0.name}','{0.job}'>"\ 371 | .format(self).encode('utf-8') 372 | 373 | 374 | class Keyword(Element): 375 | id = Datapoint('id') 376 | name = Datapoint('name') 377 | 378 | def __repr__(self): 379 | return u"<{0.__class__.__name__} '{0.name}'>"\ 380 | .format(self).encode('utf-8') 381 | 382 | 383 | class Release(Element): 384 | certification = Datapoint('certification') 385 | country = Datapoint('iso_3166_1') 386 | releasedate = Datapoint('release_date', handler=process_date) 387 | def __repr__(self): 388 | return u"<{0.__class__.__name__} '{0.country}', {0.releasedate}>"\ 389 | .format(self).encode('utf-8') 390 | 391 | 392 | class Trailer(Element): 393 | name = Datapoint('name') 394 | size = Datapoint('size') 395 | source = Datapoint('source') 396 | 397 | 398 | class YoutubeTrailer(Trailer): 399 | def geturl(self): 400 | self.source = self.source.encode('ascii',errors='ignore') 401 | return "http://www.youtube.com/watch?v={0}".format(self.source) 402 | 403 | def __repr__(self): 404 | # modified BASE64 encoding, no need to worry about unicode 405 | return u"<{0.__class__.__name__} '{0.name}'>".format(self) 406 | 407 | 408 | class AppleTrailer(Element): 409 | name = Datapoint('name') 410 | sources = Datadict('sources', handler=Trailer, attr='size') 411 | 412 | def sizes(self): 413 | return self.sources.keys() 414 | 415 | def geturl(self, size=None): 416 | if size is None: 417 | # sort assuming ###p format for now, take largest resolution 418 | size = str(sorted( 419 | [int(size[:-1]) for size in self.sources] 420 | )[-1]) + 'p' 421 | return self.sources[size].source 422 | 423 | def __repr__(self): 424 | return u"<{0.__class__.__name__} '{0.name}'>".format(self) 425 | 426 | 427 | class Translation(Element): 428 | name = Datapoint('name') 429 | language = Datapoint('iso_639_1') 430 | englishname = Datapoint('english_name') 431 | 432 | def __repr__(self): 433 | return u"<{0.__class__.__name__} '{0.name}' ({0.language})>"\ 434 | .format(self).encode('utf-8') 435 | 436 | 437 | class Genre(NameRepr, Element): 438 | id = Datapoint('id') 439 | name = Datapoint('name') 440 | 441 | def _populate_movies(self): 442 | return Request('genre/{0}/movies'.format(self.id), \ 443 | language=self._locale.language) 444 | 445 | @property 446 | def movies(self): 447 | if 'movies' not in self._data: 448 | search = MovieSearchResult(self._populate_movies(), \ 449 | locale=self._locale) 450 | search._name = u"{0.name} Movies".format(self) 451 | self._data['movies'] = search 452 | return self._data['movies'] 453 | 454 | @classmethod 455 | def getAll(cls, locale=None): 456 | class GenreList(Element): 457 | genres = Datalist('genres', handler=Genre) 458 | 459 | def _populate(self): 460 | return Request('genre/list', language=self._locale.language) 461 | return GenreList(locale=locale).genres 462 | 463 | 464 | class Studio(NameRepr, Element): 465 | id = Datapoint('id', initarg=1) 466 | name = Datapoint('name') 467 | description = Datapoint('description') 468 | headquarters = Datapoint('headquarters') 469 | logo = Datapoint('logo_path', handler=Logo, raw=False, default=None) 470 | # FIXME: manage not-yet-defined handlers in a way that will propogate 471 | # locale information properly 472 | parent = Datapoint('parent_company', handler=lambda x: Studio(raw=x)) 473 | 474 | def _populate(self): 475 | return Request('company/{0}'.format(self.id)) 476 | 477 | def _populate_movies(self): 478 | return Request('company/{0}/movies'.format(self.id), 479 | language=self._locale.language) 480 | 481 | # FIXME: add a cleaner way of adding types with no additional processing 482 | @property 483 | def movies(self): 484 | if 'movies' not in self._data: 485 | search = MovieSearchResult(self._populate_movies(), 486 | locale=self._locale) 487 | search._name = u"{0.name} Movies".format(self) 488 | self._data['movies'] = search 489 | return self._data['movies'] 490 | 491 | 492 | class Country(NameRepr, Element): 493 | code = Datapoint('iso_3166_1') 494 | name = Datapoint('name') 495 | 496 | 497 | class Language(NameRepr, Element): 498 | code = Datapoint('iso_639_1') 499 | name = Datapoint('name') 500 | 501 | 502 | class Movie(Element): 503 | @classmethod 504 | def latest(cls): 505 | req = Request('latest/movie') 506 | req.lifetime = 600 507 | return cls(raw=req.readJSON()) 508 | 509 | @classmethod 510 | def nowplaying(cls, locale=None): 511 | res = MovieSearchResult(Request('movie/now-playing'), locale=locale) 512 | res._name = 'Now Playing' 513 | return res 514 | 515 | @classmethod 516 | def mostpopular(cls, locale=None): 517 | res = MovieSearchResult(Request('movie/popular'), locale=locale) 518 | res._name = 'Popular' 519 | return res 520 | 521 | @classmethod 522 | def toprated(cls, locale=None): 523 | res = MovieSearchResult(Request('movie/top_rated'), locale=locale) 524 | res._name = 'Top Rated' 525 | return res 526 | 527 | @classmethod 528 | def upcoming(cls, locale=None): 529 | res = MovieSearchResult(Request('movie/upcoming'), locale=locale) 530 | res._name = 'Upcoming' 531 | return res 532 | 533 | @classmethod 534 | def favorites(cls, session=None): 535 | if session is None: 536 | session = get_session() 537 | account = Account(session=session) 538 | res = MovieSearchResult( 539 | Request('account/{0}/favorite_movies'.format(account.id), 540 | session_id=session.sessionid)) 541 | res._name = "Favorites" 542 | return res 543 | 544 | @classmethod 545 | def ratedmovies(cls, session=None): 546 | if session is None: 547 | session = get_session() 548 | account = Account(session=session) 549 | res = MovieSearchResult( 550 | Request('account/{0}/rated_movies'.format(account.id), 551 | session_id=session.sessionid)) 552 | res._name = "Movies You Rated" 553 | return res 554 | 555 | @classmethod 556 | def watchlist(cls, session=None): 557 | if session is None: 558 | session = get_session() 559 | account = Account(session=session) 560 | res = MovieSearchResult( 561 | Request('account/{0}/movie_watchlist'.format(account.id), 562 | session_id=session.sessionid)) 563 | res._name = "Movies You're Watching" 564 | return res 565 | 566 | @classmethod 567 | def fromIMDB(cls, imdbid, locale=None): 568 | try: 569 | # assume string 570 | if not imdbid.startswith('tt'): 571 | imdbid = "tt{0:0>7}".format(imdbid) 572 | except AttributeError: 573 | # assume integer 574 | imdbid = "tt{0:0>7}".format(imdbid) 575 | if locale is None: 576 | locale = get_locale() 577 | movie = cls(imdbid, locale=locale) 578 | movie._populate() 579 | return movie 580 | 581 | id = Datapoint('id', initarg=1) 582 | title = Datapoint('title') 583 | originaltitle = Datapoint('original_title') 584 | tagline = Datapoint('tagline') 585 | overview = Datapoint('overview') 586 | runtime = Datapoint('runtime') 587 | budget = Datapoint('budget') 588 | revenue = Datapoint('revenue') 589 | releasedate = Datapoint('release_date', handler=process_date) 590 | homepage = Datapoint('homepage') 591 | imdb = Datapoint('imdb_id') 592 | 593 | backdrop = Datapoint('backdrop_path', handler=Backdrop, 594 | raw=False, default=None) 595 | poster = Datapoint('poster_path', handler=Poster, 596 | raw=False, default=None) 597 | 598 | popularity = Datapoint('popularity') 599 | userrating = Datapoint('vote_average') 600 | votes = Datapoint('vote_count') 601 | 602 | adult = Datapoint('adult') 603 | collection = Datapoint('belongs_to_collection', handler=lambda x: \ 604 | Collection(raw=x)) 605 | genres = Datalist('genres', handler=Genre) 606 | studios = Datalist('production_companies', handler=Studio) 607 | countries = Datalist('production_countries', handler=Country) 608 | languages = Datalist('spoken_languages', handler=Language) 609 | 610 | def _populate(self): 611 | return Request('movie/{0}'.format(self.id), \ 612 | language=self._locale.language) 613 | 614 | def _populate_titles(self): 615 | kwargs = {} 616 | if not self._locale.fallthrough: 617 | kwargs['country'] = self._locale.country 618 | return Request('movie/{0}/alternative_titles'.format(self.id), 619 | **kwargs) 620 | 621 | def _populate_cast(self): 622 | return Request('movie/{0}/casts'.format(self.id)) 623 | 624 | def _populate_images(self): 625 | kwargs = {} 626 | if not self._locale.fallthrough: 627 | kwargs['language'] = self._locale.language 628 | return Request('movie/{0}/images'.format(self.id), **kwargs) 629 | 630 | def _populate_keywords(self): 631 | return Request('movie/{0}/keywords'.format(self.id)) 632 | 633 | def _populate_releases(self): 634 | return Request('movie/{0}/releases'.format(self.id)) 635 | 636 | def _populate_trailers(self): 637 | return Request('movie/{0}/trailers'.format(self.id), 638 | language=self._locale.language) 639 | 640 | def _populate_translations(self): 641 | return Request('movie/{0}/translations'.format(self.id)) 642 | 643 | alternate_titles = Datalist('titles', handler=AlternateTitle, \ 644 | poller=_populate_titles, sort=True) 645 | 646 | # FIXME: this data point will need to be changed to 'credits' at some point 647 | cast = Datalist('cast', handler=Cast, 648 | poller=_populate_cast, sort='order') 649 | 650 | crew = Datalist('crew', handler=Crew, poller=_populate_cast) 651 | backdrops = Datalist('backdrops', handler=Backdrop, 652 | poller=_populate_images, sort=True) 653 | posters = Datalist('posters', handler=Poster, 654 | poller=_populate_images, sort=True) 655 | keywords = Datalist('keywords', handler=Keyword, 656 | poller=_populate_keywords) 657 | releases = Datadict('countries', handler=Release, 658 | poller=_populate_releases, attr='country') 659 | youtube_trailers = Datalist('youtube', handler=YoutubeTrailer, 660 | poller=_populate_trailers) 661 | apple_trailers = Datalist('quicktime', handler=AppleTrailer, 662 | poller=_populate_trailers) 663 | translations = Datalist('translations', handler=Translation, 664 | poller=_populate_translations) 665 | 666 | def setFavorite(self, value): 667 | req = Request('account/{0}/favorite'.format( 668 | Account(session=self._session).id), 669 | session_id=self._session.sessionid) 670 | req.add_data({'movie_id': self.id, 671 | 'favorite': str(bool(value)).lower()}) 672 | req.lifetime = 0 673 | req.readJSON() 674 | 675 | def setRating(self, value): 676 | if not (0 <= value <= 10): 677 | raise TMDBError("Ratings must be between '0' and '10'.") 678 | req = Request('movie/{0}/rating'.format(self.id), 679 | session_id=self._session.sessionid) 680 | req.lifetime = 0 681 | req.add_data({'value':value}) 682 | req.readJSON() 683 | 684 | def setWatchlist(self, value): 685 | req = Request('account/{0}/movie_watchlist'.format( 686 | Account(session=self._session).id), 687 | session_id=self._session.sessionid) 688 | req.lifetime = 0 689 | req.add_data({'movie_id': self.id, 690 | 'movie_watchlist': str(bool(value)).lower()}) 691 | req.readJSON() 692 | 693 | def getSimilar(self): 694 | return self.similar 695 | 696 | @property 697 | def similar(self): 698 | res = MovieSearchResult(Request( 699 | 'movie/{0}/similar_movies'.format(self.id)), 700 | locale=self._locale) 701 | res._name = u'Similar to {0}'.format(self._printable_name()) 702 | return res 703 | 704 | @property 705 | def lists(self): 706 | res = ListSearchResult(Request('movie/{0}/lists'.format(self.id))) 707 | res._name = "Lists containing {0}".format(self._printable_name()) 708 | return res 709 | 710 | def _printable_name(self): 711 | if self.title is not None: 712 | s = u"'{0}'".format(self.title) 713 | elif self.originaltitle is not None: 714 | s = u"'{0}'".format(self.originaltitle) 715 | else: 716 | s = u"'No Title'" 717 | if self.releasedate: 718 | s = u"{0} ({1})".format(s, self.releasedate.year) 719 | return s 720 | 721 | def __repr__(self): 722 | return u"<{0} {1}>".format(self.__class__.__name__, 723 | self._printable_name()).encode('utf-8') 724 | 725 | 726 | class ReverseCast( Movie ): 727 | character = Datapoint('character') 728 | 729 | def __repr__(self): 730 | return (u"<{0.__class__.__name__} '{0.character}' on {1}>" 731 | .format(self, self._printable_name()).encode('utf-8')) 732 | 733 | 734 | class ReverseCrew( Movie ): 735 | department = Datapoint('department') 736 | job = Datapoint('job') 737 | 738 | def __repr__(self): 739 | return (u"<{0.__class__.__name__} '{0.job}' for {1}>" 740 | .format(self, self._printable_name()).encode('utf-8')) 741 | 742 | 743 | class Collection(NameRepr, Element): 744 | id = Datapoint('id', initarg=1) 745 | name = Datapoint('name') 746 | backdrop = Datapoint('backdrop_path', handler=Backdrop, \ 747 | raw=False, default=None) 748 | poster = Datapoint('poster_path', handler=Poster, raw=False, default=None) 749 | members = Datalist('parts', handler=Movie) 750 | overview = Datapoint('overview') 751 | 752 | def _populate(self): 753 | return Request('collection/{0}'.format(self.id), 754 | language=self._locale.language) 755 | 756 | def _populate_images(self): 757 | kwargs = {} 758 | if not self._locale.fallthrough: 759 | kwargs['language'] = self._locale.language 760 | return Request('collection/{0}/images'.format(self.id), **kwargs) 761 | 762 | backdrops = Datalist('backdrops', handler=Backdrop, 763 | poller=_populate_images, sort=True) 764 | posters = Datalist('posters', handler=Poster, 765 | poller=_populate_images, sort=True) 766 | 767 | class List(NameRepr, Element): 768 | id = Datapoint('id', initarg=1) 769 | name = Datapoint('name') 770 | author = Datapoint('created_by') 771 | description = Datapoint('description') 772 | favorites = Datapoint('favorite_count') 773 | language = Datapoint('iso_639_1') 774 | count = Datapoint('item_count') 775 | poster = Datapoint('poster_path', handler=Poster, raw=False, default=None) 776 | members = Datalist('items', handler=Movie) 777 | 778 | def _populate(self): 779 | return Request('list/{0}'.format(self.id)) 780 | 781 | class Network(NameRepr,Element): 782 | id = Datapoint('id', initarg=1) 783 | name = Datapoint('name') 784 | 785 | class Episode(NameRepr, Element): 786 | episode_number = Datapoint('episode_number', initarg=3) 787 | season_number = Datapoint('season_number', initarg=2) 788 | series_id = Datapoint('series_id', initarg=1) 789 | air_date = Datapoint('air_date', handler=process_date) 790 | overview = Datapoint('overview') 791 | name = Datapoint('name') 792 | userrating = Datapoint('vote_average') 793 | votes = Datapoint('vote_count') 794 | id = Datapoint('id') 795 | production_code = Datapoint('production_code') 796 | still = Datapoint('still_path', handler=Backdrop, raw=False, default=None) 797 | 798 | def _populate(self): 799 | return Request('tv/{0}/season/{1}/episode/{2}'.format(self.series_id, self.season_number, self.episode_number), 800 | language=self._locale.language) 801 | 802 | def _populate_cast(self): 803 | return Request('tv/{0}/season/{1}/episode/{2}/credits'.format( 804 | self.series_id, self.season_number, self.episode_number), 805 | language=self._locale.language) 806 | 807 | def _populate_external_ids(self): 808 | return Request('tv/{0}/season/{1}/episode/{2}/external_ids'.format( 809 | self.series_id, self.season_number, self.episode_number)) 810 | 811 | def _populate_images(self): 812 | kwargs = {} 813 | if not self._locale.fallthrough: 814 | kwargs['language'] = self._locale.language 815 | return Request('tv/{0}/season/{1}/episode/{2}/images'.format( 816 | self.series_id, self.season_number, self.episode_number), **kwargs) 817 | 818 | cast = Datalist('cast', handler=Cast, 819 | poller=_populate_cast, sort='order') 820 | guest_stars = Datalist('guest_stars', handler=Cast, 821 | poller=_populate_cast, sort='order') 822 | crew = Datalist('crew', handler=Crew, poller=_populate_cast) 823 | imdb_id = Datapoint('imdb_id', poller=_populate_external_ids) 824 | freebase_id = Datapoint('freebase_id', poller=_populate_external_ids) 825 | freebase_mid = Datapoint('freebase_mid', poller=_populate_external_ids) 826 | tvdb_id = Datapoint('tvdb_id', poller=_populate_external_ids) 827 | tvrage_id = Datapoint('tvrage_id', poller=_populate_external_ids) 828 | stills = Datalist('stills', handler=Backdrop, poller=_populate_images, sort=True) 829 | 830 | class Season(NameRepr, Element): 831 | season_number = Datapoint('season_number', initarg=2) 832 | series_id = Datapoint('series_id', initarg=1) 833 | id = Datapoint('id') 834 | air_date = Datapoint('air_date', handler=process_date) 835 | poster = Datapoint('poster_path', handler=Poster, raw=False, default=None) 836 | overview = Datapoint('overview') 837 | name = Datapoint('name') 838 | episodes = Datadict('episodes', attr='episode_number', handler=Episode, 839 | passthrough={'series_id': 'series_id', 'season_number': 'season_number'}) 840 | 841 | def _populate(self): 842 | return Request('tv/{0}/season/{1}'.format(self.series_id, self.season_number), 843 | language=self._locale.language) 844 | 845 | def _populate_images(self): 846 | kwargs = {} 847 | if not self._locale.fallthrough: 848 | kwargs['language'] = self._locale.language 849 | return Request('tv/{0}/season/{1}/images'.format(self.series_id, self.season_number), **kwargs) 850 | 851 | def _populate_external_ids(self): 852 | return Request('tv/{0}/season/{1}/external_ids'.format(self.series_id, self.season_number)) 853 | 854 | posters = Datalist('posters', handler=Poster, 855 | poller=_populate_images, sort=True) 856 | 857 | freebase_id = Datapoint('freebase_id', poller=_populate_external_ids) 858 | freebase_mid = Datapoint('freebase_mid', poller=_populate_external_ids) 859 | tvdb_id = Datapoint('tvdb_id', poller=_populate_external_ids) 860 | tvrage_id = Datapoint('tvrage_id', poller=_populate_external_ids) 861 | 862 | class Series(NameRepr, Element): 863 | id = Datapoint('id', initarg=1) 864 | backdrop = Datapoint('backdrop_path', handler=Backdrop, raw=False, default=None) 865 | authors = Datalist('created_by', handler=Person) 866 | episode_run_times = Datalist('episode_run_time') 867 | first_air_date = Datapoint('first_air_date', handler=process_date) 868 | last_air_date = Datapoint('last_air_date', handler=process_date) 869 | genres = Datalist('genres', handler=Genre) 870 | homepage = Datapoint('homepage') 871 | in_production = Datapoint('in_production') 872 | languages = Datalist('languages') 873 | origin_countries = Datalist('origin_country') 874 | name = Datapoint('name') 875 | original_name = Datapoint('original_name') 876 | number_of_episodes = Datapoint('number_of_episodes') 877 | number_of_seasons = Datapoint('number_of_seasons') 878 | overview = Datapoint('overview') 879 | popularity = Datapoint('popularity') 880 | status = Datapoint('status') 881 | userrating = Datapoint('vote_average') 882 | votes = Datapoint('vote_count') 883 | poster = Datapoint('poster_path', handler=Poster, raw=False, default=None) 884 | networks = Datalist('networks', handler=Network) 885 | seasons = Datadict('seasons', attr='season_number', handler=Season, passthrough={'id': 'series_id'}) 886 | 887 | def _populate(self): 888 | return Request('tv/{0}'.format(self.id), 889 | language=self._locale.language) 890 | 891 | def _populate_cast(self): 892 | return Request('tv/{0}/credits'.format(self.id)) 893 | 894 | def _populate_images(self): 895 | kwargs = {} 896 | if not self._locale.fallthrough: 897 | kwargs['language'] = self._locale.language 898 | return Request('tv/{0}/images'.format(self.id), **kwargs) 899 | 900 | def _populate_external_ids(self): 901 | return Request('tv/{0}/external_ids'.format(self.id)) 902 | 903 | def _populate_keywords(self): 904 | return Request('tv/{0}/keywords'.format(self.id)) 905 | 906 | cast = Datalist('cast', handler=Cast, 907 | poller=_populate_cast, sort='order') 908 | crew = Datalist('crew', handler=Crew, poller=_populate_cast) 909 | backdrops = Datalist('backdrops', handler=Backdrop, 910 | poller=_populate_images, sort=True) 911 | posters = Datalist('posters', handler=Poster, 912 | poller=_populate_images, sort=True) 913 | keywords = Datalist('results', handler=Keyword, 914 | poller=_populate_keywords) 915 | 916 | imdb_id = Datapoint('imdb_id', poller=_populate_external_ids) 917 | freebase_id = Datapoint('freebase_id', poller=_populate_external_ids) 918 | freebase_mid = Datapoint('freebase_mid', poller=_populate_external_ids) 919 | tvdb_id = Datapoint('tvdb_id', poller=_populate_external_ids) 920 | tvrage_id = Datapoint('tvrage_id', poller=_populate_external_ids) 921 | 922 | def getSimilar(self): 923 | return self.similar 924 | 925 | @property 926 | def similar(self): 927 | res = SeriesSearchResult(Request( 928 | 'tv/{0}/similar'.format(self.id)), 929 | locale=self._locale) 930 | res._name = u'Similar to {0.name}'.format(self) 931 | return res 932 | --------------------------------------------------------------------------------