1from datetime import datetime, timedelta 2 3from dateutil.parser import parse as dateutil_parse 4from loguru import logger 5from sqlalchemy import ( 6 Boolean, 7 Column, 8 Date, 9 DateTime, 10 Float, 11 Integer, 12 String, 13 Table, 14 Unicode, 15 func, 16 or_, 17) 18from sqlalchemy.ext.associationproxy import association_proxy 19from sqlalchemy.orm import relation 20from sqlalchemy.schema import ForeignKey 21 22from flexget import db_schema, plugin 23from flexget.event import event 24from flexget.manager import Session 25from flexget.utils import requests 26from flexget.utils.database import json_synonym, with_session, year_property 27 28logger = logger.bind(name='api_tmdb') 29Base = db_schema.versioned_base('api_tmdb', 6) 30 31# This is a FlexGet API key 32API_KEY = 'bdfc018dbdb7c243dc7cb1454ff74b95' 33BASE_URL = 'https://api.themoviedb.org/3/' 34 35_tmdb_config = None 36 37 38class TMDBConfig(Base): 39 __tablename__ = 'tmdb_configuration' 40 41 id = Column(Integer, primary_key=True) 42 _configuration = Column('configuration', Unicode) 43 configuration = json_synonym('_configuration') 44 updated = Column(DateTime, default=datetime.now, nullable=False) 45 46 def __init__(self): 47 try: 48 configuration = tmdb_request('configuration') 49 except requests.RequestException as e: 50 raise LookupError('Error updating data from tmdb: %s' % e) 51 self.configuration = configuration 52 53 @property 54 def expired(self): 55 if self.updated < datetime.now() - timedelta(days=5): 56 return True 57 return False 58 59 60def get_tmdb_config(): 61 """Loads TMDB config and caches it in DB and memory""" 62 global _tmdb_config 63 if _tmdb_config is None: 64 logger.debug('no tmdb configuration in memory, checking cache') 65 with Session() as session: 66 config = session.query(TMDBConfig).first() 67 if not config or config.expired: 68 logger.debug('no config cached or config expired, refreshing') 69 config = session.merge(TMDBConfig()) 70 _tmdb_config = config.configuration 71 return _tmdb_config 72 73 74def tmdb_request(endpoint, **params): 75 params.setdefault('api_key', API_KEY) 76 full_url = BASE_URL + endpoint 77 return requests.get(full_url, params=params).json() 78 79 80@db_schema.upgrade('api_tmdb') 81def upgrade(ver, session): 82 if ver is None or ver <= 5: 83 raise db_schema.UpgradeImpossible 84 return ver 85 86 87# association tables 88genres_table = Table( 89 'tmdb_movie_genres', 90 Base.metadata, 91 Column('movie_id', Integer, ForeignKey('tmdb_movies.id')), 92 Column('genre_id', Integer, ForeignKey('tmdb_genres.id')), 93) 94Base.register_table(genres_table) 95 96 97class TMDBMovie(Base): 98 __tablename__ = 'tmdb_movies' 99 100 id = Column(Integer, primary_key=True, autoincrement=False, nullable=False) 101 imdb_id = Column(Unicode) 102 url = Column(Unicode) 103 name = Column(Unicode) 104 original_name = Column(Unicode) 105 alternative_name = Column(Unicode) 106 released = Column(Date) 107 year = year_property('released') 108 runtime = Column(Integer) 109 language = Column(Unicode) 110 overview = Column(Unicode) 111 tagline = Column(Unicode) 112 rating = Column(Float) 113 votes = Column(Integer) 114 popularity = Column(Float) 115 adult = Column(Boolean) 116 budget = Column(Integer) 117 revenue = Column(Integer) 118 homepage = Column(Unicode) 119 lookup_language = Column(String) 120 _posters = relation('TMDBPoster', backref='movie', cascade='all, delete, delete-orphan') 121 _backdrops = relation('TMDBBackdrop', backref='movie', cascade='all, delete, delete-orphan') 122 _genres = relation('TMDBGenre', secondary=genres_table, backref='movies') 123 genres = association_proxy('_genres', 'name') 124 updated = Column(DateTime, default=datetime.now, nullable=False) 125 126 def __init__(self, id, language): 127 """ 128 Looks up movie on tmdb and creates a new database model for it. 129 These instances should only be added to a session via `session.merge`. 130 """ 131 self.id = id 132 try: 133 movie = tmdb_request( 134 'movie/{}'.format(self.id), 135 append_to_response='alternative_titles', 136 language=language, 137 ) 138 except requests.RequestException as e: 139 raise LookupError('Error updating data from tmdb: %s' % e) 140 self.imdb_id = movie['imdb_id'] 141 self.name = movie['title'] 142 self.original_name = movie['original_title'] 143 if movie.get('release_date'): 144 self.released = dateutil_parse(movie['release_date']).date() 145 self.runtime = movie['runtime'] 146 self.language = movie['original_language'] 147 self.overview = movie['overview'] 148 self.tagline = movie['tagline'] 149 self.rating = movie['vote_average'] 150 self.votes = movie['vote_count'] 151 self.popularity = movie['popularity'] 152 self.adult = movie['adult'] 153 self.budget = movie['budget'] 154 self.revenue = movie['revenue'] 155 self.homepage = movie['homepage'] 156 self.lookup_language = language 157 try: 158 self.alternative_name = movie['alternative_titles']['titles'][0]['title'] 159 except (KeyError, IndexError): 160 # No alternate titles 161 self.alternative_name = None 162 self._genres = [TMDBGenre(**g) for g in movie['genres']] 163 self.updated = datetime.now() 164 165 def get_images(self): 166 logger.debug('images for movie {} not found in DB, fetching from TMDB', self.name) 167 try: 168 images = tmdb_request('movie/{}/images'.format(self.id)) 169 except requests.RequestException as e: 170 raise LookupError('Error updating data from tmdb: %s' % e) 171 172 self._posters = [TMDBPoster(movie_id=self.id, **p) for p in images['posters']] 173 self._backdrops = [TMDBBackdrop(movie_id=self.id, **b) for b in images['backdrops']] 174 175 @property 176 def posters(self): 177 if not self._posters: 178 self.get_images() 179 return self._posters 180 181 @property 182 def backdrops(self): 183 if not self._backdrops: 184 self.get_images() 185 return self._backdrops 186 187 def to_dict(self): 188 return { 189 'id': self.id, 190 'imdb_id': self.imdb_id, 191 'name': self.name, 192 'original_name': self.original_name, 193 'alternative_name': self.alternative_name, 194 'year': self.year, 195 'runtime': self.runtime, 196 'language': self.language, 197 'overview': self.overview, 198 'tagline': self.tagline, 199 'rating': self.rating, 200 'votes': self.votes, 201 'popularity': self.popularity, 202 'adult': self.adult, 203 'budget': self.budget, 204 'revenue': self.revenue, 205 'homepage': self.homepage, 206 'genres': [g for g in self.genres], 207 'updated': self.updated, 208 'lookup_language': self.lookup_language, 209 } 210 211 212class TMDBGenre(Base): 213 __tablename__ = 'tmdb_genres' 214 215 id = Column(Integer, primary_key=True, autoincrement=False) 216 name = Column(Unicode, nullable=False) 217 218 219class TMDBImage(Base): 220 __tablename__ = 'tmdb_images' 221 222 id = Column(Integer, primary_key=True, autoincrement=True) 223 movie_id = Column(Integer, ForeignKey('tmdb_movies.id')) 224 file_path = Column(Unicode) 225 width = Column(Integer) 226 height = Column(Integer) 227 aspect_ratio = Column(Float) 228 vote_average = Column(Float) 229 vote_count = Column(Integer) 230 iso_639_1 = Column(Unicode) 231 type = Column(Unicode) 232 __mapper_args__ = {'polymorphic_on': type} 233 234 def url(self, size): 235 return get_tmdb_config()['images']['base_url'] + size + self.file_path 236 237 def to_dict(self): 238 return { 239 'id': self.id, 240 'urls': { 241 size: self.url(size) for size in get_tmdb_config()['images'][self.type + '_sizes'] 242 }, 243 'movie_id': self.movie_id, 244 'file_path': self.file_path, 245 'width': self.width, 246 'height': self.height, 247 'aspect_ratio': self.aspect_ratio, 248 'vote_average': self.vote_average, 249 'vote_count': self.vote_count, 250 'language_code': self.iso_639_1, 251 } 252 253 254class TMDBPoster(TMDBImage): 255 __mapper_args__ = {'polymorphic_identity': 'poster'} 256 257 258class TMDBBackdrop(TMDBImage): 259 __mapper_args__ = {'polymorphic_identity': 'backdrop'} 260 261 262class TMDBSearchResult(Base): 263 __tablename__ = 'tmdb_search_results' 264 265 search = Column(Unicode, primary_key=True) 266 movie_id = Column(Integer, ForeignKey('tmdb_movies.id'), nullable=True) 267 movie = relation(TMDBMovie) 268 269 def __init__(self, search, movie_id=None, movie=None): 270 self.search = search.lower() 271 if movie_id: 272 self.movie_id = movie_id 273 if movie: 274 self.movie = movie 275 276 277class ApiTmdb: 278 """Does lookups to TMDb and provides movie information. Caches lookups.""" 279 280 @staticmethod 281 @with_session 282 def lookup( 283 title=None, 284 year=None, 285 tmdb_id=None, 286 imdb_id=None, 287 smart_match=None, 288 only_cached=False, 289 session=None, 290 language='en', 291 ): 292 """ 293 Do a lookup from TMDb for the movie matching the passed arguments. 294 295 Any combination of criteria can be passed, the most specific criteria specified will be used. 296 297 :param int tmdb_id: tmdb_id of desired movie 298 :param unicode imdb_id: imdb_id of desired movie 299 :param unicode title: title of desired movie 300 :param int year: release year of desired movie 301 :param unicode smart_match: attempt to clean and parse title and year from a string 302 :param bool only_cached: if this is specified, an online lookup will not occur if the movie is not in the cache 303 session: optionally specify a session to use, if specified, returned Movie will be live in that session 304 :param language: Specify title lookup language 305 :param session: sqlalchemy Session in which to do cache lookups/storage. commit may be called on a passed in 306 session. If not supplied, a session will be created automatically. 307 308 :return: The :class:`TMDBMovie` object populated with data from tmdb 309 310 :raises: :class:`LookupError` if a match cannot be found or there are other problems with the lookup 311 """ 312 313 # Populate tmdb config 314 get_tmdb_config() 315 316 if smart_match and not (title or tmdb_id or imdb_id): 317 # If smart_match was specified, parse it into a title and year 318 title_parser = plugin.get('parsing', 'api_tmdb').parse_movie(smart_match) 319 title = title_parser.name 320 year = title_parser.year 321 if not (title or tmdb_id or imdb_id): 322 raise LookupError('No criteria specified for TMDb lookup') 323 id_str = '<title={}, year={}, tmdb_id={}, imdb_id={}>'.format( 324 title, year, tmdb_id, imdb_id 325 ) 326 327 logger.debug('Looking up TMDb information for {}', id_str) 328 movie = None 329 if imdb_id or tmdb_id: 330 ors = [] 331 if tmdb_id: 332 ors.append(TMDBMovie.id == tmdb_id) 333 if imdb_id: 334 ors.append(TMDBMovie.imdb_id == imdb_id) 335 movie = session.query(TMDBMovie).filter(or_(*ors)).first() 336 elif title: 337 movie_filter = session.query(TMDBMovie).filter( 338 func.lower(TMDBMovie.name) == title.lower() 339 ) 340 if year: 341 movie_filter = movie_filter.filter(TMDBMovie.year == year) 342 movie = movie_filter.first() 343 if not movie: 344 search_string = title + ' ({})'.format(year) if year else title 345 found = ( 346 session.query(TMDBSearchResult) 347 .filter(TMDBSearchResult.search == search_string.lower()) 348 .first() 349 ) 350 if found and found.movie: 351 movie = found.movie 352 if movie: 353 # Movie found in cache, check if cache has expired. 354 refresh_time = timedelta(days=2) 355 if movie.released: 356 if movie.released > datetime.now().date() - timedelta(days=7): 357 # Movie is less than a week old, expire after 1 day 358 refresh_time = timedelta(days=1) 359 else: 360 age_in_years = (datetime.now().date() - movie.released).days / 365 361 refresh_time += timedelta(days=age_in_years * 5) 362 if movie.updated < datetime.now() - refresh_time and not only_cached: 363 logger.debug( 364 'Cache has expired for {}, attempting to refresh from TMDb.', movie.name 365 ) 366 try: 367 updated_movie = TMDBMovie(id=movie.id, language=language) 368 except LookupError: 369 logger.error( 370 'Error refreshing movie details from TMDb, cached info being used.' 371 ) 372 else: 373 movie = session.merge(updated_movie) 374 else: 375 logger.debug('Movie {} information restored from cache.', movie.name) 376 else: 377 if only_cached: 378 raise LookupError('Movie %s not found from cache' % id_str) 379 # There was no movie found in the cache, do a lookup from tmdb 380 logger.verbose('Searching from TMDb {}', id_str) 381 if imdb_id and not tmdb_id: 382 try: 383 result = tmdb_request('find/{}'.format(imdb_id), external_source='imdb_id') 384 except requests.RequestException as e: 385 raise LookupError('Error searching imdb id on tmdb: {}'.format(e)) 386 if result['movie_results']: 387 tmdb_id = result['movie_results'][0]['id'] 388 if not tmdb_id: 389 search_string = title + ' ({})'.format(year) if year else title 390 search_params = {'query': title, 'language': language} 391 if year: 392 search_params['year'] = year 393 try: 394 results = tmdb_request('search/movie', **search_params) 395 except requests.RequestException as e: 396 raise LookupError( 397 'Error searching for tmdb item {}: {}'.format(search_string, e) 398 ) 399 if not results['results']: 400 raise LookupError('No results for {} from tmdb'.format(search_string)) 401 tmdb_id = results['results'][0]['id'] 402 session.add(TMDBSearchResult(search=search_string, movie_id=tmdb_id)) 403 if tmdb_id: 404 movie = TMDBMovie(id=tmdb_id, language=language) 405 movie = session.merge(movie) 406 else: 407 raise LookupError('Unable to find movie on tmdb: {}'.format(id_str)) 408 409 return movie 410 411 412@event('plugin.register') 413def register_plugin(): 414 plugin.register(ApiTmdb, 'api_tmdb', api_ver=2, interfaces=[]) 415