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