imdbpie 5.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
imdbpie/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ # -*- coding: utf-8 -*-
2
+ from .imdbpie import Imdb # noqa
3
+ from .exceptions import ImdbAPIError # noqa
4
+ from .facade import ImdbFacade # noqa
imdbpie/auth.py ADDED
@@ -0,0 +1,99 @@
1
+ # -*- coding: utf-8 -*-
2
+ from __future__ import absolute_import, unicode_literals
3
+
4
+ import json
5
+ import tempfile
6
+ from datetime import datetime
7
+ from urllib.parse import urlparse, parse_qs
8
+ import diskcache_community
9
+ import requests
10
+ from botocore.auth import SigV4Auth
11
+ from botocore.awsrequest import AWSRequest
12
+ from botocore.credentials import Credentials
13
+ from dateutil.parser import parse
14
+ from dateutil.tz import tzutc
15
+
16
+ from .constants import APP_KEY, HOST, USER_AGENT, BASE_URI
17
+
18
+
19
+ def _get_credentials():
20
+ url = '{0}/authentication/credentials/temporary/ios82'.format(BASE_URI)
21
+ response = requests.post(
22
+ url, json={'appKey': APP_KEY}, headers={'User-Agent': USER_AGENT}
23
+ )
24
+ response.raise_for_status()
25
+ return json.loads(response.content.decode('utf8'))['resource']
26
+
27
+
28
+ class Auth(object):
29
+ SOON_EXPIRES_SECONDS = 60
30
+ _CREDS_STORAGE_KEY = 'imdbpie-credentials'
31
+
32
+ def __init__(self):
33
+ self._cachedir = tempfile.gettempdir()
34
+
35
+ def _cache(self):
36
+ return diskcache_community.Cache(
37
+ directory=self._cachedir,
38
+ disk=diskcache_community.SafeDisk.with_options(on_error='miss'),
39
+ )
40
+
41
+ def _get_creds(self):
42
+ with self._cache() as cache:
43
+ return cache.get(self._CREDS_STORAGE_KEY)
44
+
45
+ def _set_creds(self, creds):
46
+ with self._cache() as cache:
47
+ cache[self._CREDS_STORAGE_KEY] = creds
48
+ return creds
49
+
50
+ def clear_cached_credentials(self):
51
+ with self._cache() as cache:
52
+ cache.delete(self._CREDS_STORAGE_KEY)
53
+
54
+ def _creds_soon_expiring(self):
55
+ creds = self._get_creds()
56
+ if not creds:
57
+ return creds, True
58
+ expires_at = parse(creds['expirationTimeStamp'])
59
+ now = datetime.now(tzutc())
60
+ if now < expires_at:
61
+ time_diff = expires_at - now
62
+ if time_diff.total_seconds() < self.SOON_EXPIRES_SECONDS:
63
+ # creds will soon expire, so renew them
64
+ return creds, True
65
+ return creds, False
66
+ else:
67
+ return creds, True
68
+
69
+ def get_auth_headers(self, url_path):
70
+ creds, soon_expires = self._creds_soon_expiring()
71
+ if soon_expires:
72
+ creds = self._set_creds(creds=_get_credentials())
73
+ credentials = Credentials(
74
+ access_key=creds['accessKeyId'],
75
+ secret_key=creds['secretAccessKey'],
76
+ token=creds['sessionToken'],
77
+ )
78
+
79
+ parsed_url = urlparse(url_path)
80
+ params = {
81
+ key: val[0] for key, val in parse_qs(parsed_url.query).items()
82
+ }
83
+ req = requests.Request(
84
+ 'GET',
85
+ f'https://{HOST}{parsed_url.path}',
86
+ params=params,
87
+ data='',
88
+ headers={},
89
+ )
90
+ prepared_request = req.prepare()
91
+ prepared_request.headers['User-Agent'] = USER_AGENT
92
+ aws_request = AWSRequest(
93
+ method=prepared_request.method,
94
+ url=prepared_request.url,
95
+ data=prepared_request.body,
96
+ headers=prepared_request.headers,
97
+ )
98
+ SigV4Auth(credentials, 'imdbapi', 'us-east-1').add_auth(aws_request)
99
+ return aws_request.prepare().headers
imdbpie/constants.py ADDED
@@ -0,0 +1,8 @@
1
+ # -*- coding: utf-8 -*-
2
+ from __future__ import absolute_import, unicode_literals
3
+
4
+ HOST = 'api.imdbws.com'
5
+ BASE_URI = 'https://{0}'.format(HOST)
6
+ SEARCH_BASE_URI = 'https://v2.sg.media-imdb.com'
7
+ USER_AGENT = 'IMDb/8.3.1 (iPhone9,4; iOS 11.2.1)'
8
+ APP_KEY = '76a6cc20-6073-4290-8a2c-951b4580ae4a'
imdbpie/exceptions.py ADDED
@@ -0,0 +1,3 @@
1
+ # -*- coding: utf-8 -*-
2
+ class ImdbAPIError(Exception):
3
+ pass
imdbpie/facade.py ADDED
@@ -0,0 +1,430 @@
1
+ # -*- coding: utf-8 -*-
2
+ from __future__ import absolute_import, unicode_literals
3
+
4
+ import re
5
+
6
+ from dateutil.parser import parse
7
+
8
+ from .imdbpie import Imdb
9
+ from .objects import (
10
+ Award,
11
+ FilmingLocation,
12
+ Image,
13
+ Name,
14
+ NameSearchResult,
15
+ Title,
16
+ TitleBusiness,
17
+ TitleCertificate,
18
+ TitleEpisodes,
19
+ TitleName,
20
+ TitleRelease,
21
+ TitleSearchResult,
22
+ TitleVideo,
23
+ WatchOption,
24
+ )
25
+
26
+ REGEX_IMDB_ID = re.compile(r'([a-zA-Z]{2}[0-9]{7,8})')
27
+
28
+
29
+ class ImdbFacade(object):
30
+ def __init__(self, client=None):
31
+ self._client = client or Imdb()
32
+
33
+ def get_title(self, imdb_id):
34
+ title_data, title_aux_data = self._get_title_data(imdb_id=imdb_id)
35
+ try:
36
+ runtime = title_aux_data['runningTimes'][0]['timeMinutes']
37
+ except (KeyError, IndexError):
38
+ runtime = None
39
+ try:
40
+ episodes = TitleEpisodes(facade=self, imdb_id=imdb_id)
41
+ except LookupError:
42
+ episodes = ()
43
+ try:
44
+ season = title_aux_data['season']
45
+ episode = title_aux_data['episode']
46
+ except KeyError:
47
+ season = None
48
+ episode = None
49
+ return Title(
50
+ season=season,
51
+ episode=episode,
52
+ episodes=episodes,
53
+ runtime=runtime,
54
+ **title_data
55
+ )
56
+
57
+ def get_name(self, imdb_id):
58
+ name_data = self._client.get_name(imdb_id=imdb_id)
59
+ base = name_data['base']
60
+ name = base['name']
61
+ imdb_id = self._parse_id(base['id'])
62
+
63
+ try:
64
+ image_data = base['image']
65
+ image = Image(
66
+ url=image_data['url'],
67
+ height=image_data['height'],
68
+ width=image_data['width'],
69
+ )
70
+ except KeyError:
71
+ image = None
72
+
73
+ gender = (base.get('gender') or '').lower() or None
74
+ birth_date = base.get('birthDate')
75
+ date_of_birth = parse(birth_date).date() if birth_date else None
76
+ birth_place = base.get('birthPlace')
77
+ try:
78
+ bios = tuple(b['text'] for b in base['miniBios'])
79
+ except KeyError:
80
+ bios = ()
81
+
82
+ filmography_data = self._client.get_name_filmography(imdb_id)
83
+ filmography = tuple(
84
+ self._parse_id(f['id']) for f in filmography_data['filmography']
85
+ )
86
+ known_for = tuple(
87
+ self._parse_id(item['id'])
88
+ for item in (name_data.get('knownFor') or ())
89
+ if item.get('id')
90
+ )
91
+ return Name(
92
+ name=name,
93
+ imdb_id=imdb_id,
94
+ date_of_birth=date_of_birth,
95
+ gender=gender,
96
+ birth_place=birth_place,
97
+ bios=bios,
98
+ image=image,
99
+ filmography=filmography,
100
+ jobs=tuple(name_data.get('jobs') or ()),
101
+ known_for=known_for,
102
+ real_name=base.get('realName'),
103
+ height_centimeters=base.get('heightCentimeters'),
104
+ )
105
+
106
+ def search_for_name(self, query):
107
+ results = []
108
+ for result in self._client.search_for_name(query):
109
+ result = NameSearchResult(
110
+ imdb_id=result['imdb_id'],
111
+ name=result['name'],
112
+ )
113
+ results.append(result)
114
+ return tuple(results)
115
+
116
+ def search_for_title(self, query):
117
+ results = []
118
+ for result in self._client.search_for_title(query):
119
+ if result['year']:
120
+ year = int(result['year'])
121
+ else:
122
+ year = None
123
+ result = TitleSearchResult(
124
+ imdb_id=result['imdb_id'],
125
+ title=result['title'],
126
+ type=result['type'],
127
+ year=year,
128
+ )
129
+ results.append(result)
130
+ return tuple(results)
131
+
132
+ def _get_writers(self, top_crew_data):
133
+ return tuple(
134
+ TitleName(
135
+ name=i['name'],
136
+ job=i.get('job'),
137
+ category=i.get('category'),
138
+ imdb_id=self._parse_id(i['id']),
139
+ )
140
+ for i in top_crew_data['writers']
141
+ )
142
+
143
+ def _get_stars(self, principals_data):
144
+ return tuple(
145
+ TitleName(
146
+ name=i['name'],
147
+ job=i.get('job'),
148
+ characters=tuple(i.get('characters', ())),
149
+ category=i.get('category'),
150
+ imdb_id=self._parse_id(i['id']),
151
+ )
152
+ for i in principals_data
153
+ )
154
+
155
+ def _get_creators(self, top_crew_data):
156
+ return tuple(
157
+ TitleName(
158
+ name=i['name'],
159
+ job=i.get('job'),
160
+ category=i.get('category'),
161
+ imdb_id=self._parse_id(i['id']),
162
+ )
163
+ for i in top_crew_data['writers']
164
+ if i.get('job') == 'creator'
165
+ )
166
+
167
+ def _get_directors(self, top_crew_data):
168
+ return tuple(
169
+ TitleName(
170
+ name=i['name'],
171
+ job=i.get('job'),
172
+ category=i.get('category'),
173
+ imdb_id=self._parse_id(i['id']),
174
+ )
175
+ for i in top_crew_data['directors']
176
+ )
177
+
178
+ def _get_credits(self, credits_data):
179
+ credits = []
180
+ for category in credits_data.get('credits', ()):
181
+ for item in credits_data['credits'][category]:
182
+ credits.append(
183
+ TitleName(
184
+ name=item['name'],
185
+ category=item.get('category'),
186
+ job=item.get('job'),
187
+ imdb_id=self._parse_id(item['id']),
188
+ )
189
+ )
190
+ return tuple(credits)
191
+
192
+ def _parse_id(self, string):
193
+ match = REGEX_IMDB_ID.search(string or '')
194
+ if not match:
195
+ raise ValueError('invalid imdb id: {0!r}'.format(string))
196
+ return match.group(1)
197
+
198
+ def _get_title_data(self, imdb_id):
199
+ base_title_data = self._client.get_title(imdb_id=imdb_id)
200
+ top_crew_data = self._client.get_title_top_crew(imdb_id=imdb_id)
201
+ title_aux_data = self._client.get_title_auxiliary(imdb_id=imdb_id)
202
+ credits_data = self._client.get_title_credits(imdb_id=imdb_id)
203
+
204
+ title = base_title_data['base']['title']
205
+ year = base_title_data['base'].get('year')
206
+ try:
207
+ rating = float(base_title_data['ratings']['rating'])
208
+ except KeyError:
209
+ rating = None
210
+ type_ = base_title_data['base']['titleType'].lower()
211
+
212
+ try:
213
+ releases_data = self._client.get_title_releases(imdb_id=imdb_id)
214
+ except LookupError:
215
+ release_date = None
216
+ releases = ()
217
+ else:
218
+ release_date = parse(releases_data['releases'][0]['date']).date()
219
+ releases = tuple(
220
+ TitleRelease(date=parse(r['date']).date(), region=r['region'])
221
+ for r in releases_data['releases']
222
+ )
223
+
224
+ try:
225
+ rating_count = base_title_data['ratings']['ratingCount']
226
+ except KeyError:
227
+ rating_count = 0
228
+
229
+ try:
230
+ plot_outline = base_title_data['plot']['outline']['text']
231
+ except KeyError:
232
+ plot_outline = None
233
+
234
+ writers = self._get_writers(top_crew_data)
235
+ directors = self._get_directors(top_crew_data)
236
+ creators = self._get_creators(top_crew_data)
237
+ try:
238
+ genres = tuple(g.lower() for g in title_aux_data.get('genres'))
239
+ except TypeError:
240
+ genres = ()
241
+ credits = self._get_credits(credits_data)
242
+
243
+ try:
244
+ certification = title_aux_data['certificate']['certificate']
245
+ except (KeyError, TypeError):
246
+ certification = None
247
+ stars = self._get_stars(title_aux_data['principals'])
248
+ try:
249
+ image_data = title_aux_data['image']
250
+ image = Image(
251
+ url=image_data['url'],
252
+ height=image_data['height'],
253
+ width=image_data['width'],
254
+ )
255
+ except KeyError:
256
+ image = None
257
+ metacritic_info = title_aux_data.get('metacriticInfo') or {}
258
+ certificate_data = title_aux_data.get('certificate') or {}
259
+ return (
260
+ dict(
261
+ imdb_id=imdb_id,
262
+ title=title,
263
+ year=year,
264
+ rating=rating,
265
+ type=type_,
266
+ release_date=release_date,
267
+ releases=releases,
268
+ plot_outline=plot_outline,
269
+ rating_count=rating_count,
270
+ writers=writers,
271
+ directors=directors,
272
+ creators=creators,
273
+ genres=genres,
274
+ credits=credits,
275
+ certification=certification,
276
+ image=image,
277
+ stars=stars,
278
+ metacritic=metacritic_info.get('metaScore'),
279
+ awards_summary=title_aux_data.get('awards'),
280
+ spoken_languages=tuple(
281
+ title_aux_data.get('spokenLanguages') or ()
282
+ ),
283
+ countries=tuple(title_aux_data.get('origins') or ()),
284
+ alternate_titles=tuple(
285
+ title_aux_data.get('alternateTitlesSample') or ()
286
+ ),
287
+ alternate_titles_count=(
288
+ title_aux_data.get('alternateTitlesCount') or 0
289
+ ),
290
+ top_rank=title_aux_data.get('topRank'),
291
+ trailer=self._parse_trailer(title_aux_data.get('videos')),
292
+ production_status=title_aux_data.get('productionStatus'),
293
+ certificate_reason=certificate_data.get('ratingReason'),
294
+ ),
295
+ title_aux_data,
296
+ )
297
+
298
+ @staticmethod
299
+ def _parse_trailer(videos_data):
300
+ trailer = (videos_data or {}).get('mainTrailer')
301
+ if not trailer:
302
+ return None
303
+ url = None
304
+ for encoding in trailer.get('encodings') or ():
305
+ if encoding.get('play'):
306
+ url = encoding['play']
307
+ break
308
+ return TitleVideo(
309
+ content_type=trailer.get('contentType'),
310
+ description=trailer.get('description'),
311
+ url=url,
312
+ duration_seconds=trailer.get('durationSeconds'),
313
+ )
314
+
315
+ def get_title_watch_options(self, imdb_id):
316
+ data = self._client.get_title_watch_options(imdb_id=imdb_id)
317
+ options = []
318
+ for group in data.get('optionGroups') or ():
319
+ category = (group.get('displayName') or '').lower()
320
+ for option in group.get('watchOptions') or ():
321
+ link = option.get('link') or {}
322
+ provider = option.get('provider') or {}
323
+ options.append(
324
+ WatchOption(
325
+ provider=option.get('primaryText'),
326
+ category=category,
327
+ url=link.get('uri'),
328
+ provider_id=provider.get('providerId'),
329
+ notes=option.get('secondaryText'),
330
+ )
331
+ )
332
+ return tuple(options)
333
+
334
+ def get_title_business(self, imdb_id):
335
+ data = self._client.get_title_business(imdb_id=imdb_id)
336
+ gross = data.get('gross') or {}
337
+ aggregations = gross.get('aggregations') or []
338
+ worldwide_gross = None
339
+ for aggregation in aggregations:
340
+ if aggregation.get('area') == 'World-wide':
341
+ worldwide_gross = aggregation.get('total')
342
+ break
343
+ opening_weekend = data.get('openingWeekendGross') or {}
344
+ return TitleBusiness(
345
+ budget=data.get('budget'),
346
+ worldwide_gross=worldwide_gross,
347
+ gross=tuple(gross.get('regional') or ()),
348
+ opening_weekend_gross=tuple(opening_weekend.get('regional') or ()),
349
+ )
350
+
351
+ def get_title_keywords(self, imdb_id):
352
+ data = self._client.get_title_keywords(imdb_id=imdb_id)
353
+ return tuple(data.get('keywords') or ())
354
+
355
+ def get_title_certificates(self, imdb_id):
356
+ data = self._client.get_title_certificates(imdb_id=imdb_id)
357
+ certificates = []
358
+ for country, entries in (data.get('certificates') or {}).items():
359
+ for entry in entries or ():
360
+ certificates.append(
361
+ TitleCertificate(
362
+ country=country,
363
+ certificate=entry.get('certificate'),
364
+ attributes=tuple(entry.get('attributes') or ()),
365
+ )
366
+ )
367
+ return tuple(certificates)
368
+
369
+ def get_title_filming_locations(self, imdb_id):
370
+ data = self._client.get_title_filming_locations(imdb_id=imdb_id)
371
+ return tuple(
372
+ FilmingLocation(
373
+ location=item.get('location'),
374
+ attributes=tuple(item.get('attributes') or ()),
375
+ notes=tuple(item.get('extras') or ()),
376
+ )
377
+ for item in (data.get('locations') or ())
378
+ )
379
+
380
+ def get_title_crazy_credits(self, imdb_id):
381
+ data = self._client.get_title_crazy_credits(imdb_id=imdb_id)
382
+ return tuple(
383
+ item['text']
384
+ for item in (data.get('crazycredits') or ())
385
+ if item.get('text')
386
+ )
387
+
388
+ def get_title_principals(self, imdb_id):
389
+ data = self._client.get_title_principals(imdb_id=imdb_id)
390
+ return tuple(
391
+ TitleName(
392
+ name=item.get('name'),
393
+ category=item.get('category'),
394
+ imdb_id=(
395
+ self._parse_id(item['id']) if item.get('id') else None
396
+ ),
397
+ job=item.get('job'),
398
+ characters=tuple(item.get('characters') or ()),
399
+ )
400
+ for item in (data.get('principals') or ())
401
+ )
402
+
403
+ def get_name_known_for(self, imdb_id):
404
+ data = self._client.get_name_known_for(imdb_id=imdb_id)
405
+ return tuple(
406
+ self._parse_id(item['id'])
407
+ for item in (data.get('knownFor') or ())
408
+ if item.get('id')
409
+ )
410
+
411
+ def get_name_awards(self, imdb_id):
412
+ data = self._client.get_name_awards(imdb_id=imdb_id)
413
+ awards = []
414
+ for item in data.get('awards') or ():
415
+ titles = (item.get('nominations') or {}).get('titles') or ()
416
+ awards.append(
417
+ Award(
418
+ name=item.get('awardName'),
419
+ category=item.get('category'),
420
+ year=item.get('year'),
421
+ won=bool(item.get('isWinner')),
422
+ event_name=item.get('eventName'),
423
+ title_ids=tuple(
424
+ self._parse_id(title['id'])
425
+ for title in titles
426
+ if title.get('id')
427
+ ),
428
+ )
429
+ )
430
+ return tuple(awards)
imdbpie/imdbpie.py ADDED
@@ -0,0 +1,430 @@
1
+ # -*- coding: utf-8 -*-
2
+ from __future__ import absolute_import, unicode_literals
3
+
4
+ import copy
5
+ import re
6
+ import json
7
+ import time
8
+ from datetime import date
9
+ import tempfile
10
+ import logging
11
+
12
+ from text_unidecode import unidecode
13
+ import requests
14
+ from urllib.parse import urlencode, urljoin, quote
15
+ from http import client as httplib
16
+
17
+ from .constants import BASE_URI, SEARCH_BASE_URI
18
+ from .auth import Auth
19
+ from .exceptions import ImdbAPIError
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # client method name -> api path
24
+ _SIMPLE_GET_ENDPOINTS = {
25
+ 'get_name_images': '/name/{imdb_id}/images',
26
+ 'get_name_videos': '/name/{imdb_id}/videos',
27
+ 'get_title_metacritic_reviews': '/title/{imdb_id}/metacritic',
28
+ 'get_title_user_reviews': '/title/{imdb_id}/userreviews',
29
+ 'get_title_videos': '/title/{imdb_id}/videos',
30
+ 'get_title_images': '/title/{imdb_id}/images',
31
+ 'get_title_companies': '/title/{imdb_id}/companies',
32
+ 'get_title_technical': '/title/{imdb_id}/technical',
33
+ 'get_title_trivia': '/title/{imdb_id}/trivia',
34
+ 'get_title_goofs': '/title/{imdb_id}/goofs',
35
+ 'get_title_soundtracks': '/title/{imdb_id}/soundtracks',
36
+ 'get_title_news': '/title/{imdb_id}/news',
37
+ 'get_title_plot': '/title/{imdb_id}/plot',
38
+ 'get_title_plot_synopsis': '/title/{imdb_id}/plotsynopsis',
39
+ 'get_title_plot_taglines': '/title/{imdb_id}/taglines',
40
+ 'get_title_versions': '/title/{imdb_id}/versions',
41
+ 'get_title_releases': '/title/{imdb_id}/releases',
42
+ 'get_title_quotes': '/title/{imdb_id}/quotes',
43
+ 'get_title_connections': '/title/{imdb_id}/connections',
44
+ 'get_title_genres': '/title/{imdb_id}/genres',
45
+ 'get_title_similarities': '/title/{imdb_id}/similarities',
46
+ 'get_title_awards': '/title/{imdb_id}/awards',
47
+ 'get_title_ratings': '/title/{imdb_id}/ratings',
48
+ 'get_title_credits': '/title/{imdb_id}/fullcredits',
49
+ 'get_title_keywords': '/title/{imdb_id}/keywords',
50
+ 'get_title_certificates': '/title/{imdb_id}/certificates',
51
+ 'get_title_business': '/title/{imdb_id}/business',
52
+ 'get_title_watch_options': '/title/{imdb_id}/watchoptions',
53
+ 'get_title_principals': '/title/{imdb_id}/principals',
54
+ 'get_title_filming_locations': '/title/{imdb_id}/filminglocations',
55
+ 'get_title_crazy_credits': '/title/{imdb_id}/crazycredits',
56
+ 'get_name': '/name/{imdb_id}/fulldetails',
57
+ 'get_name_filmography': '/name/{imdb_id}/filmography',
58
+ 'get_name_known_for': '/name/{imdb_id}/knownfor',
59
+ 'get_name_awards': '/name/{imdb_id}/awards',
60
+ 'get_name_bio': '/name/{imdb_id}/bio',
61
+ 'get_name_trivia': '/name/{imdb_id}/trivia',
62
+ 'get_name_quotes': '/name/{imdb_id}/quotes',
63
+ 'get_name_news': '/name/{imdb_id}/news',
64
+ }
65
+
66
+
67
+ class Imdb(Auth):
68
+ CACHE_MAX_ENTRIES = 128
69
+
70
+ def __init__(
71
+ self,
72
+ locale=None,
73
+ exclude_episodes=False,
74
+ session=None,
75
+ cache_ttl=300,
76
+ ):
77
+ super().__init__()
78
+ self.locale = locale or 'en_US'
79
+ self.region = self.locale.split('_')[-1].upper()
80
+ self.exclude_episodes = exclude_episodes
81
+ self.session = session or requests.Session()
82
+ self._cachedir = tempfile.gettempdir()
83
+ self.cache_ttl = cache_ttl
84
+ self._resource_cache = {}
85
+
86
+ def __getattr__(self, name):
87
+ if name in _SIMPLE_GET_ENDPOINTS:
88
+ return self._simple_get_method(
89
+ method=name, path=_SIMPLE_GET_ENDPOINTS[name]
90
+ )
91
+ else:
92
+ msg = '{obj!r} object has no attribute {name!r}'.format(
93
+ obj=self.__class__.__name__,
94
+ name=name,
95
+ )
96
+ raise AttributeError(msg)
97
+
98
+ def get_title(self, imdb_id):
99
+ logger.info('called get_title %s', imdb_id)
100
+ self.validate_imdb_id(imdb_id)
101
+ # redirection check using API response
102
+ self._redirection_title_check(imdb_id)
103
+ try:
104
+ resource = self._get_resource_cached(
105
+ '/title/{0}/auxiliary'.format(imdb_id)
106
+ )
107
+ except LookupError:
108
+ self._title_not_found()
109
+
110
+ if (
111
+ self.exclude_episodes is True
112
+ and resource['base']['titleType'] == 'tvEpisode'
113
+ ):
114
+ raise LookupError(
115
+ 'Title not found. Title was an episode and '
116
+ '"exclude_episodes" is set to true'
117
+ )
118
+ return resource
119
+
120
+ def _fetch_title_auxiliary(self, imdb_id):
121
+ path = '/template/imdb-ios-writable/title-auxiliary-v31.jstl/render'
122
+ return self._get_cached(
123
+ url=urljoin(BASE_URI, path),
124
+ params={
125
+ 'inlineBannerAdWeblabOn': 'false',
126
+ 'minwidth': '320',
127
+ 'osVersion': '11.3.0',
128
+ 'region': self.region,
129
+ 'tconst': imdb_id,
130
+ 'today': date.today().strftime('%Y-%m-%d'),
131
+ },
132
+ )
133
+
134
+ def get_title_auxiliary(self, imdb_id):
135
+ logger.info('called get_title_auxiliary %s', imdb_id)
136
+ self.validate_imdb_id(imdb_id)
137
+ # redirection check using API response
138
+ self._redirection_title_check(imdb_id)
139
+ try:
140
+ resource = self._fetch_title_auxiliary(imdb_id)
141
+ except LookupError:
142
+ self._title_not_found()
143
+
144
+ if (
145
+ self.exclude_episodes is True
146
+ and resource['titleType'].lower() == 'tvepisode'
147
+ ):
148
+ raise LookupError(
149
+ 'Title not found. Title was an episode and '
150
+ '"exclude_episodes" is set to true'
151
+ )
152
+ return resource
153
+
154
+ def _simple_get_method(self, method, path):
155
+ """Return client method generated from ``_SIMPLE_GET_ENDPOINTS``."""
156
+
157
+ def get(imdb_id):
158
+ logger.info('called %s %s', method, imdb_id)
159
+ self.validate_imdb_id(imdb_id)
160
+ self._redirection_title_check(imdb_id)
161
+ return self._get_resource(path.format(imdb_id=imdb_id))
162
+
163
+ return get
164
+
165
+ def title_exists(self, imdb_id):
166
+ self.validate_imdb_id(imdb_id)
167
+ try:
168
+ self._get_resource('/title/{0}/auxiliary'.format(imdb_id))
169
+ return True
170
+ except LookupError:
171
+ return False
172
+
173
+ def _suggest_search(self, query):
174
+ # translates national characters into similar sounding latin characters
175
+ cleaned_query = unidecode(query)
176
+ query_encoded = quote(cleaned_query)
177
+ first_alphanum_char = self._query_first_alpha_num(cleaned_query)
178
+ path = '/suggests/{0}/{1}.json'.format(
179
+ first_alphanum_char, query_encoded
180
+ )
181
+ url = urljoin(SEARCH_BASE_URI, path)
182
+ search_results = self._get(url=url, query=query_encoded)
183
+ return search_results
184
+
185
+ def search_for_name(self, name):
186
+ logger.info('called search_for_name %s', name)
187
+ name = re.sub(r'\W+', '+', name).strip('+')
188
+ search_results = self._suggest_search(name)
189
+ results = []
190
+ for result in search_results.get('d', ()):
191
+ if not result['id'].startswith('nm'):
192
+ # ignore non-person results
193
+ continue
194
+ result_item = {
195
+ 'name': result['l'],
196
+ 'imdb_id': result['id'],
197
+ }
198
+ results.append(result_item)
199
+ return results
200
+
201
+ def search_for_title(self, title):
202
+ logger.info('called search_for_title %s', title)
203
+ title = re.sub(r'\W+', '+', title).strip('+')
204
+ search_results = self._suggest_search(title)
205
+ results = []
206
+ for result in search_results.get('d', ()):
207
+ if not result['id'].startswith('tt'):
208
+ # ignore non-title results
209
+ continue
210
+ result_item = {
211
+ 'title': result['l'],
212
+ 'year': str(result['y']) if result.get('y') else None,
213
+ 'imdb_id': result['id'],
214
+ 'type': result.get('q'),
215
+ }
216
+ results.append(result_item)
217
+ return results
218
+
219
+ def get_popular_titles(self):
220
+ return self._get_resource('/chart/titlemeter')
221
+
222
+ def get_popular_shows(self):
223
+ return self._get_resource('/chart/tvmeter')
224
+
225
+ def get_popular_movies(self):
226
+ return self._get_resource('/chart/moviemeter')
227
+
228
+ def get_title_episodes(self, imdb_id):
229
+ logger.info('called get_title_episodes %s', imdb_id)
230
+ self.validate_imdb_id(imdb_id)
231
+ if self.exclude_episodes:
232
+ raise ValueError('exclude_episodes is current set to true')
233
+ return self._get_resource('/title/{0}/episodes'.format(imdb_id))
234
+
235
+ def get_title_episodes_detailed(
236
+ self, imdb_id, season, limit=500, region=None, offset=0
237
+ ):
238
+ """
239
+ Request detailed information for a tv series, for a specific season.
240
+
241
+ :param imdb_id: The imdb id including the TT prefix.
242
+ :param limit: Limit the amound of episodes returned for a season.
243
+ :param region: Two capital letter region code in ISO 3166-1 alpha-2.
244
+ :param season: The season you want the detailed information for.
245
+ :param offset: Offset episode results by this value.
246
+ """
247
+ logger.info('called get_title_episodes_detailed %s', imdb_id)
248
+ self.validate_imdb_id(imdb_id)
249
+ if season < 1:
250
+ raise ValueError('season must be greater than zero')
251
+ params = {
252
+ 'end': limit,
253
+ 'start': offset,
254
+ 'season': season - 1, # api seasons are zero indexed
255
+ 'tconst': imdb_id,
256
+ }
257
+ if region:
258
+ params.update({'region': region})
259
+
260
+ return self._get(
261
+ urljoin(
262
+ BASE_URI,
263
+ '/template/imdb-ios-writable/tv-episodes-v2.jstl/render',
264
+ ),
265
+ params=params,
266
+ )
267
+
268
+ def get_title_top_crew(self, imdb_id):
269
+ """
270
+ Request detailed information about title's top crew
271
+ (ie: directors, writters, etc.).
272
+
273
+ :param imdb_id: The imdb id including the TT prefix.
274
+ """
275
+ logger.info('called get_title_top_crew %s', imdb_id)
276
+ self.validate_imdb_id(imdb_id)
277
+ params = {'tconst': imdb_id}
278
+ return self._get(
279
+ urljoin(
280
+ BASE_URI,
281
+ '/template/imdb-android-writable/7.3.top-crew.jstl/render',
282
+ ),
283
+ params=params,
284
+ )
285
+
286
+ @staticmethod
287
+ def _parse_dirty_json(data, query=None):
288
+ if query is None:
289
+ match_json_within_dirty_json = r'imdb\$.+\({1}(.+)\){1}'
290
+ else:
291
+ # No need to unquote as the json is containing quoted query
292
+ match_json_within_dirty_json = r'imdb\${}\((.+)\)'.format(query)
293
+ data_clean = re.match(
294
+ match_json_within_dirty_json, data, re.IGNORECASE
295
+ ).groups()[0]
296
+ return json.loads(data_clean)
297
+
298
+ @staticmethod
299
+ def validate_imdb_id(imdb_id):
300
+ match_id = r'[a-zA-Z]{2}[0-9]{7,8}'
301
+ try:
302
+ re.match(match_id, imdb_id, re.IGNORECASE).group()
303
+ except (AttributeError, TypeError):
304
+ raise ValueError('invalid imdb id')
305
+
306
+ @staticmethod
307
+ def _is_redirection_result(response):
308
+ """
309
+ Return True if response is that of a redirection else False
310
+ Redirection results have no information of use.
311
+ """
312
+ imdb_id = response['data'].get('tconst')
313
+ if imdb_id and imdb_id != response['data'].get('news', {}).get(
314
+ 'channel'
315
+ ):
316
+ return True
317
+ return False
318
+
319
+ def _get_resource(self, path):
320
+ url = urljoin(BASE_URI, path)
321
+ return self._get(url=url)['resource']
322
+
323
+ def _get_resource_cached(self, path):
324
+ url = urljoin(BASE_URI, path)
325
+ return self._get_cached(url=url)['resource']
326
+
327
+ def clear_cache(self):
328
+ """Drop resources cached by this client instance."""
329
+ self._resource_cache.clear()
330
+
331
+ def _evict_cache(self, now):
332
+ for key, (expires_at, _) in list(self._resource_cache.items()):
333
+ if expires_at <= now:
334
+ del self._resource_cache[key]
335
+ while len(self._resource_cache) >= self.CACHE_MAX_ENTRIES:
336
+ oldest = next(iter(self._resource_cache))
337
+ del self._resource_cache[oldest]
338
+
339
+ def _get_cached(self, url, params=None):
340
+ """``_get`` with a ``cache_ttl`` second memory cache."""
341
+ if not self.cache_ttl:
342
+ return self._get(url=url, params=params)
343
+ key = (url, tuple(sorted((params or {}).items())))
344
+ cached = self._resource_cache.get(key)
345
+ now = time.monotonic()
346
+ if cached is not None and cached[0] > now:
347
+ # Hand out a copy so callers cannot mutate the cached payload.
348
+ return copy.deepcopy(cached[1])
349
+ resource = self._get(url=url, params=params)
350
+ if resource is not None:
351
+ # Do not cache error responses, they clear up on the next call.
352
+ self._evict_cache(now)
353
+ self._resource_cache[key] = (
354
+ now + self.cache_ttl,
355
+ copy.deepcopy(resource),
356
+ )
357
+ return resource
358
+
359
+ def _get(self, url, query=None, params=None):
360
+ headers = {'Accept-Language': self.locale}
361
+ if params:
362
+ full_url = '{0}?{1}'.format(url, urlencode(params))
363
+ else:
364
+ full_url = url
365
+ headers.update(self.get_auth_headers(full_url))
366
+ resp = self.session.get(url, headers=headers, params=params)
367
+ if not resp.ok:
368
+ if resp.status_code == httplib.NOT_FOUND:
369
+ raise LookupError('Resource {0} not found'.format(url))
370
+ else:
371
+ msg = '{0} {1}'.format(resp.status_code, resp.text)
372
+ raise ImdbAPIError(msg)
373
+ resp_data = resp.content.decode('utf-8')
374
+ try:
375
+ resp_dict = json.loads(resp_data)
376
+ except ValueError:
377
+ resp_dict = self._parse_dirty_json(data=resp_data, query=query)
378
+
379
+ if resp_dict.get('error'):
380
+ return None
381
+ return resp_dict
382
+
383
+ def _redirection_title_check(self, imdb_id):
384
+ if self.is_redirection_title(imdb_id):
385
+ self._title_not_found(
386
+ msg='{0} is a redirection imdb id'.format(imdb_id)
387
+ )
388
+
389
+ def is_redirection_title(self, imdb_id):
390
+ self.validate_imdb_id(imdb_id)
391
+ try:
392
+ if imdb_id.startswith('nm'):
393
+ resource = self._get_resource_cached(
394
+ '/name/{0}/fulldetails'.format(imdb_id)
395
+ )
396
+ returned_id = resource['base'].get('id', '')
397
+ if returned_id:
398
+ match = re.search(r'nm\d{7,8}', returned_id)
399
+ if match:
400
+ actual_id = match.group()
401
+ # If the returned ID differs, it's a redirection
402
+ return actual_id != imdb_id
403
+ else:
404
+ resource = self._fetch_title_auxiliary(imdb_id)
405
+ returned_id = resource.get('id', '')
406
+ if returned_id:
407
+ match = re.search(r'tt\d{7,8}', returned_id)
408
+ if match:
409
+ actual_id = match.group()
410
+ # If the returned ID differs, it's a redirection
411
+ return actual_id != imdb_id
412
+ return False
413
+ except LookupError:
414
+ # If ID doesn't exist at all, it's not a redirection
415
+ return False
416
+
417
+ @staticmethod
418
+ def _query_first_alpha_num(query):
419
+ for char in query.lower():
420
+ if char.isalnum():
421
+ return char
422
+ raise ValueError(
423
+ 'invalid query, does not contain any alphanumeric characters'
424
+ )
425
+
426
+ @staticmethod
427
+ def _title_not_found(msg=''):
428
+ if msg:
429
+ msg = ' {0}'.format(msg)
430
+ raise LookupError('Title not found.{0}'.format(msg))
imdbpie/objects.py ADDED
@@ -0,0 +1,162 @@
1
+ import attr
2
+
3
+
4
+ @attr.s
5
+ class Image(object):
6
+ url = attr.ib()
7
+ width = attr.ib()
8
+ height = attr.ib()
9
+
10
+
11
+ @attr.s
12
+ class TitleVideo(object):
13
+ content_type = attr.ib(default=None)
14
+ description = attr.ib(default=None)
15
+ url = attr.ib(default=None)
16
+ duration_seconds = attr.ib(default=None)
17
+
18
+
19
+ @attr.s
20
+ class WatchOption(object):
21
+ provider = attr.ib()
22
+ category = attr.ib()
23
+ url = attr.ib(default=None)
24
+ provider_id = attr.ib(default=None)
25
+ notes = attr.ib(default=None)
26
+
27
+
28
+ @attr.s
29
+ class TitleBusiness(object):
30
+ budget = attr.ib(default=None)
31
+ worldwide_gross = attr.ib(default=None)
32
+ gross = attr.ib(default=())
33
+ opening_weekend_gross = attr.ib(default=())
34
+
35
+
36
+ @attr.s
37
+ class TitleCertificate(object):
38
+ country = attr.ib()
39
+ certificate = attr.ib()
40
+ attributes = attr.ib(default=())
41
+
42
+
43
+ @attr.s
44
+ class FilmingLocation(object):
45
+ location = attr.ib()
46
+ attributes = attr.ib(default=())
47
+ notes = attr.ib(default=())
48
+
49
+
50
+ @attr.s
51
+ class Award(object):
52
+ name = attr.ib()
53
+ category = attr.ib()
54
+ year = attr.ib(default=None)
55
+ won = attr.ib(default=False)
56
+ event_name = attr.ib(default=None)
57
+ title_ids = attr.ib(default=())
58
+
59
+
60
+ class TitleEpisodes(object):
61
+ def __init__(self, facade, imdb_id):
62
+ self._facade = facade
63
+ episodes = self._facade._client.get_title_episodes(imdb_id=imdb_id)
64
+ self._episode_imdb_ids = []
65
+ for season in episodes['seasons']:
66
+ for episode in season['episodes']:
67
+ imdb_id = self._facade._parse_id(episode['id'])
68
+ self._episode_imdb_ids.append(imdb_id)
69
+ self._count = len(self._episode_imdb_ids)
70
+
71
+ def __len__(self):
72
+ return self._count
73
+
74
+ def __bool__(self):
75
+ return self._count > 0
76
+
77
+ def __getitem__(self, index):
78
+ imdb_id = self._episode_imdb_ids[index]
79
+ return self._facade.get_title(imdb_id=imdb_id)
80
+
81
+
82
+ @attr.s
83
+ class Title(object):
84
+ imdb_id = attr.ib()
85
+ title = attr.ib()
86
+ type = attr.ib()
87
+ certification = attr.ib()
88
+ year = attr.ib()
89
+ genres = attr.ib()
90
+ writers = attr.ib()
91
+ creators = attr.ib()
92
+ credits = attr.ib()
93
+ directors = attr.ib()
94
+ stars = attr.ib()
95
+ image = attr.ib()
96
+ episodes = attr.ib()
97
+ rating_count = attr.ib(default=0)
98
+ releases = attr.ib(default=())
99
+ season = attr.ib(default=None)
100
+ episode = attr.ib(default=None)
101
+ rating = attr.ib(default=None)
102
+ plot_outline = attr.ib(default=None)
103
+ release_date = attr.ib(default=None)
104
+ runtime = attr.ib(default=None)
105
+ metacritic = attr.ib(default=None)
106
+ awards_summary = attr.ib(default=None)
107
+ spoken_languages = attr.ib(default=())
108
+ countries = attr.ib(default=())
109
+ alternate_titles = attr.ib(default=())
110
+ alternate_titles_count = attr.ib(default=0)
111
+ top_rank = attr.ib(default=None)
112
+ trailer = attr.ib(default=None)
113
+ production_status = attr.ib(default=None)
114
+ certificate_reason = attr.ib(default=None)
115
+
116
+ def __repr__(self):
117
+ return 'Title(imdb_id={0}, title={1})'.format(self.imdb_id, self.title)
118
+
119
+
120
+ @attr.s
121
+ class TitleSearchResult(object):
122
+ imdb_id = attr.ib()
123
+ title = attr.ib()
124
+ type = attr.ib()
125
+ year = attr.ib()
126
+
127
+
128
+ @attr.s
129
+ class NameSearchResult(object):
130
+ imdb_id = attr.ib()
131
+ name = attr.ib()
132
+
133
+
134
+ @attr.s
135
+ class TitleRelease(object):
136
+ date = attr.ib()
137
+ region = attr.ib()
138
+
139
+
140
+ @attr.s
141
+ class TitleName(object):
142
+ name = attr.ib()
143
+ category = attr.ib()
144
+ imdb_id = attr.ib()
145
+ job = attr.ib(default=None)
146
+ characters = attr.ib(default=())
147
+
148
+
149
+ @attr.s
150
+ class Name(object):
151
+ name = attr.ib()
152
+ imdb_id = attr.ib()
153
+ image = attr.ib()
154
+ birth_place = attr.ib()
155
+ gender = attr.ib()
156
+ bios = attr.ib()
157
+ date_of_birth = attr.ib()
158
+ filmography = attr.ib()
159
+ jobs = attr.ib(default=())
160
+ known_for = attr.ib(default=())
161
+ real_name = attr.ib(default=None)
162
+ height_centimeters = attr.ib(default=None)
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: imdbpie
3
+ Version: 5.8.0
4
+ Summary: Python IMDB client using the IMDB json web service made available for their iOS app.
5
+ Author-email: Richard O'Dwyer <richard@richard.do>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://code.richard.do/richardARPANET/imdb-pie/
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Internet :: WWW/HTTP
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/x-rst
15
+ License-File: LICENSE
16
+ Requires-Dist: attrs>=26.1.0
17
+ Requires-Dist: boto>=2.49.0
18
+ Requires-Dist: botocore>=1.42.83
19
+ Requires-Dist: diskcache-community>=5.8.3
20
+ Requires-Dist: python-dateutil>=2.9.0.post0
21
+ Requires-Dist: requests>=2.33.1
22
+ Requires-Dist: setuptools>=82.0.1
23
+ Requires-Dist: text-unidecode>=1.3
24
+ Requires-Dist: wheel>=0.46.3
25
+ Dynamic: license-file
26
+
27
+ ImdbPie
28
+ =======
29
+
30
+ |PyPI| |Python Versions|
31
+
32
+ Python IMDB client using the IMDB JSON web service made available for their iOS application.
33
+
34
+ API Terminology
35
+ ---------------
36
+
37
+ - ``Title`` this can be a movie, tv show, video, documentary etc.
38
+ - ``Name`` this can be a credit, cast member, any person generally.
39
+
40
+ Installation
41
+ ------------
42
+
43
+ To install imdbpie, simply:
44
+
45
+ .. code:: bash
46
+
47
+ uv add imdbpie
48
+
49
+ How to use
50
+ ------------
51
+
52
+ Choose an option:
53
+
54
+ 1. `ImdbPie Facade usage examples <FACADE.rst>`_ (the easy way, returns objects).
55
+
56
+ 2. `ImdbPie Client usage examples <CLIENT.rst>`_ (more low level client API, returns raw dicts).
57
+
58
+ Requirements
59
+ ------------
60
+
61
+ ::
62
+
63
+ 1. Python 3.10 or newer
64
+ 2. Dependencies are declared in pyproject.toml and pinned in uv.lock
65
+
66
+ Development
67
+ -----------
68
+
69
+ This project uses `uv <https://docs.astral.sh/uv/>`_ for environments,
70
+ dependencies and locking.
71
+
72
+ .. code:: bash
73
+
74
+ uv sync
75
+
76
+ Running the tests
77
+ -----------------
78
+
79
+ .. code:: bash
80
+
81
+ uv run pytest src/tests
82
+
83
+ Some tests call the live IMDb API and are marked ``live``. Skip them when
84
+ working offline:
85
+
86
+ .. code:: bash
87
+
88
+ uv run pytest src/tests -m "not live"
89
+
90
+ .. |PyPI| image:: https://img.shields.io/pypi/v/imdbpie.svg
91
+ :target: https://pypi.python.org/pypi/imdb-pie
92
+ .. |Python Versions| image:: https://img.shields.io/pypi/pyversions/imdbpie.svg
93
+ :target: https://pypi.python.org/pypi/imdb-pie
@@ -0,0 +1,12 @@
1
+ imdbpie/__init__.py,sha256=q4MdNaE0WwCfodXbYENvHnpHNAwbXTruLHF6lq_E97c,142
2
+ imdbpie/auth.py,sha256=IwPeYfacjZC2HqhRe41VN7XtWBjl_CwTIFAph6a9OdY,3190
3
+ imdbpie/constants.py,sha256=26DT7y1jKwI_eVuVyjL6js9IN2OxYGxkAsF4Gzr8Gb0,292
4
+ imdbpie/exceptions.py,sha256=gmYjoIWWd7kQGlIGHKO00261ElQDEo-pnZ1tXXvX4kM,64
5
+ imdbpie/facade.py,sha256=74Qkdds2Qp-Oir1Eye5DjmdUUqDWOnWeCvw0vYbQuDk,14861
6
+ imdbpie/imdbpie.py,sha256=pFBt251C554d0sD96jM5YoqGThXDLnIl-rPZul22ylQ,15819
7
+ imdbpie/objects.py,sha256=P_0rbO8EFSYh1LCozfL30-yJCI2AoiB5H1X9ptRDAeg,3908
8
+ imdbpie-5.8.0.dist-info/licenses/LICENSE,sha256=i42eLltK2Nu2C2HUnml4JMKcAz582pLMyxeOShb70Sg,583
9
+ imdbpie-5.8.0.dist-info/METADATA,sha256=KA4U4kAGHme6iMV1xY3DJODSjPSJtVVIxB6iahIEIqU,2317
10
+ imdbpie-5.8.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
11
+ imdbpie-5.8.0.dist-info/top_level.txt,sha256=FPdYez0bUsp6xnEuNFanitCDlm6gO11optSdUfQk4wE,8
12
+ imdbpie-5.8.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,13 @@
1
+ Copyright 2017 Richard O'Dwyer
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License.
5
+ You may obtain a copy of the License at
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the License is distributed on an "AS IS" BASIS,
11
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ See the License for the specific language governing permissions and
13
+ limitations under the License.
@@ -0,0 +1 @@
1
+ imdbpie