diff --git a/README.md b/README.md index b51f226..3bb21e1 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 @@ -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. diff --git a/examples/seasons_output.py b/examples/seasons_output.py new file mode 100644 index 0000000..1dda21e --- /dev/null +++ b/examples/seasons_output.py @@ -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') + ]) + ] +) diff --git a/src/simplejustwatchapi/justwatch.py b/src/simplejustwatchapi/justwatch.py index bfdc532..0f97fbf 100644 --- a/src/simplejustwatchapi/justwatch.py +++ b/src/simplejustwatchapi/justwatch.py @@ -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" @@ -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]]: diff --git a/src/simplejustwatchapi/query.py b/src/simplejustwatchapi/query.py index eededd7..6ded63d 100644 --- a/src/simplejustwatchapi/query.py +++ b/src/simplejustwatchapi/query.py @@ -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!, @@ -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: @@ -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: @@ -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 diff --git a/test/simplejustwatchapi/test_justwatch.py b/test/simplejustwatchapi/test_justwatch.py index ab0f618..eb889b0 100644 --- a/test/simplejustwatchapi/test_justwatch.py +++ b/test/simplejustwatchapi/test_justwatch.py @@ -2,12 +2,13 @@ from pytest import fixture -from simplejustwatchapi.justwatch import details, offers_for_countries, search +from simplejustwatchapi.justwatch import details, offers_for_countries, search, seasons JUSTWATCH_GRAPHQL_URL = "https://apis.justwatch.com/graphql" SEARCH_INPUT = ("TITLE", "COUNTRY", "LANGUAGE", 5, True) DETAILS_INPUT = ("NODE ID", "COUNTRY", "LANGUAGE", False) +SEASONS_INPUT = ("NODE ID", "COUNTRY", "LANGUAGE") OFFERS_COUNTRIES_INPUT = {"COUNTRY1", "COUNTRY2", "COUNTRY3"} OFFERS_INPUT = ("NODE ID", OFFERS_COUNTRIES_INPUT, "LANGUAGE", True) @@ -43,6 +44,15 @@ def test_details(requests_mock, parser_mock, httpx_post_mock): assert results == DUMMY_ENTRIES +@patch("simplejustwatchapi.justwatch.parse_seasons_response", return_value=DUMMY_ENTRIES) +@patch("simplejustwatchapi.justwatch.prepare_seasons_request", return_value=DUMMY_REQUEST) +def test_seasons(requests_mock, parser_mock, httpx_post_mock): + results = seasons(*SEASONS_INPUT) + requests_mock.assert_called_with(*SEASONS_INPUT) + parser_mock.assert_called_with(DUMMY_RESPONSE) + assert results == DUMMY_ENTRIES + + @patch( "simplejustwatchapi.justwatch.parse_offers_for_countries_response", return_value=DUMMY_ENTRIES ) diff --git a/test/simplejustwatchapi/test_parser.py b/test/simplejustwatchapi/test_parser.py index 90f8b40..236b5e5 100644 --- a/test/simplejustwatchapi/test_parser.py +++ b/test/simplejustwatchapi/test_parser.py @@ -1,15 +1,19 @@ from pytest import mark from simplejustwatchapi.query import ( + Episode, Interactions, MediaEntry, Offer, OfferPackage, Scoring, + Season, + SeasonsEntry, StreamingCharts, parse_details_response, parse_offers_for_countries_response, parse_search_response, + parse_seasons_response, ) DETAILS_URL = "https://justwatch.com" @@ -368,6 +372,496 @@ [], ) +PARSED_NODE_4 = 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"), + ], + ), + ], +) +RESPONSE_NODE_4 = { + "__typename": "Show", + "id": "ts85167", + "seasons": [ + { + "__typename": "Season", + "content": {"seasonNumber": 1}, + "episodes": [ + { + "__typename": "Episode", + "content": {"episodeNumber": 1, "seasonNumber": 1, "title": "Pilot"}, + "id": "tse1631894", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 2, "seasonNumber": 1, "title": "City Council"}, + "id": "tse1747980", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 3, "seasonNumber": 1, "title": "Werewolf Feud"}, + "id": "tse1747981", + }, + { + "__typename": "Episode", + "content": { + "episodeNumber": 4, + "seasonNumber": 1, + "title": "Manhattan Night Club", + }, + "id": "tse1747982", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 5, "seasonNumber": 1, "title": "Animal Control"}, + "id": "tse1747983", + }, + { + "__typename": "Episode", + "content": { + "episodeNumber": 6, + "seasonNumber": 1, + "title": "Baron's Night Out", + }, + "id": "tse1769781", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 7, "seasonNumber": 1, "title": "The Trial"}, + "id": "tse1777016", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 8, "seasonNumber": 1, "title": "Citizenship"}, + "id": "tse1797942", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 9, "seasonNumber": 1, "title": "The Orgy"}, + "id": "tse1797943", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 10, "seasonNumber": 1, "title": "Ancestry"}, + "id": "tse1797944", + }, + ], + "id": "tss98319", + }, + { + "__typename": "Season", + "content": {"seasonNumber": 2}, + "episodes": [ + { + "__typename": "Episode", + "content": {"episodeNumber": 1, "seasonNumber": 2, "title": "Resurrection"}, + "id": "tse3177541", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 2, "seasonNumber": 2, "title": "Ghosts"}, + "id": "tse4343145", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 3, "seasonNumber": 2, "title": "Brain Scramblies"}, + "id": "tse4366752", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 4, "seasonNumber": 2, "title": "The Curse"}, + "id": "tse4366760", + }, + { + "__typename": "Episode", + "content": { + "episodeNumber": 5, + "seasonNumber": 2, + "title": "Colin's Promotion", + }, + "id": "tse4366763", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 6, "seasonNumber": 2, "title": "On the Run"}, + "id": "tse4366757", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 7, "seasonNumber": 2, "title": "The Return"}, + "id": "tse4366754", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 8, "seasonNumber": 2, "title": "Collaboration"}, + "id": "tse4366751", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 9, "seasonNumber": 2, "title": "Witches"}, + "id": "tse4366755", + }, + { + "__typename": "Episode", + "content": { + "episodeNumber": 10, + "seasonNumber": 2, + "title": "Nouveau Théâtre des " "Vampires", + }, + "id": "tse4366769", + }, + ], + "id": "tss200608", + }, + { + "__typename": "Season", + "content": {"seasonNumber": 3}, + "episodes": [ + { + "__typename": "Episode", + "content": {"episodeNumber": 1, "seasonNumber": 3, "title": "The Prisoner"}, + "id": "tse5761373", + }, + { + "__typename": "Episode", + "content": { + "episodeNumber": 2, + "seasonNumber": 3, + "title": "The Cloak of Duplication", + }, + "id": "tse5761374", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 3, "seasonNumber": 3, "title": "Gail"}, + "id": "tse5863479", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 4, "seasonNumber": 3, "title": "The Casino"}, + "id": "tse5863485", + }, + { + "__typename": "Episode", + "content": { + "episodeNumber": 5, + "seasonNumber": 3, + "title": "The Chamber of Judgement", + }, + "id": "tse5863482", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 6, "seasonNumber": 3, "title": "The Escape"}, + "id": "tse5863480", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 7, "seasonNumber": 3, "title": "The Siren"}, + "id": "tse5863486", + }, + { + "__typename": "Episode", + "content": { + "episodeNumber": 8, + "seasonNumber": 3, + "title": "The Wellness Center", + }, + "id": "tse5863484", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 9, "seasonNumber": 3, "title": "A Farewell"}, + "id": "tse5863483", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 10, "seasonNumber": 3, "title": "The Portrait"}, + "id": "tse5863481", + }, + ], + "id": "tss306291", + }, + { + "__typename": "Season", + "content": {"seasonNumber": 4}, + "episodes": [ + { + "__typename": "Episode", + "content": {"episodeNumber": 1, "seasonNumber": 4, "title": "Reunited"}, + "id": "tse6569917", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 2, "seasonNumber": 4, "title": "The Lamp"}, + "id": "tse6569916", + }, + { + "__typename": "Episode", + "content": { + "episodeNumber": 3, + "seasonNumber": 4, + "title": "The Grand Opening", + }, + "id": "tse6569915", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 4, "seasonNumber": 4, "title": "The Night Market"}, + "id": "tse6638945", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 5, "seasonNumber": 4, "title": "Private School"}, + "id": "tse6638946", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 6, "seasonNumber": 4, "title": "The Wedding"}, + "id": "tse6638941", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 7, "seasonNumber": 4, "title": "Pine Barrens"}, + "id": "tse6638942", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 8, "seasonNumber": 4, "title": "Go Flip Yourself"}, + "id": "tse6638947", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 9, "seasonNumber": 4, "title": "Freddie"}, + "id": "tse6638944", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 10, "seasonNumber": 4, "title": "Sunrise, Sunset"}, + "id": "tse6638943", + }, + ], + "id": "tss357215", + }, + { + "__typename": "Season", + "content": {"seasonNumber": 5}, + "episodes": [ + { + "__typename": "Episode", + "content": {"episodeNumber": 1, "seasonNumber": 5, "title": "The Mall"}, + "id": "tse7422284", + }, + { + "__typename": "Episode", + "content": { + "episodeNumber": 2, + "seasonNumber": 5, + "title": "A Night Out with the Guys", + }, + "id": "tse7422285", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 3, "seasonNumber": 5, "title": "Pride Parade"}, + "id": "tse7482502", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 4, "seasonNumber": 5, "title": "The Campaign"}, + "id": "tse7528347", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 5, "seasonNumber": 5, "title": "Local News"}, + "id": "tse7528354", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 6, "seasonNumber": 5, "title": "Urgent Care"}, + "id": "tse7528351", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 7, "seasonNumber": 5, "title": "Hybrid Creatures"}, + "id": "tse7528350", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 8, "seasonNumber": 5, "title": "The Roast"}, + "id": "tse7528352", + }, + { + "__typename": "Episode", + "content": { + "episodeNumber": 9, + "seasonNumber": 5, + "title": "A Weekend at Morrigan Manor", + }, + "id": "tse7528353", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 10, "seasonNumber": 5, "title": "Exit Interview"}, + "id": "tse7528349", + }, + ], + "id": "tss361882", + }, + { + "__typename": "Season", + "content": {"seasonNumber": 6}, + "episodes": [ + { + "__typename": "Episode", + "content": {"episodeNumber": 1, "seasonNumber": 6, "title": "Episode 1"}, + "id": "tse7528348", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 2, "seasonNumber": 6, "title": "Episode 2"}, + "id": "tse8544433", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 3, "seasonNumber": 6, "title": "Episode 3"}, + "id": "tse8544439", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 4, "seasonNumber": 6, "title": "Episode 4"}, + "id": "tse8544449", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 5, "seasonNumber": 6, "title": "Episode 5"}, + "id": "tse8544457", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 6, "seasonNumber": 6, "title": "Episode 6"}, + "id": "tse8544465", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 7, "seasonNumber": 6, "title": "Episode 7"}, + "id": "tse8544476", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 8, "seasonNumber": 6, "title": "Episode 8"}, + "id": "tse8544483", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 9, "seasonNumber": 6, "title": "Episode 9"}, + "id": "tse8544494", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 10, "seasonNumber": 6, "title": "Episode 10"}, + "id": "tse8544502", + }, + { + "__typename": "Episode", + "content": {"episodeNumber": 11, "seasonNumber": 6, "title": "Episode 11"}, + "id": "tse8544521", + }, + ], + "id": "tss361883", + }, + ], +} + API_SEARCH_RESPONSE_JSON = { "data": { "popularTitles": { @@ -409,6 +903,18 @@ def test_parse_details_response(response_json: dict, expected_output: MediaEntry assert parsed_entries == expected_output +@mark.parametrize( + argnames=["response_json", "expected_output"], + argvalues=[ + ({"data": {"node": RESPONSE_NODE_4}}, PARSED_NODE_4), + ({"errors": [], "data": {"node": None}}, None), + ], +) +def test_parse_seasons_response(response_json: dict, expected_output: SeasonsEntry) -> None: + parsed_entries = parse_seasons_response(response_json) + assert parsed_entries == expected_output + + @mark.parametrize( argnames=["response_json", "countries", "expected_output"], argvalues=[ diff --git a/test/simplejustwatchapi/test_request.py b/test/simplejustwatchapi/test_request.py index 44f02bc..645706f 100644 --- a/test/simplejustwatchapi/test_request.py +++ b/test/simplejustwatchapi/test_request.py @@ -4,6 +4,7 @@ prepare_details_request, prepare_offers_for_countries_request, prepare_search_request, + prepare_seasons_request, ) GRAPHQL_DETAILS_QUERY = """ @@ -25,6 +26,47 @@ } """ +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_SEARCH_QUERY = """ query GetSearchTitles( $searchTitlesFilter: TitleFilter!, @@ -268,6 +310,42 @@ def test_prepare_details_request_asserts_on_invalid_country_code(invalid_code: s assert str(error.value) == expected_error_message +@mark.parametrize( + argnames=["node_id", "country", "language"], + argvalues=[ + ("NODE ID 1", "US", "language 1"), + ("NODE ID 1", "gb", "language 2"), + ], +) +def test_prepare_seasons_request(node_id: str, country: str, language: str) -> None: + expected_request = { + "operationName": "GetNodeById", + "variables": { + "nodeId": node_id, + "language": language, + "country": country.upper(), + }, + "query": GRAPHQL_SEASONS_QUERY, + } + request = prepare_seasons_request(node_id, country, language) + assert expected_request == request + + +@mark.parametrize( + argnames=["invalid_code"], + argvalues=[ + ("United Stated of America",), # too long + ("usa",), # too long + ("u",), # too short + ], +) +def test_prepare_seasons_request_asserts_on_invalid_country_code(invalid_code: str) -> None: + expected_error_message = f"Invalid country code: {invalid_code}, code must be 2 characters long" + with raises(AssertionError) as error: + prepare_seasons_request("", invalid_code, "") + assert str(error.value) == expected_error_message + + @mark.parametrize( argnames=["node_id", "countries", "language", "best_only"], argvalues=[