Skip to content

Commit 531c655

Browse files
Add "offers" request
Add function requesting all offers for an entry with given node ID. Allow specifying multiple countries to look for offers for.
1 parent 1272ba3 commit 531c655

5 files changed

Lines changed: 298 additions & 15 deletions

File tree

src/simplejustwatchapi/justwatch.py

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
"""Main module orchestrating requests to JustWatch GraphQL API.
2-
Currently only search requests are supported.
2+
Currently supported requests are:
3+
- ``search`` - search for entries via title
4+
- ``details`` - get details for given node ID
5+
- ``offers_for_countries`` - get all offers for entry with given node ID,
6+
can look for offers for multiple countries
37
"""
48

59
from httpx import post
610

711
from simplejustwatchapi.query import (
812
MediaEntry,
13+
Offer,
914
parse_details_response,
15+
parse_offers_for_countries_response,
1016
parse_search_response,
1117
prepare_details_request,
18+
prepare_offers_for_countries_request,
1219
prepare_search_request,
1320
)
1421

@@ -55,9 +62,57 @@ def details(
5562
best_only: return only best offers if ``True``, return all offers if ``False``
5663
5764
Returns:
58-
MediaEntry NamedTuple with data about requested entry.
65+
``MediaEntry`` NamedTuple with data about requested entry.
5966
"""
6067
request = prepare_details_request(node_id, country, language, best_only)
6168
response = post(_GRAPHQL_API_URL, json=request)
6269
response.raise_for_status()
6370
return parse_details_response(response.json())
71+
72+
73+
def offers_for_countries(
74+
node_id: str, countries: set[str], language: str = "en", best_only: bool = True
75+
) -> dict[str, list[Offer]]:
76+
"""Get offers for entry of given node ID for all countries passed as argument.
77+
Language argument only specifies format of price string, e.g. whether ".", or "," is used
78+
in decimal fractions.
79+
80+
Returned dict has keys matching "countries" argument and values are list of found offers.
81+
If no countries are passed (an empty set given as argument) empty dict is returned.
82+
83+
Country codes passed as argument are case-insensitive, however keys in returned dict will match
84+
them exactly. E.g. for countries specified as:
85+
.. code-block:: python
86+
87+
{"uK", "Us", "AU", "ca"}
88+
89+
returned dict will have the following structure:
90+
.. code-block:: python
91+
92+
{
93+
"uK": [... offers ...],
94+
"Us": [... offers ...],
95+
"AU": [... offers ...],
96+
"ca": [... offers ...],
97+
}
98+
99+
``best_only`` allows filtering out redundant offers, e.g. when if provide offers service
100+
in 4K, HD and SD, using ``best_only = True`` returns only 4K option, ``best_only = False``
101+
returns all three.
102+
103+
Args:
104+
node_id: ID of entry to look up offers for
105+
countries: set of country codes to search for offers
106+
language: language of responses, ``en`` by default
107+
best_only: return only best offers if ``True``, return all offers if ``False``
108+
109+
Returns:
110+
``dict`` where keys match values in ``countries`` and keys are all found offers for their
111+
respective countries
112+
"""
113+
if not countries:
114+
return {}
115+
request = prepare_offers_for_countries_request(node_id, countries, language, best_only)
116+
response = post(_GRAPHQL_API_URL, json=request)
117+
response.raise_for_status()
118+
return parse_offers_for_countries_response(response.json(), countries)

src/simplejustwatchapi/query.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,24 @@
5858
}
5959
"""
6060

61+
_GRAPHQL_OFFERS_BY_COUNTRY_QUERY = """
62+
query GetTitleOffers(
63+
$nodeId: ID!,
64+
$language: Language!,
65+
$formatOfferIcon: ImageFormat,
66+
$filter: OfferFilter!,
67+
) {{
68+
node(id: $nodeId) {{
69+
... on MovieOrShow {{
70+
{country_entries}
71+
__typename
72+
}}
73+
__typename
74+
}}
75+
__typename
76+
}}
77+
"""
78+
6179
_GRAPHQL_DETAILS_FRAGMENT = """
6280
fragment TitleDetails on MovieOrShow {
6381
id
@@ -122,6 +140,13 @@
122140
}
123141
"""
124142

143+
_GRAPHQL_COUNTRY_OFFERS_ENTRY = """
144+
{country_code}: offers(country: {country_code}, platform: WEB, filter: $filter) {{
145+
...TitleOffer
146+
__typename
147+
}}
148+
"""
149+
125150

126151
class OfferPackage(NamedTuple):
127152
"""Parsed single offer package from JustWatch GraphQL API for single entry."""
@@ -270,10 +295,78 @@ def parse_details_response(json: any) -> MediaEntry | None:
270295
return _parse_entry(json["data"]["node"]) if "errors" not in json else None
271296

272297

298+
def prepare_offers_for_countries_request(
299+
node_id: str, countries: set[str], language: str, best_only: bool
300+
) -> dict:
301+
"""Prepare an offers request for specified node ID and for all specified countries
302+
to JustWatch GraphQL API.
303+
Creates a ``GetTitleOffers`` GraphQL query.
304+
Country codes should be two uppercase letters, however they will be auto-converted to uppercase.
305+
``countries`` argument mustn't be empty.
306+
307+
Args:
308+
node_id: node ID of entry to get details for
309+
countries: list of country codes to search for offers
310+
language: language of responses
311+
best_only: return only best offers if ``True``, return all offers if ``False``
312+
313+
Returns:
314+
JSON/dict with GraphQL POST body
315+
"""
316+
assert countries, "Cannot prepare offers request without specified countries"
317+
for country in countries:
318+
_assert_country_code_is_valid(country)
319+
return {
320+
"operationName": "GetTitleOffers",
321+
"variables": {
322+
"nodeId": node_id,
323+
"language": language,
324+
"formatPoster": "JPG",
325+
"formatOfferIcon": "PNG",
326+
"profile": "S718",
327+
"backdropProfile": "S1920",
328+
"filter": {"bestOnly": best_only},
329+
},
330+
"query": _prepare_offers_for_countries_entry(countries),
331+
}
332+
333+
334+
def parse_offers_for_countries_response(json: any, countries: set[str]) -> dict[str, list[Offer]]:
335+
"""Parse response from offers query from JustWatch GraphQL API.
336+
Parses response for ``GetTitleOffers`` query.
337+
Response if searched for country codes passed as ``countries`` argument.
338+
Countries in JSON response which are not present in ``countries`` set will be ignored.
339+
If response doesn't have offers for a country, then that country still will be present
340+
in returned dict, just with an empty list as value.
341+
342+
Args:
343+
json: JSON returned by JustWatch GraphQL API
344+
countries: set of countries to look for in API response
345+
346+
Returns:
347+
A dict, where keys are matching ``countries`` argument and values are offers for a given
348+
country parsed from JSON response.
349+
"""
350+
offers_node = json["data"]["node"]
351+
return {
352+
country: list(map(_parse_offer, offers_node.get(country.upper(), [])))
353+
for country in countries
354+
}
355+
356+
273357
def _assert_country_code_is_valid(code: str) -> None:
274358
assert len(code) == 2, f"Invalid country code: {code}, code must be 2 characters long"
275359

276360

361+
def _prepare_offers_for_countries_entry(countries: set[str]) -> str:
362+
offer_requests = [
363+
_GRAPHQL_COUNTRY_OFFERS_ENTRY.format(country_code=country_code.upper())
364+
for country_code in countries
365+
]
366+
main_body = _GRAPHQL_OFFERS_BY_COUNTRY_QUERY.format(country_entries="\n".join(offer_requests))
367+
return main_body + _GRAPHQL_OFFER_FRAGMENT
368+
369+
277370
def _parse_entry(json: any) -> MediaEntry:
278371
entry_id = json.get("id")
279372
object_id = json.get("objectId")

test/simplejustwatchapi/test_justwatch.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from unittest.mock import MagicMock, patch
2+
23
from pytest import fixture
34

4-
from simplejustwatchapi.justwatch import search, details
5+
from simplejustwatchapi.justwatch import details, search
56

67
JUSTWATCH_GRAPHQL_URL = "https://apis.justwatch.com/graphql"
78
SEARCH_INPUT = ("TITLE", "COUNTRY", "LANGUAGE", 5, True)
@@ -22,7 +23,7 @@ def httpx_post_mock(mocker):
2223

2324
@patch("simplejustwatchapi.justwatch.parse_search_response", return_value=DUMMY_ENTRIES)
2425
@patch("simplejustwatchapi.justwatch.prepare_search_request", return_value=DUMMY_REQUEST)
25-
def test_search(requests_mock, parser_mock, httpx_post_mock) -> None:
26+
def test_search(requests_mock, parser_mock, httpx_post_mock):
2627
results = search(*SEARCH_INPUT)
2728
requests_mock.assert_called_with(*SEARCH_INPUT)
2829
parser_mock.assert_called_with(DUMMY_RESPONSE)
@@ -31,7 +32,7 @@ def test_search(requests_mock, parser_mock, httpx_post_mock) -> None:
3132

3233
@patch("simplejustwatchapi.justwatch.parse_details_response", return_value=DUMMY_ENTRIES)
3334
@patch("simplejustwatchapi.justwatch.prepare_details_request", return_value=DUMMY_REQUEST)
34-
def test_details(requests_mock, parser_mock, httpx_post_mock) -> None:
35+
def test_details(requests_mock, parser_mock, httpx_post_mock):
3536
results = details(*DETAILS_INPUT)
3637
requests_mock.assert_called_with(*DETAILS_INPUT)
3738
parser_mock.assert_called_with(DUMMY_RESPONSE)

test/simplejustwatchapi/test_parser.py

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
from pytest import mark
22

3-
from simplejustwatchapi.query import MediaEntry, Offer, parse_search_response, OfferPackage, parse_details_response
3+
from simplejustwatchapi.query import (
4+
MediaEntry,
5+
Offer,
6+
OfferPackage,
7+
parse_details_response,
8+
parse_offers_for_countries_response,
9+
parse_search_response,
10+
)
411

512
DETAILS_URL = "https://justwatch.com"
613
IMAGES_URL = "https://images.justwatch.com"
@@ -282,10 +289,10 @@
282289
argnames=["response_json", "expected_output"],
283290
argvalues=[
284291
(API_SEARCH_RESPONSE_JSON, [PARSED_NODE_1, PARSED_NODE_2, PARSED_NODE_3]),
285-
(API_SEARCH_RESPONSE_NO_DATA, [])
286-
]
292+
(API_SEARCH_RESPONSE_NO_DATA, []),
293+
],
287294
)
288-
def test_parse_search_response(response_json, expected_output) -> None:
295+
def test_parse_search_response(response_json: dict, expected_output: list[MediaEntry]) -> None:
289296
parsed_entries = parse_search_response(response_json)
290297
assert parsed_entries == expected_output
291298

