Skip to content

Commit 1c66a3c

Browse files
Add "details" command
Add command requesting details for a given ID for a single entry. Separate fragments from queries for better re-usability. Update queries and returned data structures with additional fields better matching behavior of a browser accessing JustWatch normally.
1 parent f5fe60a commit 1c66a3c

5 files changed

Lines changed: 393 additions & 130 deletions

File tree

src/simplejustwatchapi/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
from simplejustwatchapi.justwatch import search
1+
from simplejustwatchapi.justwatch import details, search
22
from simplejustwatchapi.query import MediaEntry, Offer

src/simplejustwatchapi/justwatch.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@
44

55
from httpx import post
66

7-
from simplejustwatchapi.query import MediaEntry, parse_search_response, prepare_search_request
7+
from simplejustwatchapi.query import (
8+
MediaEntry,
9+
parse_details_response,
10+
parse_search_response,
11+
prepare_details_request,
12+
prepare_search_request,
13+
)
814

915
_GRAPHQL_API_URL = "https://apis.justwatch.com/graphql"
1016

@@ -29,3 +35,23 @@ def search(
2935
response = post(_GRAPHQL_API_URL, json=request)
3036
response.raise_for_status()
3137
return parse_search_response(response.json())
38+
39+
40+
def details(
41+
node_id: str, country: str = "US", language: str = "en", best_only: bool = True
42+
) -> MediaEntry:
43+
"""Get details of entry for a given ID.
44+
45+
Args:
46+
node_id: ID of entry to look up
47+
country: country to search for offers, "US" by default
48+
language: language of responses, "en" by default
49+
best_only: return only best offers if True, return all offers if False
50+
51+
Returns:
52+
MediaEntry NamedTuple with data about requested entry.
53+
"""
54+
request = prepare_details_request(node_id, country, language, best_only)
55+
response = post(_GRAPHQL_API_URL, json=request)
56+
response.raise_for_status()
57+
return parse_details_response(response.json())

src/simplejustwatchapi/query.py

Lines changed: 184 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,24 @@
88
_DETAILS_URL = "https://justwatch.com"
99
_IMAGES_URL = "https://images.justwatch.com"
1010

11+
_GRAPHQL_DETAILS_QUERY = """
12+
query GetTitleNode(
13+
$nodeId: ID!,
14+
$language: Language!,
15+
$country: Country!,
16+
$formatPoster: ImageFormat,
17+
$formatOfferIcon: ImageFormat,
18+
$profile: PosterProfile,
19+
$backdropProfile: BackdropProfile,
20+
$filter: OfferFilter!,
21+
) {
22+
node(id: $nodeId) {
23+
...TitleDetails
24+
__typename
25+
}
26+
__typename
27+
}
28+
"""
1129

1230
_GRAPHQL_SEARCH_QUERY = """
1331
query GetSearchTitles(
@@ -29,85 +47,116 @@
2947
sortRandomSeed: 0
3048
) {
3149
edges {
32-
...SearchTitleGraphql
50+
node {
51+
...TitleDetails
52+
__typename
53+
}
3354
__typename
3455
}
3556
__typename
3657
}
3758
}
59+
"""
3860

39-
fragment SearchTitleGraphql on PopularTitlesEdge {
40-
node {
41-
id
42-
objectId
43-
objectType
44-
content(country: $country, language: $language) {
45-
title
46-
fullPath
47-
originalReleaseYear
48-
originalReleaseDate
49-
runtime
50-
shortDescription
51-
genres {
52-
shortName
53-
__typename
54-
}
55-
externalIds {
56-
imdbId
57-
__typename
58-
}
59-
posterUrl(profile: $profile, format: $formatPoster)
60-
backdrops(profile: $backdropProfile, format: $formatPoster) {
61-
backdropUrl
62-
__typename
63-
}
61+
_GRAPHQL_DETAILS_FRAGMENT = """
62+
fragment TitleDetails on MovieOrShow {
63+
id
64+
objectId
65+
objectType
66+
content(country: $country, language: $language) {
67+
title
68+
fullPath
69+
originalReleaseYear
70+
originalReleaseDate
71+
runtime
72+
shortDescription
73+
genres {
74+
shortName
6475
__typename
6576
}
66-
offers(country: $country, platform: WEB, filter: $filter) {
67-
monetizationType
68-
presentationType
69-
standardWebURL
70-
retailPrice(language: $language)
71-
retailPriceValue
72-
currency
73-
package {
74-
id
75-
packageId
76-
clearName
77-
technicalName
78-
icon(profile: S100, format: $formatOfferIcon)
79-
__typename
80-
}
81-
id
77+
externalIds {
78+
imdbId
79+
__typename
80+
}
81+
posterUrl(profile: $profile, format: $formatPoster)
82+
backdrops(profile: $backdropProfile, format: $formatPoster) {
83+
backdropUrl
8284
__typename
8385
}
8486
__typename
8587
}
88+
offers(country: $country, platform: WEB, filter: $filter) {
89+
...TitleOffer
90+
}
8691
__typename
8792
}
8893
"""
8994

95+
_GRAPHQL_OFFER_FRAGMENT = """
96+
fragment TitleOffer on Offer {
97+
id
98+
monetizationType
99+
presentationType
100+
retailPrice(language: $language)
101+
retailPriceValue
102+
currency
103+
lastChangeRetailPriceValue
104+
type
105+
package {
106+
id
107+
packageId
108+
clearName
109+
technicalName
110+
icon(profile: S100, format: $formatOfferIcon)
111+
__typename
112+
}
113+
standardWebURL
114+
elementCount
115+
availableTo
116+
deeplinkRoku: deeplinkURL(platform: ROKU_OS)
117+
subtitleLanguages
118+
videoTechnology
119+
audioTechnology
120+
audioLanguages
121+
__typename
122+
}
123+
"""
124+
125+
126+
class OfferPackage(NamedTuple):
127+
"""Parsed single offer package from JustWatch GraphQL API for single entry."""
128+
129+
id: str
130+
package_id: int
131+
name: str
132+
technical_name: str
133+
icon: str
134+
90135

91136
class Offer(NamedTuple):
92-
"""Parsed single offer from JustWatch GraphQL API for single entry.
93-
Doesn't match fully received response, some fields are simplified.
94-
"""
137+
"""Parsed single offer from JustWatch GraphQL API for single entry."""
95138

139+
id: str
96140
monetization_type: str
97141
presentation_type: str
98-
url: str
99142
price_string: str | None
100143
price_value: float | None
101144
price_currency: str
102-
name: str
103-
technical_name: str
104-
icon: str
145+
last_change_retail_price_value: float | None
146+
type: str
147+
package: OfferPackage
148+
url: str
149+
element_count: int
150+
available_to: str | None
151+
deeplink_roku: str | None
152+
subtitle_languages: list[str]
153+
video_technology: list[str]
154+
audio_technology: list[str]
155+
audio_languages: list[str]
105156

106157

107158
class MediaEntry(NamedTuple):
108-
"""Parsed response from JustWatch GraphQL API for "GetSearchTitles" query for single entry.
109-
Doesn't match fully received response, some fields are simplified.
110-
"""
159+
"""Parsed response from JustWatch GraphQL API for "GetSearchTitles" query for single entry."""
111160

112161
entry_id: str
113162
object_id: int
@@ -156,25 +205,73 @@ def prepare_search_request(
156205
"backdropProfile": "S1920",
157206
"filter": {"bestOnly": best_only},
158207
},
159-
"query": _GRAPHQL_SEARCH_QUERY,
208+
"query": _GRAPHQL_SEARCH_QUERY + _GRAPHQL_DETAILS_FRAGMENT + _GRAPHQL_OFFER_FRAGMENT,
160209
}
161210

162211

163212
def parse_search_response(json: dict) -> list[MediaEntry]:
164213
"""Parse response from search query from JustWatch GraphQL API.
165214
Parses response for "GetSearchTitles" query.
215+
If API didn't return any data, then an empty list is returned.
166216
167217
Args:
168218
json: JSON returned by JustWatch GraphQL API
169219
170220
Returns:
171-
Parsed received JSON as a MediaEntry NamedTuple
221+
Parsed received JSON as a list of MediaEntry NamedTuples
172222
"""
173223
nodes = json["data"]["popularTitles"]["edges"]
174224
entries = [_parse_entry(node["node"]) for node in nodes]
175225
return entries
176226

177227

228+
def prepare_details_request(node_id: str, country: str, language: str, best_only: bool) -> dict:
229+
"""Prepare a details request for specified node ID to JustWatch GraphQL API.
230+
Creates a "GetTitleNode" GraphQL query.
231+
Country code should be two uppercase letters, however it will be auto-converted to uppercase.
232+
233+
Args:
234+
node_id: node ID of entry to get details for
235+
country: country to search for offers
236+
language: language of responses
237+
best_only: return only best offers if True, return all offers if False
238+
239+
Returns:
240+
JSON/dict with GraphQL POST body
241+
"""
242+
assert len(country) == 2, f"Invalid country code: {country}, code must be 2 characters long"
243+
return {
244+
"operationName": "GetTitleNode",
245+
"variables": {
246+
"nodeId": node_id,
247+
"language": language,
248+
"country": country.upper(),
249+
"formatPoster": "JPG",
250+
"formatOfferIcon": "PNG",
251+
"profile": "S718",
252+
"backdropProfile": "S1920",
253+
"filter": {"bestOnly": best_only},
254+
},
255+
"query": _GRAPHQL_DETAILS_QUERY + _GRAPHQL_DETAILS_FRAGMENT + _GRAPHQL_OFFER_FRAGMENT,
256+
}
257+
258+
259+
def parse_details_response(json: any) -> MediaEntry | None:
260+
"""Parse response from details query from JustWatch GraphQL API.
261+
Parses response for "GetTitleNode" query.
262+
If API responded with an internal error (mostly due to not found node ID),
263+
then "None" will be returned instead.
264+
265+
Args:
266+
json: JSON returned by JustWatch GraphQL API
267+
268+
Returns:
269+
Parsed received JSON as a MediaEntry NamedTuple,
270+
or None in case data for a given node ID was not found
271+
"""
272+
return _parse_entry(json["data"]["node"]) if "errors" not in json else None
273+
274+
178275
def _parse_entry(json: any) -> MediaEntry:
179276
entry_id = json.get("id")
180277
object_id = json.get("objectId")
@@ -212,24 +309,48 @@ def _parse_entry(json: any) -> MediaEntry:
212309

213310

214311
def _parse_offer(json: any) -> Offer:
312+
id = json.get("id")
215313
monetization_type = json.get("monetizationType")
216314
presentation_type = json.get("presentationType")
217-
url = json.get("standardWebURL")
218315
price_string = json.get("retailPrice")
219316
price_value = json.get("retailPriceValue")
220317
price_currency = json.get("currency")
221-
package = json["package"]
222-
name = package.get("clearName")
223-
technical_name = package.get("technicalName")
224-
icon = _IMAGES_URL + package.get("icon")
318+
last_change_retail_price_value = json.get("lastChangeRetailPriceValue")
319+
type = json.get("type")
320+
package = _parse_package(json["package"])
321+
url = json.get("standardWebURL")
322+
element_count = json.get("elementCount", 0)
323+
available_to = json.get("availableTo")
324+
deeplink_roku = json.get("deeplinkRoku")
325+
subtitle_languages = json.get("subtitleLanguages")
326+
video_technology = json.get("videoTechnology")
327+
audio_technology = json.get("audioTechnology")
328+
audio_languages = json.get("audioLanguages")
225329
return Offer(
330+
id,
226331
monetization_type,
227332
presentation_type,
228-
url,
229333
price_string,
230334
price_value,
231335
price_currency,
232-
name,
233-
technical_name,
234-
icon,
336+
last_change_retail_price_value,
337+
type,
338+
package,
339+
url,
340+
element_count,
341+
available_to,
342+
deeplink_roku,
343+
subtitle_languages,
344+
video_technology,
345+
audio_technology,
346+
audio_languages,
235347
)
348+
349+
350+
def _parse_package(json: any) -> OfferPackage:
351+
id = json.get("id")
352+
package_id = json.get("packageId")
353+
name = json.get("clearName")
354+
technical_name = json.get("technicalName")
355+
icon = _IMAGES_URL + json.get("icon")
356+
return OfferPackage(id, package_id, name, technical_name, icon)

0 commit comments

Comments
 (0)