Skip to content

Seasons #12

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ A simple unofficial JustWatch Python API which uses [`GraphQL`](https://graphql.
* [Usage](#usage)
* [Search](#search)
* [Details](#details)
* [Seasons](#seasons)
* [Offers for countries](#offers-for-countries)
* [Return data structures](#return-data-structures)
* [Locale, language, country](#locale-language-country)
Expand All @@ -35,10 +36,11 @@ pip install simple-justwatch-python-api

## Usage

This Python API has 3 functions:
This Python API has 4 functions:

- `search` - search for entries based on title
- `details` - get details for entry based on its node ID
- `seasons` - get season details for show entry based on its node ID
- `offers_for_countries` - get offers for entry based on its node ID, can look for offers
in multiple countries

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


### Seasons

Seasons function allows for looking up season and episode information for a single show entry via its node ID.
Node ID can be taken from output of the [`search`](#search) command.


```python
from simplejustwatchapi.justwatch import seasons

results = seasons("nodeID", "US", "en")
```

Only the first argument is required - the node ID of a show element to look up details for.

| | Argument | Type | Required | Default value | Description |
|---|-------------|--------|----------|---------------|--------------------------------------------------------|
| 1 | `node_id` | `str` | **YES** | - | Node ID to look up |
| 2 | `country` | `str` | NO | `"US"` | Country to search for offers |
| 3 | `language` | `str` | NO | `"en"` | Language of responses |

General usage of these arguments matches the [`search`](#search) command.

Returned value is a single [`SeasonsEntry`](#return-data-structures) object.

Example command and its output is in [`examples/seasons_output.py`](examples/seasons_output.py).


### Offers for countries

This function allows looking up offers for entry by given node ID.
Expand Down
91 changes: 91 additions & 0 deletions examples/seasons_output.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Output from command:
# seasons("ts85167", "US", "en")

from simplejustwatchapi.query import (
SeasonsEntry,
Season,
Episode,
)

result = SeasonsEntry(
entry_id='ts85167',
seasons=[
Season(seasonNumber=1,
episodes=[
Episode(seasonNumber=1, episodeNumber=1, title='Pilot'),
Episode(seasonNumber=1, episodeNumber=2, title='City Council'),
Episode(seasonNumber=1, episodeNumber=3, title='Werewolf Feud'),
Episode(seasonNumber=1, episodeNumber=4, title='Manhattan Night Club'),
Episode(seasonNumber=1, episodeNumber=5, title='Animal Control'),
Episode(seasonNumber=1, episodeNumber=6, title="Baron's Night Out"),
Episode(seasonNumber=1, episodeNumber=7, title='The Trial'),
Episode(seasonNumber=1, episodeNumber=8, title='Citizenship'),
Episode(seasonNumber=1, episodeNumber=9, title='The Orgy'),
Episode(seasonNumber=1, episodeNumber=10, title='Ancestry')
]),
Season(seasonNumber=2,
episodes=[
Episode(seasonNumber=2, episodeNumber=1, title='Resurrection'),
Episode(seasonNumber=2, episodeNumber=2, title='Ghosts'),
Episode(seasonNumber=2, episodeNumber=3, title='Brain Scramblies'),
Episode(seasonNumber=2, episodeNumber=4, title='The Curse'),
Episode(seasonNumber=2, episodeNumber=5, title="Colin's Promotion"),
Episode(seasonNumber=2, episodeNumber=6, title='On the Run'),
Episode(seasonNumber=2, episodeNumber=7, title='The Return'),
Episode(seasonNumber=2, episodeNumber=8, title='Collaboration'),
Episode(seasonNumber=2, episodeNumber=9, title='Witches'),
Episode(seasonNumber=2, episodeNumber=10, title='Nouveau Théâtre des Vampires')
]),
Season(seasonNumber=3,
episodes=[
Episode(seasonNumber=3, episodeNumber=1, title='The Prisoner'),
Episode(seasonNumber=3, episodeNumber=2, title='The Cloak of Duplication'),
Episode(seasonNumber=3, episodeNumber=3, title='Gail'),
Episode(seasonNumber=3, episodeNumber=4, title='The Casino'),
Episode(seasonNumber=3, episodeNumber=5, title='The Chamber of Judgement'),
Episode(seasonNumber=3, episodeNumber=6, title='The Escape'),
Episode(seasonNumber=3, episodeNumber=7, title='The Siren'),
Episode(seasonNumber=3, episodeNumber=8, title='The Wellness Center'),
Episode(seasonNumber=3, episodeNumber=9, title='A Farewell'),
Episode(seasonNumber=3, episodeNumber=10, title='The Portrait')
]),
Season(seasonNumber=4,
episodes=[Episode(seasonNumber=4, episodeNumber=1, title='Reunited'),
Episode(seasonNumber=4, episodeNumber=2, title='The Lamp'),
Episode(seasonNumber=4, episodeNumber=3, title='The Grand Opening'),
Episode(seasonNumber=4, episodeNumber=4, title='The Night Market'),
Episode(seasonNumber=4, episodeNumber=5, title='Private School'),
Episode(seasonNumber=4, episodeNumber=6, title='The Wedding'),
Episode(seasonNumber=4, episodeNumber=7, title='Pine Barrens'),
Episode(seasonNumber=4, episodeNumber=8, title='Go Flip Yourself'),
Episode(seasonNumber=4, episodeNumber=9, title='Freddie'),
Episode(seasonNumber=4, episodeNumber=10, title='Sunrise, Sunset')
]),
Season(seasonNumber=5,
episodes=[Episode(seasonNumber=5, episodeNumber=1, title='The Mall'),
Episode(seasonNumber=5, episodeNumber=2, title='A Night Out with the Guys'),
Episode(seasonNumber=5, episodeNumber=3, title='Pride Parade'),
Episode(seasonNumber=5, episodeNumber=4, title='The Campaign'),
Episode(seasonNumber=5, episodeNumber=5, title='Local News'),
Episode(seasonNumber=5, episodeNumber=6, title='Urgent Care'),
Episode(seasonNumber=5, episodeNumber=7, title='Hybrid Creatures'),
Episode(seasonNumber=5, episodeNumber=8, title='The Roast'),
Episode(seasonNumber=5, episodeNumber=9, title='A Weekend at Morrigan Manor'),
Episode(seasonNumber=5, episodeNumber=10, title='Exit Interview')
]),
Season(seasonNumber=6,
episodes=[
Episode(seasonNumber=6, episodeNumber=1, title='Episode 1'),
Episode(seasonNumber=6, episodeNumber=2, title='Episode 2'),
Episode(seasonNumber=6, episodeNumber=3, title='Episode 3'),
Episode(seasonNumber=6, episodeNumber=4, title='Episode 4'),
Episode(seasonNumber=6, episodeNumber=5, title='Episode 5'),
Episode(seasonNumber=6, episodeNumber=6, title='Episode 6'),
Episode(seasonNumber=6, episodeNumber=7, title='Episode 7'),
Episode(seasonNumber=6, episodeNumber=8, title='Episode 8'),
Episode(seasonNumber=6, episodeNumber=9, title='Episode 9'),
Episode(seasonNumber=6, episodeNumber=10, title='Episode 10'),
Episode(seasonNumber=6, episodeNumber=11, title='Episode 11')
])
]
)
20 changes: 20 additions & 0 deletions src/simplejustwatchapi/justwatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@
from simplejustwatchapi.query import (
MediaEntry,
Offer,
SeasonsEntry,
parse_details_response,
parse_offers_for_countries_response,
parse_search_response,
parse_seasons_response,
prepare_details_request,
prepare_offers_for_countries_request,
prepare_search_request,
prepare_seasons_request,
)

_GRAPHQL_API_URL = "https://apis.justwatch.com/graphql"
Expand Down Expand Up @@ -67,6 +70,23 @@ def details(
return parse_details_response(response.json())


def seasons(node_id: str, country: str = "US", language: str = "en") -> SeasonsEntry:
"""Get show seasons for a given ID.

Args:
node_id: ID of entry to look up
country: country to search for offers, ``US`` by default
language: language of responses, ``en`` by default

Returns:
``SeasonsEntry`` NamedTuple with data about requested entry.
"""
request = prepare_seasons_request(node_id, country, language)
response = post(_GRAPHQL_API_URL, json=request)
response.raise_for_status()
return parse_seasons_response(response.json())


def offers_for_countries(
node_id: str, countries: set[str], language: str = "en", best_only: bool = True
) -> dict[str, list[Offer]]:
Expand Down
160 changes: 160 additions & 0 deletions src/simplejustwatchapi/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,47 @@
_DETAILS_URL = "https://justwatch.com"
_IMAGES_URL = "https://images.justwatch.com"

_GRAPHQL_SEASONS_QUERY = """
fragment Episode on Episode {
__typename
id
content(country: $country, language: $language) {
title
seasonNumber
episodeNumber
}
}
fragment Season on Season {
__typename
id
content(country: $country, language: $language) {
seasonNumber
}
episodes {
...Episode
}
}
fragment Show on Show {
__typename
id
seasons {
...Season
}
}
fragment Node on Node {
__typename
id
...Episode
...Season
...Show
}
query GetNodeById($nodeId: ID!, $country: Country!, $language: Language!) {
node(id: $nodeId) {
...Node
}
}
"""

_GRAPHQL_DETAILS_QUERY = """
query GetTitleNode(
$nodeId: ID!,
Expand Down Expand Up @@ -385,6 +426,39 @@ class MediaEntry(NamedTuple):
"""List of available offers for this entry, empty if there are no available offers."""


class Episode(NamedTuple):
"""Parsed response from JustWatch GraphQL API for "GetNodeById" query for an episode."""

seasonNumber: int
"""Season Number of show episode."""

episodeNumber: int
"""Episode Number of show episode."""

title: str
"""Title of show episode."""


class Season(NamedTuple):
"""Parsed response from JustWatch GraphQL API for "GetNodeById" query for a season."""

seasonNumber: int
"""Season Number of show."""

episodes: list[Episode]
"""List of season's episodes. """


class SeasonsEntry(NamedTuple):
"""Parsed response from JustWatch GraphQL API for "GetNodeById" query for show seasons."""

entry_id: str
"""Entry ID, contains type code and numeric ID."""

seasons: list[Season]
"""List of show seasons. """


def prepare_search_request(
title: str, country: str, language: str, count: int, best_only: bool
) -> dict:
Expand Down Expand Up @@ -493,6 +567,53 @@ def parse_details_response(json: any) -> MediaEntry | None:
return _parse_entry(json["data"]["node"]) if "errors" not in json else None


def prepare_seasons_request(node_id: str, country: str, language: str) -> dict:
"""Prepare a seasons request for specified node ID to JustWatch GraphQL API.
Creates a ``GetNodeById`` GraphQL query.

Country code should be two uppercase letters, however it will be auto-converted to uppercase.

Meant to be used together with :func:`parse_seasons_response`.

Args:
node_id: node ID of entry to get seasons for
country: country to search for offers
language: language of responses

Returns:
JSON/dict with GraphQL POST body
"""
_assert_country_code_is_valid(country)
return {
"operationName": "GetNodeById",
"variables": {
"nodeId": node_id,
"language": language,
"country": country.upper(),
},
"query": _GRAPHQL_SEASONS_QUERY,
}


def parse_seasons_response(json: any) -> SeasonsEntry | None:
"""Parse response from seasons query from JustWatch GraphQL API.
Parses response for ``GetNodeById`` query.

If API responded with an internal error (mostly due to not found node ID),
then ``None`` will be returned instead.

Meant to be used together with :func:`prepare_seasons_request`.

Args:
json: JSON returned by JustWatch GraphQL API

Returns:
Parsed received JSON as a ``SeasonsEntry`` NamedTuple,
or ``None`` in case data for a given node ID was not found
"""
return _parse_seasons(json["data"]["node"]) if "errors" not in json else None


def prepare_offers_for_countries_request(
node_id: str, countries: set[str], language: str, best_only: bool
) -> dict:
Expand Down Expand Up @@ -617,6 +738,45 @@ def _parse_entry(json: any) -> MediaEntry:
)


def _parse_seasons(json: any) -> SeasonsEntry:
if not json:
return None
entry_id = json.get("id")
seasons = [_parse_season(edge) for edge in json.get("seasons", [])]

return SeasonsEntry(
entry_id,
seasons,
)


def _parse_season(json: any) -> Season:
if not json:
return None
content = json.get("content")
seasonNumber = content.get("seasonNumber")
episodes = [_parse_episode(edge["content"]) for edge in json.get("episodes", [])]

return Season(
seasonNumber,
episodes,
)


def _parse_episode(json: any) -> Episode:
if not json:
return None
seasonNumber = json.get("seasonNumber")
episodeNumber = json.get("episodeNumber")
title = json.get("title")

return Episode(
seasonNumber,
episodeNumber,
title,
)


def _parse_scores(json: any) -> Scoring | None:
if not json:
return None
Expand Down
Loading
Loading