@@ -296,9 +303,47 @@ def test_parse_search_response(response_json, expected_output) -> None:
296303
({"data": {"node": RESPONSE_NODE_1}}, PARSED_NODE_1),
297304
({"data": {"node": RESPONSE_NODE_2}}, PARSED_NODE_2),
298305
({"data": {"node": RESPONSE_NODE_3}}, PARSED_NODE_3),
299-
({"errors": [], "data": {"node": None}}, None)
300-
]
306+
({"errors": [], "data": {"node": None}}, None),
307+
],
301308
)
302-
def test_parse_details_response(response_json, expected_output) -> None:
309+
def test_parse_details_response(response_json: dict, expected_output: MediaEntry) -> None:
303310
parsed_entries = parse_details_response(response_json)
304311
assert parsed_entries == expected_output
312+
313+
314+
@mark.parametrize(
315+
argnames=["response_json", "countries", "expected_output"],
316+
argvalues=[
317+
(
318+
{"data": {"node": {"US": RESPONSE_NODE_1["offers"]}}},
319+
{"US"},
320+
{"US": PARSED_NODE_1.offers},
321+
),
322+
(
323+
{"data": {"node": {"US": RESPONSE_NODE_1["offers"], "GB": RESPONSE_NODE_2["offers"]}}},
324+
{"US", "GB"},
325+
{"US": PARSED_NODE_1.offers, "GB": PARSED_NODE_2.offers},
326+
),
327+
(
328+
{"data": {"node": {"US": RESPONSE_NODE_1["offers"], "GB": RESPONSE_NODE_2["offers"]}}},
329+
{"US"},
330+
{"US": PARSED_NODE_1.offers},
331+
),
332+
(
333+
{"data": {"node": {"US": RESPONSE_NODE_1["offers"], "GB": RESPONSE_NODE_2["offers"]}}},
334+
{"GB"},
335+
{"GB": PARSED_NODE_2.offers},
336+
),
337+
(
338+
{"data": {"node": {"US": RESPONSE_NODE_1["offers"], "GB": []}}},
339+
{"US", "GB"},
340+
{"US": PARSED_NODE_1.offers, "GB": []},
341+
),
342+
({"data": {"node": {"US": []}}}, {"US"}, {"US": []}),
343+
],
344+
)
345+
def test_parse_offers_for_countries_response(
346+
response_json: dict, countries: set[str], expected_output: dict[str, list[Offer]]
347+
) -> None:
348+
parsed_entries = parse_offers_for_countries_response(response_json, countries)
349+
assert parsed_entries == expected_output

0 commit comments

Comments
 (0)