Skip to content

Commit 4e8206a

Browse files
committed
add seasons function
1 parent 5524c49 commit 4e8206a

File tree

7 files changed

+821
-2
lines changed

7 files changed

+821
-2
lines changed

README.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ A simple unofficial JustWatch Python API which uses [`GraphQL`](https://graphql.
1818
* [Usage](#usage)
1919
* [Search](#search)
2020
* [Details](#details)
21+
* [Seasons](#seasons)
2122
* [Offers for countries](#offers-for-countries)
2223
* [Return data structures](#return-data-structures)
2324
* [Locale, language, country](#locale-language-country)
@@ -35,10 +36,11 @@ pip install simple-justwatch-python-api
3536

3637
## Usage
3738

38-
This Python API has 3 functions:
39+
This Python API has 4 functions:
3940

4041
- `search` - search for entries based on title
4142
- `details` - get details for entry based on its node ID
43+
- `seasons` - get season details for show entry based on its node ID
4244
- `offers_for_countries` - get offers for entry based on its node ID, can look for offers
4345
in multiple countries
4446

@@ -112,6 +114,33 @@ Returned value is a single [`MediaEntry`](#return-data-structures) object.
112114
Example command and its output is in [`examples/details_output.py`](examples/details_output.py).
113115

114116

117+
### Seasons
118+
119+
Seasons function allows for looking up season and episode information for a single show entry via its node ID.
120+
Node ID can be taken from output of the [`search`](#search) command.
121+
122+
123+
```python
124+
from simplejustwatchapi.justwatch import seasons
125+
126+
results = seasons("nodeID", "US", "en")
127+
```
128+
129+
Only the first argument is required - the node ID of a show element to look up details for.
130+
131+
| | Argument | Type | Required | Default value | Description |
132+
|---|-------------|--------|----------|---------------|--------------------------------------------------------|
133+
| 1 | `node_id` | `str` | **YES** | - | Node ID to look up |
134+
| 2 | `country` | `str` | NO | `"US"` | Country to search for offers |
135+
| 3 | `language` | `str` | NO | `"en"` | Language of responses |
136+
137+
General usage of these arguments matches the [`search`](#search) command.
138+
139+
Returned value is a single [`SeasonsEntry`](#return-data-structures) object.
140+
141+
Example command and its output is in [`examples/seasons_output.py`](examples/seasons_output.py).
142+
143+
115144
### Offers for countries
116145

117146
This function allows looking up offers for entry by given node ID.

examples/seasons_output.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Output from command:
2+
# seasons("ts85167", "US", "en")
3+
4+
from simplejustwatchapi.query import (
5+
SeasonsEntry,
6+
Season,
7+
Episode,
8+
)
9+
10+
result = SeasonsEntry(
11+
entry_id='ts85167',
12+
seasons=[
13+
Season(seasonNumber=1,
14+
episodes=[
15+
Episode(seasonNumber=1, episodeNumber=1, title='Pilot'),
16+
Episode(seasonNumber=1, episodeNumber=2, title='City Council'),
17+
Episode(seasonNumber=1, episodeNumber=3, title='Werewolf Feud'),
18+
Episode(seasonNumber=1, episodeNumber=4, title='Manhattan Night Club'),
19+
Episode(seasonNumber=1, episodeNumber=5, title='Animal Control'),
20+
Episode(seasonNumber=1, episodeNumber=6, title="Baron's Night Out"),
21+
Episode(seasonNumber=1, episodeNumber=7, title='The Trial'),
22+
Episode(seasonNumber=1, episodeNumber=8, title='Citizenship'),
23+
Episode(seasonNumber=1, episodeNumber=9, title='The Orgy'),
24+
Episode(seasonNumber=1, episodeNumber=10, title='Ancestry')
25+
]),
26+
Season(seasonNumber=2,
27+
episodes=[
28+
Episode(seasonNumber=2, episodeNumber=1, title='Resurrection'),
29+
Episode(seasonNumber=2, episodeNumber=2, title='Ghosts'),
30+
Episode(seasonNumber=2, episodeNumber=3, title='Brain Scramblies'),
31+
Episode(seasonNumber=2, episodeNumber=4, title='The Curse'),
32+
Episode(seasonNumber=2, episodeNumber=5, title="Colin's Promotion"),
33+
Episode(seasonNumber=2, episodeNumber=6, title='On the Run'),
34+
Episode(seasonNumber=2, episodeNumber=7, title='The Return'),
35+
Episode(seasonNumber=2, episodeNumber=8, title='Collaboration'),
36+
Episode(seasonNumber=2, episodeNumber=9, title='Witches'),
37+
Episode(seasonNumber=2, episodeNumber=10, title='Nouveau Théâtre des Vampires')
38+
]),
39+
Season(seasonNumber=3,
40+
episodes=[
41+
Episode(seasonNumber=3, episodeNumber=1, title='The Prisoner'),
42+
Episode(seasonNumber=3, episodeNumber=2, title='The Cloak of Duplication'),
43+
Episode(seasonNumber=3, episodeNumber=3, title='Gail'),
44+
Episode(seasonNumber=3, episodeNumber=4, title='The Casino'),
45+
Episode(seasonNumber=3, episodeNumber=5, title='The Chamber of Judgement'),
46+
Episode(seasonNumber=3, episodeNumber=6, title='The Escape'),
47+
Episode(seasonNumber=3, episodeNumber=7, title='The Siren'),
48+
Episode(seasonNumber=3, episodeNumber=8, title='The Wellness Center'),
49+
Episode(seasonNumber=3, episodeNumber=9, title='A Farewell'),
50+
Episode(seasonNumber=3, episodeNumber=10, title='The Portrait')
51+
]),
52+
Season(seasonNumber=4,
53+
episodes=[Episode(seasonNumber=4, episodeNumber=1, title='Reunited'),
54+
Episode(seasonNumber=4, episodeNumber=2, title='The Lamp'),
55+
Episode(seasonNumber=4, episodeNumber=3, title='The Grand Opening'),
56+
Episode(seasonNumber=4, episodeNumber=4, title='The Night Market'),
57+
Episode(seasonNumber=4, episodeNumber=5, title='Private School'),
58+
Episode(seasonNumber=4, episodeNumber=6, title='The Wedding'),
59+
Episode(seasonNumber=4, episodeNumber=7, title='Pine Barrens'),
60+
Episode(seasonNumber=4, episodeNumber=8, title='Go Flip Yourself'),
61+
Episode(seasonNumber=4, episodeNumber=9, title='Freddie'),
62+
Episode(seasonNumber=4, episodeNumber=10, title='Sunrise, Sunset')
63+
]),
64+
Season(seasonNumber=5,
65+
episodes=[Episode(seasonNumber=5, episodeNumber=1, title='The Mall'),
66+
Episode(seasonNumber=5, episodeNumber=2, title='A Night Out with the Guys'),
67+
Episode(seasonNumber=5, episodeNumber=3, title='Pride Parade'),
68+
Episode(seasonNumber=5, episodeNumber=4, title='The Campaign'),
69+
Episode(seasonNumber=5, episodeNumber=5, title='Local News'),
70+
Episode(seasonNumber=5, episodeNumber=6, title='Urgent Care'),
71+
Episode(seasonNumber=5, episodeNumber=7, title='Hybrid Creatures'),
72+
Episode(seasonNumber=5, episodeNumber=8, title='The Roast'),
73+
Episode(seasonNumber=5, episodeNumber=9, title='A Weekend at Morrigan Manor'),
74+
Episode(seasonNumber=5, episodeNumber=10, title='Exit Interview')
75+
]),
76+
Season(seasonNumber=6,
77+
episodes=[
78+
Episode(seasonNumber=6, episodeNumber=1, title='Episode 1'),
79+
Episode(seasonNumber=6, episodeNumber=2, title='Episode 2'),
80+
Episode(seasonNumber=6, episodeNumber=3, title='Episode 3'),
81+
Episode(seasonNumber=6, episodeNumber=4, title='Episode 4'),
82+
Episode(seasonNumber=6, episodeNumber=5, title='Episode 5'),
83+
Episode(seasonNumber=6, episodeNumber=6, title='Episode 6'),
84+
Episode(seasonNumber=6, episodeNumber=7, title='Episode 7'),
85+
Episode(seasonNumber=6, episodeNumber=8, title='Episode 8'),
86+
Episode(seasonNumber=6, episodeNumber=9, title='Episode 9'),
87+
Episode(seasonNumber=6, episodeNumber=10, title='Episode 10'),
88+
Episode(seasonNumber=6, episodeNumber=11, title='Episode 11')
89+
])
90+
]
91+
)

src/simplejustwatchapi/justwatch.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@
44

55
from simplejustwatchapi.query import (
66
MediaEntry,
7+
SeasonsEntry,
78
Offer,
89
parse_details_response,
10+
parse_seasons_response,
911
parse_offers_for_countries_response,
1012
parse_search_response,
1113
prepare_details_request,
14+
prepare_seasons_request,
1215
prepare_offers_for_countries_request,
1316
prepare_search_request,
1417
)
@@ -67,6 +70,25 @@ def details(
6770
return parse_details_response(response.json())
6871

6972

73+
def seasons(
74+
node_id: str, country: str = "US", language: str = "en"
75+
) -> SeasonsEntry:
76+
"""Get show seasons for a given ID.
77+
78+
Args:
79+
node_id: ID of entry to look up
80+
country: country to search for offers, ``US`` by default
81+
language: language of responses, ``en`` by default
82+
83+
Returns:
84+
``SeasonsEntry`` NamedTuple with data about requested entry.
85+
"""
86+
request = prepare_seasons_request(node_id, country, language)
87+
response = post(_GRAPHQL_API_URL, json=request)
88+
response.raise_for_status()
89+
return parse_seasons_response(response.json())
90+
91+
7092
def offers_for_countries(
7193
node_id: str, countries: set[str], language: str = "en", best_only: bool = True
7294
) -> dict[str, list[Offer]]:

src/simplejustwatchapi/query.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,47 @@
66
_DETAILS_URL = "https://justwatch.com"
77
_IMAGES_URL = "https://images.justwatch.com"
88

9+
_GRAPHQL_SEASONS_QUERY = """
10+
fragment Episode on Episode {
11+
__typename
12+
id
13+
content(country: $country, language: $language) {
14+
title
15+
seasonNumber
16+
episodeNumber
17+
}
18+
}
19+
fragment Season on Season {
20+
__typename
21+
id
22+
content(country: $country, language: $language) {
23+
seasonNumber
24+
}
25+
episodes {
26+
...Episode
27+
}
28+
}
29+
fragment Show on Show {
30+
__typename
31+
id
32+
seasons {
33+
...Season
34+
}
35+
}
36+
fragment Node on Node {
37+
__typename
38+
id
39+
...Episode
40+
...Season
41+
...Show
42+
}
43+
query GetNodeById($nodeId: ID!, $country: Country!, $language: Language!) {
44+
node(id: $nodeId) {
45+
...Node
46+
}
47+
}
48+
"""
49+
950
_GRAPHQL_DETAILS_QUERY = """
1051
query GetTitleNode(
1152
$nodeId: ID!,
@@ -385,6 +426,39 @@ class MediaEntry(NamedTuple):
385426
"""List of available offers for this entry, empty if there are no available offers."""
386427

387428

429+
class Episode(NamedTuple):
430+
"""Parsed response from JustWatch GraphQL API for "GetNodeById" query for an episode."""
431+
432+
seasonNumber: int
433+
"""Season Number of show episode."""
434+
435+
episodeNumber: int
436+
"""Episode Number of show episode."""
437+
438+
title: str
439+
"""Title of show episode."""
440+
441+
442+
class Season(NamedTuple):
443+
"""Parsed response from JustWatch GraphQL API for "GetNodeById" query for a season."""
444+
445+
seasonNumber: int
446+
"""Season Number of show."""
447+
448+
episodes: list[Episode]
449+
"""List of season's episodes. """
450+
451+
452+
class SeasonsEntry(NamedTuple):
453+
"""Parsed response from JustWatch GraphQL API for "GetNodeById" query for show seasons."""
454+
455+
entry_id: str
456+
"""Entry ID, contains type code and numeric ID."""
457+
458+
seasons: list[Season]
459+
"""List of show seasons. """
460+
461+
388462
def prepare_search_request(
389463
title: str, country: str, language: str, count: int, best_only: bool
390464
) -> dict:
@@ -493,6 +567,52 @@ def parse_details_response(json: any) -> MediaEntry | None:
493567
return _parse_entry(json["data"]["node"]) if "errors" not in json else None
494568

495569

570+
def prepare_seasons_request(node_id: str, country: str, language: str) -> dict:
571+
"""Prepare a seasons request for specified node ID to JustWatch GraphQL API.
572+
Creates a ``GetNodeById`` GraphQL query.
573+
574+
Country code should be two uppercase letters, however it will be auto-converted to uppercase.
575+
576+
Meant to be used together with :func:`parse_seasons_response`.
577+
578+
Args:
579+
node_id: node ID of entry to get seasons for
580+
country: country to search for offers
581+
language: language of responses
582+
583+
Returns:
584+
JSON/dict with GraphQL POST body
585+
"""
586+
_assert_country_code_is_valid(country)
587+
return {
588+
"operationName": "GetNodeById",
589+
"variables": {
590+
"nodeId": node_id,
591+
"language": language,
592+
"country": country.upper(),
593+
},
594+
"query": _GRAPHQL_SEASONS_QUERY,
595+
}
596+
597+
def parse_seasons_response(json: any) -> SeasonsEntry | None:
598+
"""Parse response from seasons query from JustWatch GraphQL API.
599+
Parses response for ``GetNodeById`` query.
600+
601+
If API responded with an internal error (mostly due to not found node ID),
602+
then ``None`` will be returned instead.
603+
604+
Meant to be used together with :func:`prepare_seasons_request`.
605+
606+
Args:
607+
json: JSON returned by JustWatch GraphQL API
608+
609+
Returns:
610+
Parsed received JSON as a ``SeasonsEntry`` NamedTuple,
611+
or ``None`` in case data for a given node ID was not found
612+
"""
613+
return _parse_seasons(json["data"]["node"]) if "errors" not in json else None
614+
615+
496616
def prepare_offers_for_countries_request(
497617
node_id: str, countries: set[str], language: str, best_only: bool
498618
) -> dict:
@@ -617,6 +737,45 @@ def _parse_entry(json: any) -> MediaEntry:
617737
)
618738

619739

740+
def _parse_seasons(json: any) -> SeasonsEntry:
741+
if not json:
742+
return None
743+
entry_id = json.get("id")
744+
seasons = [_parse_season(edge) for edge in json.get("seasons", [])]
745+
746+
return SeasonsEntry(
747+
entry_id,
748+
seasons,
749+
)
750+
751+
752+
def _parse_season(json: any) -> Season:
753+
if not json:
754+
return None
755+
content = json.get("content")
756+
seasonNumber = content.get("seasonNumber")
757+
episodes = [_parse_episode(edge["content"]) for edge in json.get("episodes", [])]
758+
759+
return Season(
760+
seasonNumber,
761+
episodes,
762+
)
763+
764+
765+
def _parse_episode(json: any) -> Episode:
766+
if not json:
767+
return None
768+
seasonNumber = json.get("seasonNumber")
769+
episodeNumber = json.get("episodeNumber")
770+
title = json.get("title")
771+
772+
return Episode(
773+
seasonNumber,
774+
episodeNumber,
775+
title,
776+
)
777+
778+
620779
def _parse_scores(json: any) -> Scoring | None:
621780
if not json:
622781
return None

0 commit comments

Comments
 (0)