Skip to content

Commit 7b1ef5d

Browse files
Merge "requests" and "parser" modules into "query" module
Keeping parsing code together with query definition helps show why fields are parsed specific way. Add more docstrings.
1 parent 3c15904 commit 7b1ef5d

7 files changed

Lines changed: 229 additions & 213 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 @@
11
from simplejustwatchapi.justwatch import search
2-
from simplejustwatchapi.parser import MediaEntry, Offer
2+
from simplejustwatchapi.query import MediaEntry, Offer

src/simplejustwatchapi/justwatch.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
"""Main module orchestrating requests to JustWatch GraphQL API.
2+
Currently only search requests are supported.
3+
"""
4+
15
from httpx import post
26

3-
from simplejustwatchapi.parser import MediaEntry, parse_search_response
4-
from simplejustwatchapi.requests import prepare_search_request
7+
from simplejustwatchapi.query import prepare_search_request, parse_search_response, MediaEntry
58

69
_GRAPHQL_API_URL = "https://apis.justwatch.com/graphql"
710

src/simplejustwatchapi/parser.py

Lines changed: 0 additions & 100 deletions
This file was deleted.

src/simplejustwatchapi/query.py

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
"""Module responsible for creating GraphQL queries and parsing responses from JustWatch GraphQL API into NamedTuples.
2+
Currently only responses from "GetSearchTitles" query are supported.
3+
"""
4+
5+
from typing import NamedTuple
6+
7+
_DETAILS_URL = "https://justwatch.com"
8+
_IMAGES_URL = "https://images.justwatch.com"
9+
10+
11+
_GRAPHQL_SEARCH_QUERY = """
12+
query GetSearchTitles(
13+
$searchTitlesFilter: TitleFilter!,
14+
$country: Country!,
15+
$language: Language!,
16+
$first: Int!,
17+
$format: ImageFormat,
18+
$profile: PosterProfile,
19+
$backdropProfile: BackdropProfile,
20+
$filter: OfferFilter!,
21+
) {
22+
popularTitles(
23+
country: $country
24+
filter: $searchTitlesFilter
25+
first: $first
26+
sortBy: POPULAR
27+
sortRandomSeed: 0
28+
) {
29+
edges {
30+
...SearchTitleGraphql
31+
__typename
32+
}
33+
__typename
34+
}
35+
}
36+
37+
fragment SearchTitleGraphql on PopularTitlesEdge {
38+
node {
39+
id
40+
objectId
41+
objectType
42+
content(country: $country, language: $language) {
43+
title
44+
fullPath
45+
originalReleaseYear
46+
originalReleaseDate
47+
genres {
48+
shortName
49+
__typename
50+
}
51+
externalIds {
52+
imdbId
53+
__typename
54+
}
55+
posterUrl(profile: $profile, format: $format)
56+
backdrops(profile: $backdropProfile, format: $format) {
57+
backdropUrl
58+
__typename
59+
}
60+
__typename
61+
}
62+
offers(country: $country, platform: WEB, filter: $filter) {
63+
monetizationType
64+
presentationType
65+
standardWebURL
66+
retailPrice(language: $language)
67+
retailPriceValue
68+
currency
69+
package {
70+
id
71+
packageId
72+
clearName
73+
technicalName
74+
icon(profile: S100)
75+
__typename
76+
}
77+
id
78+
__typename
79+
}
80+
__typename
81+
}
82+
__typename
83+
}
84+
"""
85+
86+
87+
class Offer(NamedTuple):
88+
"""Parsed single offer from JustWatch GraphQL API for single entry.
89+
Doesn't match fully received response, some fields are simplified.
90+
"""
91+
monetization_type: str
92+
presentation_type: str
93+
url: str
94+
price_string: str | None
95+
price_value: float | None
96+
price_currency: str
97+
name: str
98+
technical_name: str
99+
icon: str
100+
101+
102+
class MediaEntry(NamedTuple):
103+
"""Parsed response from JustWatch GraphQL API for "GetSearchTitles" query for single entry.
104+
Doesn't match fully received response, some fields are simplified.
105+
"""
106+
entry_id: str
107+
object_id: int
108+
object_type: str
109+
title: str
110+
url: str
111+
release_year: int
112+
release_date: str
113+
genres: list[str]
114+
imdb_id: str | None
115+
poster: str
116+
backdrops: list[str]
117+
offers: list[Offer]
118+
119+
120+
def prepare_search_request(
121+
title: str, country: str, language: str, count: int, best_only: bool
122+
) -> dict:
123+
"""Prepare search request for JustWatch GraphQL API.
124+
Creates a "GetSearchTitles" GraphQL query.
125+
Country code should be two uppercase letters, however it will be auto-converted to uppercase.
126+
127+
Args:
128+
title: title to search
129+
country: country to search for offers
130+
language: language of responses
131+
count: how many responses should be returned
132+
best_only: return only best offers if True, return all offers if False
133+
134+
Returns:
135+
JSON/dict with GraphQL POST body
136+
"""
137+
assert len(country) == 2, f"Invalid country code: {country}, code must be 2 characters long"
138+
return {
139+
"operationName": "GetSearchTitles",
140+
"variables": {
141+
"first": count,
142+
"searchTitlesFilter": {"searchQuery": title},
143+
"language": language,
144+
"country": country.upper(),
145+
"format": "JPG",
146+
"profile": "S718",
147+
"backdropProfile": "S1920",
148+
"filter": {"bestOnly": best_only},
149+
},
150+
"query": _GRAPHQL_SEARCH_QUERY,
151+
}
152+
153+
154+
def parse_search_response(json: dict) -> list[MediaEntry]:
155+
"""Parse response from search query from JustWatch GraphQL API.
156+
Parses response for "GetSearchTitles" query.
157+
158+
Args:
159+
json: JSON returned by JustWatch GraphQL API
160+
161+
Returns:
162+
Parsed received JSON as a MediaEntry NamedTuple
163+
"""
164+
nodes = json["data"]["popularTitles"]["edges"]
165+
entries = [_parse_entry(node["node"]) for node in nodes]
166+
return entries
167+
168+
169+
def _parse_entry(json: any) -> MediaEntry:
170+
entry_id = json.get("id")
171+
object_id = json.get("objectId")
172+
object_type = json.get("objectType")
173+
content = json["content"]
174+
title = content.get("title")
175+
url = _DETAILS_URL + content.get("fullPath")
176+
year = content.get("originalReleaseYear")
177+
date = content.get("originalReleaseDate")
178+
genres = [node.get("shortName") for node in content.get("genres", []) if node]
179+
external_ids = content.get("externalIds")
180+
imdb_id = external_ids.get("imdbId") if external_ids else None
181+
poster = _IMAGES_URL + content.get("posterUrl")
182+
backdrops = [_IMAGES_URL + bd.get("backdropUrl") for bd in content.get("backdrops", []) if bd]
183+
offers = [_parse_offer(offer) for offer in json.get("offers", []) if offer]
184+
return MediaEntry(
185+
entry_id,
186+
object_id,
187+
object_type,
188+
title,
189+
url,
190+
year,
191+
date,
192+
genres,
193+
imdb_id,
194+
poster,
195+
backdrops,
196+
offers,
197+
)
198+
199+
200+
def _parse_offer(json: any) -> Offer:
201+
monetization_type = json.get("monetizationType")
202+
presentation_type = json.get("presentationType")
203+
url = json.get("standardWebURL")
204+
price_string = json.get("retailPrice")
205+
price_value = json.get("retailPriceValue")
206+
price_currency = json.get("currency")
207+
package = json["package"]
208+
name = package.get("clearName")
209+
technical_name = package.get("technicalName")
210+
icon = _IMAGES_URL + package.get("icon")
211+
return Offer(
212+
monetization_type,
213+
presentation_type,
214+
url,
215+
price_string,
216+
price_value,
217+
price_currency,
218+
name,
219+
technical_name,
220+
icon,
221+
)

0 commit comments

Comments
 (0)