Coverage for mymeco/tmdb/utils.py: 83%
64 statements
« prev ^ index » next coverage.py v7.10.7, created at 2026-01-15 20:56 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2026-01-15 20:56 +0000
1# coding: utf-8
2"""Utility module to connect and retrieve information from TMDb."""
3import os
4import typing
5import logging
6import json
7import datetime
8import urllib.parse
9import requests
12class MovieSearch(typing.NamedTuple):
13 """Basic information on a movie."""
15 title: str
16 original_title: str
17 release_date: typing.Union[datetime.date, None]
18 overview: typing.Optional[str]
19 poster_path: typing.Optional[str]
20 id: int
23class Tmdb:
24 """Main class to request data on TMDb API v3."""
26 _log: logging.Logger = logging.getLogger(__file__)
28 __api_version: int = 3
29 __base_url: str = 'https://api.themoviedb.org/' + str(__api_version)
30 __application_json: str = 'application/json; charset=utf-8'
32 def __build_request_url(self,
33 route: str,
34 **kwargs: typing.Optional[str]) -> str:
35 kwargs['api_key'] = self.__apikey
36 kwargs['language'] = self.__language
37 api_url = (
38 self.__base_url + route + '?' + urllib.parse.urlencode(kwargs)
39 )
40 self._log.debug(api_url)
41 return api_url
43 def __init__(self,
44 apikey: str,
45 lang: typing.Optional[str] = None):
46 """
47 Build a new TMDb client instance.
49 :param apikey: TMDb API key. A new API key could be obtains through
50 https://www.themoviedb.org/settings/api.
51 :param lang: Optional request language. If not set, class will
52 fallback to language as described by *LANG* environment variable.
53 """
54 self.__apikey = apikey
55 if lang is not None:
56 self.__language = lang
57 else:
58 self.__language = os.environ.get('LANG', default='en_US.utf-8')
59 self.__language = self.__language.split('.')[0].replace('_', '-')
60 self.__configuration = requests.get(
61 self.__build_request_url('/configuration'),
62 headers={
63 'Content-Type': self.__application_json
64 },
65 timeout=10
66 ).json()
67 self._log.debug(json.dumps(self.__configuration, indent=4))
69 def __repr__(self) -> str:
70 """Get a representation of instance."""
71 return (
72 f'{type(self).__name__}({self.__apikey!r}, '
73 f'lang={self.__language!r})'
74 )
76 def search_movie(
77 self,
78 title: str,
79 year: typing.Union[int, None] = None,
80 page: int = 1
81 ) -> typing.Generator[MovieSearch, None, None]:
82 """
83 Search a movie, given its title and optionally its release year.
85 :param title: Movie title, could be original title or localized title.
86 Could also be a partial title.
87 :param year: Movie release date.
88 :param page: Requested page.
90 :return: List generator with all matched movie. Each result consists in
91 a named tuple with the following keys:
92 - *title*: localized movie title
93 - *original_title*: original movie title
94 - *release_date*: movie release date
95 - *overview*: short movie summary
96 - *poster_path*: URL to retrieve small poster
97 - *id*: TMDb movie ID, useful to retrieve full movie details
99 """
100 results = requests.get(
101 self.__build_request_url('/search/movie',
102 query=title, page=str(page)),
103 headers={
104 'Content-Type': self.__application_json
105 },
106 timeout=10
107 ).json()
109 movie: typing.Mapping[str, str]
110 for movie in results['results']:
111 self._log.debug(json.dumps(movie, indent=4))
113 # Compute release date and filter result if needed
114 release_date: typing.Union[datetime.date, None]
115 try:
116 release_date = datetime.date.fromisoformat(
117 movie.get('release_date', '0000-00-00')
118 )
119 except ValueError:
120 self._log.warning('No release_date given, ignore')
121 release_date = None
123 if (year is not None and (
124 release_date is None or
125 year != release_date.year
126 )):
127 continue
129 # Compute title
130 out_title: str
131 out_title = movie.get('title', '')
133 # Compute original title
134 original_title: str
135 original_title = movie.get('original_title', title)
137 # Compute poster path
138 self._update_path(movie, 'poster_path', 'poster_sizes')
139 poster: typing.Optional[str]
140 poster = movie.get('poster_path', None)
142 # Compute overview
143 overview: typing.Optional[str]
144 overview = movie.get('overview', None)
146 # Compute movie id
147 movie_id: int
148 movie_id = int(movie.get('id', 0))
150 yield MovieSearch(
151 out_title,
152 original_title,
153 release_date,
154 overview,
155 poster,
156 movie_id
157 )
159 if page < results['total_pages']: 159 ↛ 160line 159 didn't jump to line 160 because the condition on line 159 was never true
160 yield from self.search_movie(title, year, page + 1)
162 def _update_path(self, data, key, base):
163 path: typing.Optional[str]
164 path = data.get(key, None)
165 if path is not None:
166 data[key] = (
167 self.__configuration['images']['secure_base_url'] +
168 self.__configuration['images'][base][-1] +
169 path
170 )
172 def get_movie(self, movie_id: int):
173 """Get all information about a given movie."""
174 result = requests.get(
175 self.__build_request_url(f'/movie/{movie_id}'),
176 headers={
177 'Content-Type': self.__application_json,
178 },
179 timeout=10
180 ).json()
182 self._update_path(result, 'poster_path', 'poster_sizes')
184 self._log.debug(json.dumps(result, indent=4))
185 return result
187 def get_credits(self, movie_id: int):
188 """Get cast and crew for a movie."""
189 result = requests.get(
190 self.__build_request_url(f'/movie/{movie_id}/credits'),
191 headers={
192 'Content-Type': self.__application_json,
193 },
194 timeout=10
195 ).json()
197 for cast in result['cast']:
198 self._update_path(cast, 'profile_path', 'profile_sizes')
200 self._log.debug(json.dumps(result, indent=4))
201 return result