simple-justwatch-python-api 0.14__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.
- simple_justwatch_python_api-0.14.dist-info/LICENSE +674 -0
- simple_justwatch_python_api-0.14.dist-info/METADATA +848 -0
- simple_justwatch_python_api-0.14.dist-info/RECORD +9 -0
- simple_justwatch_python_api-0.14.dist-info/WHEEL +5 -0
- simple_justwatch_python_api-0.14.dist-info/top_level.txt +1 -0
- simplejustwatchapi/__init__.py +2 -0
- simplejustwatchapi/justwatch.py +117 -0
- simplejustwatchapi/main.py +21 -0
- simplejustwatchapi/query.py +539 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
simplejustwatchapi/__init__.py,sha256=wMyheY9kEK_Paz-2VM402Mscm5AwUeTreaI64Mr_-eY,150
|
|
2
|
+
simplejustwatchapi/justwatch.py,sha256=A4sYgKxMxKMkeCQzO3d-Lw8tRZFBtrU020ZSj4Nb2xE,4436
|
|
3
|
+
simplejustwatchapi/main.py,sha256=wGgvhyq7EB7KZOLRLDBHC3PzshVpgP_HHBcyIpG-1ro,467
|
|
4
|
+
simplejustwatchapi/query.py,sha256=i-sigR331i0_TfkAEOP1r5EvhS7QpW_IzvvNDuKUH5s,16590
|
|
5
|
+
simple_justwatch_python_api-0.14.dist-info/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
|
|
6
|
+
simple_justwatch_python_api-0.14.dist-info/METADATA,sha256=wbuyQ5q8V9GDUI9LS4XeZr0KqfNZmf7fNjgeA58NFY0,50112
|
|
7
|
+
simple_justwatch_python_api-0.14.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
|
|
8
|
+
simple_justwatch_python_api-0.14.dist-info/top_level.txt,sha256=GjJ4IpR7I8dT5RfsV8uWv-QjrNr5aVlw7Qfkvj_EC1I,19
|
|
9
|
+
simple_justwatch_python_api-0.14.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
simplejustwatchapi
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Main module orchestrating requests to JustWatch GraphQL API."""
|
|
2
|
+
|
|
3
|
+
from httpx import post
|
|
4
|
+
|
|
5
|
+
from simplejustwatchapi.query import (
|
|
6
|
+
MediaEntry,
|
|
7
|
+
Offer,
|
|
8
|
+
parse_details_response,
|
|
9
|
+
parse_offers_for_countries_response,
|
|
10
|
+
parse_search_response,
|
|
11
|
+
prepare_details_request,
|
|
12
|
+
prepare_offers_for_countries_request,
|
|
13
|
+
prepare_search_request,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
_GRAPHQL_API_URL = "https://apis.justwatch.com/graphql"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def search(
|
|
20
|
+
title: str, country: str = "US", language: str = "en", count: int = 4, best_only: bool = True
|
|
21
|
+
) -> list[MediaEntry]:
|
|
22
|
+
"""Search JustWatch for given title.
|
|
23
|
+
|
|
24
|
+
Returns a list of entries up to ``count``.
|
|
25
|
+
|
|
26
|
+
``best_only`` allows filtering out redundant offers, e.g. when if provide offers service
|
|
27
|
+
in 4K, HD and SD, using ``best_only = True`` returns only 4K option, ``best_only = False``
|
|
28
|
+
returns all three.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
title: title to search
|
|
32
|
+
country: country to search for offers, ``US`` by default
|
|
33
|
+
language: language of responses, ``en`` by default
|
|
34
|
+
count: how many responses should be returned
|
|
35
|
+
best_only: return only best offers if ``True``, return all offers if ``False``
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
List of ``MediaEntry`` NamedTuples parsed from JustWatch response
|
|
39
|
+
"""
|
|
40
|
+
request = prepare_search_request(title, country, language, count, best_only)
|
|
41
|
+
response = post(_GRAPHQL_API_URL, json=request)
|
|
42
|
+
response.raise_for_status()
|
|
43
|
+
return parse_search_response(response.json())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def details(
|
|
47
|
+
node_id: str, country: str = "US", language: str = "en", best_only: bool = True
|
|
48
|
+
) -> MediaEntry:
|
|
49
|
+
"""Get details of entry for a given ID.
|
|
50
|
+
|
|
51
|
+
``best_only`` allows filtering out redundant offers, e.g. when if provide offers service
|
|
52
|
+
in 4K, HD and SD, using ``best_only = True`` returns only 4K option, ``best_only = False``
|
|
53
|
+
returns all three.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
node_id: ID of entry to look up
|
|
57
|
+
country: country to search for offers, ``US`` by default
|
|
58
|
+
language: language of responses, ``en`` by default
|
|
59
|
+
best_only: return only best offers if ``True``, return all offers if ``False``
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
``MediaEntry`` NamedTuple with data about requested entry.
|
|
63
|
+
"""
|
|
64
|
+
request = prepare_details_request(node_id, country, language, best_only)
|
|
65
|
+
response = post(_GRAPHQL_API_URL, json=request)
|
|
66
|
+
response.raise_for_status()
|
|
67
|
+
return parse_details_response(response.json())
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def offers_for_countries(
|
|
71
|
+
node_id: str, countries: set[str], language: str = "en", best_only: bool = True
|
|
72
|
+
) -> dict[str, list[Offer]]:
|
|
73
|
+
"""Get offers for entry of given node ID for all countries passed as argument.
|
|
74
|
+
Language argument only specifies format of price string, e.g. whether ".", or "," is used
|
|
75
|
+
in decimal fractions.
|
|
76
|
+
|
|
77
|
+
Returned dict has keys matching ``countries`` argument and values are list of found offers.
|
|
78
|
+
If no countries are passed (an empty set given as argument) empty dict is returned.
|
|
79
|
+
|
|
80
|
+
Country codes passed as argument are case-insensitive, however keys in returned dict will match
|
|
81
|
+
them exactly. E.g. for countries specified as:
|
|
82
|
+
|
|
83
|
+
.. code-block:: python
|
|
84
|
+
|
|
85
|
+
{"uK", "Us", "AU", "ca"}
|
|
86
|
+
|
|
87
|
+
returned dict will have the following structure:
|
|
88
|
+
|
|
89
|
+
.. code-block:: python
|
|
90
|
+
|
|
91
|
+
{
|
|
92
|
+
"uK": [ ... offers ... ],
|
|
93
|
+
"Us": [ ... offers ... ],
|
|
94
|
+
"AU": [ ... offers ... ],
|
|
95
|
+
"ca": [ ... offers ... ],
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
``best_only`` allows filtering out redundant offers, e.g. when if provide offers service
|
|
99
|
+
in 4K, HD and SD, using ``best_only = True`` returns only 4K option, ``best_only = False``
|
|
100
|
+
returns all three.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
node_id: ID of entry to look up offers for
|
|
104
|
+
countries: set of country codes to search for offers
|
|
105
|
+
language: language of responses, ``en`` by default
|
|
106
|
+
best_only: return only best offers if ``True``, return all offers if ``False``
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
``dict`` where keys match values in ``countries`` and keys are all found offers for their
|
|
110
|
+
respective countries
|
|
111
|
+
"""
|
|
112
|
+
if not countries:
|
|
113
|
+
return {}
|
|
114
|
+
request = prepare_offers_for_countries_request(node_id, countries, language, best_only)
|
|
115
|
+
response = post(_GRAPHQL_API_URL, json=request)
|
|
116
|
+
response.raise_for_status()
|
|
117
|
+
return parse_offers_for_countries_response(response.json(), countries)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from justwatch import details, offers_for_countries, search
|
|
2
|
+
from query import MediaEntry, Offer, OfferPackage
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def main():
|
|
6
|
+
response = search("The Matrix", "US", "en", 3, True)
|
|
7
|
+
print(response)
|
|
8
|
+
print()
|
|
9
|
+
|
|
10
|
+
response = details("tm10", "US", "en", False)
|
|
11
|
+
print(response)
|
|
12
|
+
print()
|
|
13
|
+
|
|
14
|
+
response = offers_for_countries("tm10", {"US", "GB", "FR"}, "en", True)
|
|
15
|
+
print(response)
|
|
16
|
+
print()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
if __name__ == "__main__":
|
|
21
|
+
main()
|
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
"""Module responsible for creating GraphQL queries and parsing responses from JustWatch GraphQL API.
|
|
2
|
+
Parsed responses are returned as Python NamedTuples for easier access."""
|
|
3
|
+
|
|
4
|
+
from typing import NamedTuple
|
|
5
|
+
|
|
6
|
+
_DETAILS_URL = "https://justwatch.com"
|
|
7
|
+
_IMAGES_URL = "https://images.justwatch.com"
|
|
8
|
+
|
|
9
|
+
_GRAPHQL_DETAILS_QUERY = """
|
|
10
|
+
query GetTitleNode(
|
|
11
|
+
$nodeId: ID!,
|
|
12
|
+
$language: Language!,
|
|
13
|
+
$country: Country!,
|
|
14
|
+
$formatPoster: ImageFormat,
|
|
15
|
+
$formatOfferIcon: ImageFormat,
|
|
16
|
+
$profile: PosterProfile,
|
|
17
|
+
$backdropProfile: BackdropProfile,
|
|
18
|
+
$filter: OfferFilter!,
|
|
19
|
+
) {
|
|
20
|
+
node(id: $nodeId) {
|
|
21
|
+
...TitleDetails
|
|
22
|
+
__typename
|
|
23
|
+
}
|
|
24
|
+
__typename
|
|
25
|
+
}
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
_GRAPHQL_SEARCH_QUERY = """
|
|
29
|
+
query GetSearchTitles(
|
|
30
|
+
$searchTitlesFilter: TitleFilter!,
|
|
31
|
+
$country: Country!,
|
|
32
|
+
$language: Language!,
|
|
33
|
+
$first: Int!,
|
|
34
|
+
$formatPoster: ImageFormat,
|
|
35
|
+
$formatOfferIcon: ImageFormat,
|
|
36
|
+
$profile: PosterProfile,
|
|
37
|
+
$backdropProfile: BackdropProfile,
|
|
38
|
+
$filter: OfferFilter!,
|
|
39
|
+
) {
|
|
40
|
+
popularTitles(
|
|
41
|
+
country: $country
|
|
42
|
+
filter: $searchTitlesFilter
|
|
43
|
+
first: $first
|
|
44
|
+
sortBy: POPULAR
|
|
45
|
+
sortRandomSeed: 0
|
|
46
|
+
) {
|
|
47
|
+
edges {
|
|
48
|
+
node {
|
|
49
|
+
...TitleDetails
|
|
50
|
+
__typename
|
|
51
|
+
}
|
|
52
|
+
__typename
|
|
53
|
+
}
|
|
54
|
+
__typename
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
_GRAPHQL_OFFERS_BY_COUNTRY_QUERY = """
|
|
60
|
+
query GetTitleOffers(
|
|
61
|
+
$nodeId: ID!,
|
|
62
|
+
$language: Language!,
|
|
63
|
+
$formatOfferIcon: ImageFormat,
|
|
64
|
+
$filter: OfferFilter!,
|
|
65
|
+
) {{
|
|
66
|
+
node(id: $nodeId) {{
|
|
67
|
+
... on MovieOrShow {{
|
|
68
|
+
{country_entries}
|
|
69
|
+
__typename
|
|
70
|
+
}}
|
|
71
|
+
__typename
|
|
72
|
+
}}
|
|
73
|
+
__typename
|
|
74
|
+
}}
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
_GRAPHQL_DETAILS_FRAGMENT = """
|
|
78
|
+
fragment TitleDetails on MovieOrShow {
|
|
79
|
+
id
|
|
80
|
+
objectId
|
|
81
|
+
objectType
|
|
82
|
+
content(country: $country, language: $language) {
|
|
83
|
+
title
|
|
84
|
+
fullPath
|
|
85
|
+
originalReleaseYear
|
|
86
|
+
originalReleaseDate
|
|
87
|
+
runtime
|
|
88
|
+
shortDescription
|
|
89
|
+
genres {
|
|
90
|
+
shortName
|
|
91
|
+
__typename
|
|
92
|
+
}
|
|
93
|
+
externalIds {
|
|
94
|
+
imdbId
|
|
95
|
+
__typename
|
|
96
|
+
}
|
|
97
|
+
posterUrl(profile: $profile, format: $formatPoster)
|
|
98
|
+
backdrops(profile: $backdropProfile, format: $formatPoster) {
|
|
99
|
+
backdropUrl
|
|
100
|
+
__typename
|
|
101
|
+
}
|
|
102
|
+
__typename
|
|
103
|
+
}
|
|
104
|
+
offers(country: $country, platform: WEB, filter: $filter) {
|
|
105
|
+
...TitleOffer
|
|
106
|
+
}
|
|
107
|
+
__typename
|
|
108
|
+
}
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
_GRAPHQL_OFFER_FRAGMENT = """
|
|
112
|
+
fragment TitleOffer on Offer {
|
|
113
|
+
id
|
|
114
|
+
monetizationType
|
|
115
|
+
presentationType
|
|
116
|
+
retailPrice(language: $language)
|
|
117
|
+
retailPriceValue
|
|
118
|
+
currency
|
|
119
|
+
lastChangeRetailPriceValue
|
|
120
|
+
type
|
|
121
|
+
package {
|
|
122
|
+
id
|
|
123
|
+
packageId
|
|
124
|
+
clearName
|
|
125
|
+
technicalName
|
|
126
|
+
icon(profile: S100, format: $formatOfferIcon)
|
|
127
|
+
__typename
|
|
128
|
+
}
|
|
129
|
+
standardWebURL
|
|
130
|
+
elementCount
|
|
131
|
+
availableTo
|
|
132
|
+
deeplinkRoku: deeplinkURL(platform: ROKU_OS)
|
|
133
|
+
subtitleLanguages
|
|
134
|
+
videoTechnology
|
|
135
|
+
audioTechnology
|
|
136
|
+
audioLanguages
|
|
137
|
+
__typename
|
|
138
|
+
}
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
_GRAPHQL_COUNTRY_OFFERS_ENTRY = """
|
|
142
|
+
{country_code}: offers(country: {country_code}, platform: WEB, filter: $filter) {{
|
|
143
|
+
...TitleOffer
|
|
144
|
+
__typename
|
|
145
|
+
}}
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class OfferPackage(NamedTuple):
|
|
150
|
+
"""Parsed single offer package from JustWatch GraphQL API for single entry.
|
|
151
|
+
Contains information about platform on which given offer is available."""
|
|
152
|
+
|
|
153
|
+
id: str
|
|
154
|
+
"""ID, defines whole platform on which this offer is available, not a single offer."""
|
|
155
|
+
|
|
156
|
+
package_id: int
|
|
157
|
+
"""Package ID, defines whole platform on which this offer is available, not a single offer."""
|
|
158
|
+
|
|
159
|
+
name: str
|
|
160
|
+
"""Name of the platform in format suited to display for users."""
|
|
161
|
+
|
|
162
|
+
technical_name: str
|
|
163
|
+
"""Technical name of the platform, usually all lowercase with no whitespaces."""
|
|
164
|
+
|
|
165
|
+
icon: str
|
|
166
|
+
"""Platform icon URL."""
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class Offer(NamedTuple):
|
|
170
|
+
"""Parsed single offer from JustWatch GraphQL API for single entry.
|
|
171
|
+
One platform can have multiple offers for one entry available, e.g. renting, buying, etc."""
|
|
172
|
+
|
|
173
|
+
id: str
|
|
174
|
+
"""Offer ID."""
|
|
175
|
+
|
|
176
|
+
monetization_type: str
|
|
177
|
+
"""Type of monetization of this offer, e.g. ``FLATRATE`` (streaming), ``RENT``, ``BUY``."""
|
|
178
|
+
|
|
179
|
+
presentation_type: str
|
|
180
|
+
"""Quality of media in this offer, e.g. ``HD``, ``SD``, ``4K``."""
|
|
181
|
+
|
|
182
|
+
price_string: str | None
|
|
183
|
+
"""Current price as a string with currency, suitable for displaying to users.
|
|
184
|
+
Format can change based on used ``language`` argument."""
|
|
185
|
+
|
|
186
|
+
price_value: float | None
|
|
187
|
+
"""Current price as a numeric value."""
|
|
188
|
+
|
|
189
|
+
price_currency: str
|
|
190
|
+
"""Represents only currency, without price, or value."""
|
|
191
|
+
|
|
192
|
+
last_change_retail_price_value: float | None
|
|
193
|
+
"""Previous available price if change in price was recorded."""
|
|
194
|
+
|
|
195
|
+
type: str
|
|
196
|
+
"""Type of offer."""
|
|
197
|
+
|
|
198
|
+
package: OfferPackage
|
|
199
|
+
"""Information about platform on which this offer is available."""
|
|
200
|
+
|
|
201
|
+
url: str
|
|
202
|
+
"""URL to this offer."""
|
|
203
|
+
|
|
204
|
+
element_count: int
|
|
205
|
+
"""Element count, usually 0."""
|
|
206
|
+
|
|
207
|
+
available_to: str | None
|
|
208
|
+
"""Date until which this offer will be available."""
|
|
209
|
+
|
|
210
|
+
deeplink_roku: str | None
|
|
211
|
+
"""Deeplink to this offer in Roku."""
|
|
212
|
+
|
|
213
|
+
subtitle_languages: list[str]
|
|
214
|
+
"""List of 2-letter language codes of available subtitles, e.g. ``["en", "pt", "de"]``."""
|
|
215
|
+
|
|
216
|
+
video_technology: list[str]
|
|
217
|
+
"""List of known video technologies available in this offer, e.g. ``DOLBY_VISION``."""
|
|
218
|
+
|
|
219
|
+
audio_technology: list[str]
|
|
220
|
+
"""List of known audio technologies available in this offer, e.g. ``DOLBY_ATMOS``."""
|
|
221
|
+
|
|
222
|
+
audio_languages: list[str]
|
|
223
|
+
"""List of 2-letter language codes of available audio tracks, e.g. ``["en", "pt", "de"]``."""
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class MediaEntry(NamedTuple):
|
|
227
|
+
"""Parsed response from JustWatch GraphQL API for "GetSearchTitles" query for single entry."""
|
|
228
|
+
|
|
229
|
+
entry_id: str
|
|
230
|
+
"""Entry ID, contains type code and numeric ID."""
|
|
231
|
+
|
|
232
|
+
object_id: int
|
|
233
|
+
"""Object ID, the numeric part of full entry ID."""
|
|
234
|
+
|
|
235
|
+
object_type: str
|
|
236
|
+
"""Type of entry, e.g. ``MOVIE``, ``SHOW``."""
|
|
237
|
+
|
|
238
|
+
title: str
|
|
239
|
+
"""Full title."""
|
|
240
|
+
|
|
241
|
+
url: str
|
|
242
|
+
"""URL to JustWatch with details for this entry."""
|
|
243
|
+
|
|
244
|
+
release_year: int
|
|
245
|
+
"""Release year as a number."""
|
|
246
|
+
|
|
247
|
+
release_date: str
|
|
248
|
+
"""Full release date as a string, e.g. ``2013-12-16``."""
|
|
249
|
+
|
|
250
|
+
runtime_minutes: int
|
|
251
|
+
"""Runtime in minutes."""
|
|
252
|
+
|
|
253
|
+
short_description: str
|
|
254
|
+
"""Short description of this entry."""
|
|
255
|
+
|
|
256
|
+
genres: list[str]
|
|
257
|
+
"""List of genre codes for this entry, e.g. ``["rly"]``, ``["cmy", "drm", "rma"]``."""
|
|
258
|
+
|
|
259
|
+
imdb_id: str | None
|
|
260
|
+
"""ID of this entry in IMDB."""
|
|
261
|
+
|
|
262
|
+
poster: str | None
|
|
263
|
+
"""URL to poster for this ID."""
|
|
264
|
+
|
|
265
|
+
backdrops: list[str]
|
|
266
|
+
"""List of URLs for backdrops (full screen images to use as background)."""
|
|
267
|
+
|
|
268
|
+
offers: list[Offer]
|
|
269
|
+
"""List of available offers for this entry, empty if there are no available offers."""
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def prepare_search_request(
|
|
273
|
+
title: str, country: str, language: str, count: int, best_only: bool
|
|
274
|
+
) -> dict:
|
|
275
|
+
"""Prepare search request for JustWatch GraphQL API.
|
|
276
|
+
Creates a ``GetSearchTitles`` GraphQL query.
|
|
277
|
+
|
|
278
|
+
Country code should be two uppercase letters, however it will be auto-converted to uppercase.
|
|
279
|
+
|
|
280
|
+
Meant to be used together with :func:`parse_search_response`.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
title: title to search
|
|
284
|
+
country: country to search for offers
|
|
285
|
+
language: language of responses
|
|
286
|
+
count: how many responses should be returned
|
|
287
|
+
best_only: return only best offers if ``True``, return all offers if ``False``
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
JSON/dict with GraphQL POST body
|
|
291
|
+
"""
|
|
292
|
+
_assert_country_code_is_valid(country)
|
|
293
|
+
return {
|
|
294
|
+
"operationName": "GetSearchTitles",
|
|
295
|
+
"variables": {
|
|
296
|
+
"first": count,
|
|
297
|
+
"searchTitlesFilter": {"searchQuery": title},
|
|
298
|
+
"language": language,
|
|
299
|
+
"country": country.upper(),
|
|
300
|
+
"formatPoster": "JPG",
|
|
301
|
+
"formatOfferIcon": "PNG",
|
|
302
|
+
"profile": "S718",
|
|
303
|
+
"backdropProfile": "S1920",
|
|
304
|
+
"filter": {"bestOnly": best_only},
|
|
305
|
+
},
|
|
306
|
+
"query": _GRAPHQL_SEARCH_QUERY + _GRAPHQL_DETAILS_FRAGMENT + _GRAPHQL_OFFER_FRAGMENT,
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def parse_search_response(json: dict) -> list[MediaEntry]:
|
|
311
|
+
"""Parse response from search query from JustWatch GraphQL API.
|
|
312
|
+
Parses response for ``GetSearchTitles`` query.
|
|
313
|
+
|
|
314
|
+
If API didn't return any data, then an empty list is returned.
|
|
315
|
+
|
|
316
|
+
Meant to be used together with :func:`prepare_search_request`.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
json: JSON returned by JustWatch GraphQL API
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
Parsed received JSON as a list of ``MediaEntry`` NamedTuples
|
|
323
|
+
"""
|
|
324
|
+
return [_parse_entry(edge["node"]) for edge in json["data"]["popularTitles"]["edges"]]
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def prepare_details_request(node_id: str, country: str, language: str, best_only: bool) -> dict:
|
|
328
|
+
"""Prepare a details request for specified node ID to JustWatch GraphQL API.
|
|
329
|
+
Creates a ``GetTitleNode`` GraphQL query.
|
|
330
|
+
|
|
331
|
+
Country code should be two uppercase letters, however it will be auto-converted to uppercase.
|
|
332
|
+
|
|
333
|
+
Meant to be used together with :func:`parse_details_response`.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
node_id: node ID of entry to get details for
|
|
337
|
+
country: country to search for offers
|
|
338
|
+
language: language of responses
|
|
339
|
+
best_only: return only best offers if ``True``, return all offers if ``False``
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
JSON/dict with GraphQL POST body
|
|
343
|
+
"""
|
|
344
|
+
_assert_country_code_is_valid(country)
|
|
345
|
+
return {
|
|
346
|
+
"operationName": "GetTitleNode",
|
|
347
|
+
"variables": {
|
|
348
|
+
"nodeId": node_id,
|
|
349
|
+
"language": language,
|
|
350
|
+
"country": country.upper(),
|
|
351
|
+
"formatPoster": "JPG",
|
|
352
|
+
"formatOfferIcon": "PNG",
|
|
353
|
+
"profile": "S718",
|
|
354
|
+
"backdropProfile": "S1920",
|
|
355
|
+
"filter": {"bestOnly": best_only},
|
|
356
|
+
},
|
|
357
|
+
"query": _GRAPHQL_DETAILS_QUERY + _GRAPHQL_DETAILS_FRAGMENT + _GRAPHQL_OFFER_FRAGMENT,
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def parse_details_response(json: any) -> MediaEntry | None:
|
|
362
|
+
"""Parse response from details query from JustWatch GraphQL API.
|
|
363
|
+
Parses response for ``GetTitleNode`` query.
|
|
364
|
+
|
|
365
|
+
If API responded with an internal error (mostly due to not found node ID),
|
|
366
|
+
then ``None`` will be returned instead.
|
|
367
|
+
|
|
368
|
+
Meant to be used together with :func:`prepare_details_request`.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
json: JSON returned by JustWatch GraphQL API
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
Parsed received JSON as a ``MediaEntry`` NamedTuple,
|
|
375
|
+
or ``None`` in case data for a given node ID was not found
|
|
376
|
+
"""
|
|
377
|
+
return _parse_entry(json["data"]["node"]) if "errors" not in json else None
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def prepare_offers_for_countries_request(
|
|
381
|
+
node_id: str, countries: set[str], language: str, best_only: bool
|
|
382
|
+
) -> dict:
|
|
383
|
+
"""Prepare an offers request for specified node ID and for all specified countries
|
|
384
|
+
to JustWatch GraphQL API.
|
|
385
|
+
Creates a ``GetTitleOffers`` GraphQL query.
|
|
386
|
+
|
|
387
|
+
Country codes should be two uppercase letters, however they will be auto-converted to uppercase.
|
|
388
|
+
``countries`` argument mustn't be empty.
|
|
389
|
+
|
|
390
|
+
Meant to be used together with :func:`parse_offers_for_countries_response`.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
node_id: node ID of entry to get details for
|
|
394
|
+
countries: list of country codes to search for offers
|
|
395
|
+
language: language of responses
|
|
396
|
+
best_only: return only best offers if ``True``, return all offers if ``False``
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
JSON/dict with GraphQL POST body
|
|
400
|
+
"""
|
|
401
|
+
assert countries, "Cannot prepare offers request without specified countries"
|
|
402
|
+
for country in countries:
|
|
403
|
+
_assert_country_code_is_valid(country)
|
|
404
|
+
return {
|
|
405
|
+
"operationName": "GetTitleOffers",
|
|
406
|
+
"variables": {
|
|
407
|
+
"nodeId": node_id,
|
|
408
|
+
"language": language,
|
|
409
|
+
"formatPoster": "JPG",
|
|
410
|
+
"formatOfferIcon": "PNG",
|
|
411
|
+
"profile": "S718",
|
|
412
|
+
"backdropProfile": "S1920",
|
|
413
|
+
"filter": {"bestOnly": best_only},
|
|
414
|
+
},
|
|
415
|
+
"query": _prepare_offers_for_countries_entry(countries),
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def parse_offers_for_countries_response(json: any, countries: set[str]) -> dict[str, list[Offer]]:
|
|
420
|
+
"""Parse response from offers query from JustWatch GraphQL API.
|
|
421
|
+
Parses response for ``GetTitleOffers`` query.
|
|
422
|
+
|
|
423
|
+
Response if searched for country codes passed as ``countries`` argument.
|
|
424
|
+
Countries in JSON response which are not present in ``countries`` set will be ignored.
|
|
425
|
+
If response doesn't have offers for a country, then that country still will be present
|
|
426
|
+
in returned dict, just with an empty list as value.
|
|
427
|
+
|
|
428
|
+
Meant to be used together with :func:`prepare_offers_for_countries_request`.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
json: JSON returned by JustWatch GraphQL API
|
|
432
|
+
countries: set of countries to look for in API response
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
A dict, where keys are matching ``countries`` argument and values are offers for a given
|
|
436
|
+
country parsed from JSON response.
|
|
437
|
+
"""
|
|
438
|
+
offers_node = json["data"]["node"]
|
|
439
|
+
return {
|
|
440
|
+
country: list(map(_parse_offer, offers_node.get(country.upper(), [])))
|
|
441
|
+
for country in countries
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _assert_country_code_is_valid(code: str) -> None:
|
|
446
|
+
assert len(code) == 2, f"Invalid country code: {code}, code must be 2 characters long"
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _prepare_offers_for_countries_entry(countries: set[str]) -> str:
|
|
450
|
+
offer_requests = [
|
|
451
|
+
_GRAPHQL_COUNTRY_OFFERS_ENTRY.format(country_code=country_code.upper())
|
|
452
|
+
for country_code in countries
|
|
453
|
+
]
|
|
454
|
+
main_body = _GRAPHQL_OFFERS_BY_COUNTRY_QUERY.format(country_entries="\n".join(offer_requests))
|
|
455
|
+
return main_body + _GRAPHQL_OFFER_FRAGMENT
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _parse_entry(json: any) -> MediaEntry:
|
|
459
|
+
entry_id = json.get("id")
|
|
460
|
+
object_id = json.get("objectId")
|
|
461
|
+
object_type = json.get("objectType")
|
|
462
|
+
content = json["content"]
|
|
463
|
+
title = content.get("title")
|
|
464
|
+
url = _DETAILS_URL + content.get("fullPath")
|
|
465
|
+
year = content.get("originalReleaseYear")
|
|
466
|
+
date = content.get("originalReleaseDate")
|
|
467
|
+
runtime_minutes = content.get("runtime")
|
|
468
|
+
short_description = content.get("shortDescription")
|
|
469
|
+
genres = [node.get("shortName") for node in content.get("genres", []) if node]
|
|
470
|
+
external_ids = content.get("externalIds")
|
|
471
|
+
imdb_id = external_ids.get("imdbId") if external_ids else None
|
|
472
|
+
poster_url_field = content.get("posterUrl")
|
|
473
|
+
poster = _IMAGES_URL + poster_url_field if poster_url_field else None
|
|
474
|
+
backdrops = [_IMAGES_URL + bd.get("backdropUrl") for bd in content.get("backdrops", []) if bd]
|
|
475
|
+
offers = [_parse_offer(offer) for offer in json.get("offers", []) if offer]
|
|
476
|
+
return MediaEntry(
|
|
477
|
+
entry_id,
|
|
478
|
+
object_id,
|
|
479
|
+
object_type,
|
|
480
|
+
title,
|
|
481
|
+
url,
|
|
482
|
+
year,
|
|
483
|
+
date,
|
|
484
|
+
runtime_minutes,
|
|
485
|
+
short_description,
|
|
486
|
+
genres,
|
|
487
|
+
imdb_id,
|
|
488
|
+
poster,
|
|
489
|
+
backdrops,
|
|
490
|
+
offers,
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _parse_offer(json: any) -> Offer:
|
|
495
|
+
id = json.get("id")
|
|
496
|
+
monetization_type = json.get("monetizationType")
|
|
497
|
+
presentation_type = json.get("presentationType")
|
|
498
|
+
price_string = json.get("retailPrice")
|
|
499
|
+
price_value = json.get("retailPriceValue")
|
|
500
|
+
price_currency = json.get("currency")
|
|
501
|
+
last_change_retail_price_value = json.get("lastChangeRetailPriceValue")
|
|
502
|
+
type = json.get("type")
|
|
503
|
+
package = _parse_package(json["package"])
|
|
504
|
+
url = json.get("standardWebURL")
|
|
505
|
+
element_count = json.get("elementCount", 0)
|
|
506
|
+
available_to = json.get("availableTo")
|
|
507
|
+
deeplink_roku = json.get("deeplinkRoku")
|
|
508
|
+
subtitle_languages = json.get("subtitleLanguages")
|
|
509
|
+
video_technology = json.get("videoTechnology")
|
|
510
|
+
audio_technology = json.get("audioTechnology")
|
|
511
|
+
audio_languages = json.get("audioLanguages")
|
|
512
|
+
return Offer(
|
|
513
|
+
id,
|
|
514
|
+
monetization_type,
|
|
515
|
+
presentation_type,
|
|
516
|
+
price_string,
|
|
517
|
+
price_value,
|
|
518
|
+
price_currency,
|
|
519
|
+
last_change_retail_price_value,
|
|
520
|
+
type,
|
|
521
|
+
package,
|
|
522
|
+
url,
|
|
523
|
+
element_count,
|
|
524
|
+
available_to,
|
|
525
|
+
deeplink_roku,
|
|
526
|
+
subtitle_languages,
|
|
527
|
+
video_technology,
|
|
528
|
+
audio_technology,
|
|
529
|
+
audio_languages,
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _parse_package(json: any) -> OfferPackage:
|
|
534
|
+
id = json.get("id")
|
|
535
|
+
package_id = json.get("packageId")
|
|
536
|
+
name = json.get("clearName")
|
|
537
|
+
technical_name = json.get("technicalName")
|
|
538
|
+
icon = _IMAGES_URL + json.get("icon")
|
|
539
|
+
return OfferPackage(id, package_id, name, technical_name, icon)
|