diff --git a/docs/usage.md b/docs/usage.md index 218c150..319f346 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -47,23 +47,25 @@ You can check [Exceptions](API Reference/exceptions.md) page for more details. Most functions have a number of common arguments (in addition to function-specific ones, like `title` to search for): -| Name | Description | -|-------------|-------------| -| `country` | 2-letter country code for which offers will be returned, e.g., `US`, `GB`, `DE`. | -| `language` | Code for language in responses. It consists of 2 lowercase letters with optional uppercase alphanumeric suffix (e.g., `en`, `en-US`, `de`, `de-CH1901`). | +| Name | Description | +|------|-------------| +| `country` | 2-letter country code for which offers will be returned, e.g., `US`, `GB`, `DE`. | +| `language` | Code for language in responses. It consists of 2 lowercase letters with optional uppercase alphanumeric suffix (e.g., `en`, `en-US`, `de`, `de-CH1901`). | | `best_only` | Whether to return only "best" offers for each provider instead of, e.g., separate offers for SD, HD, and 4K. | Functions returning data for multiple titles ([`search`][simplejustwatchapi.justwatch.search], [`popular`][simplejustwatchapi.justwatch.popular]) -also allow for specifying number of elements, basic pagination, and filtering for -specific providers: +also allow for specifying number of elements, basic pagination, and optional filtering: -| Name | Description | -|-------------|-------------| -| `count` | How many entries should be returned. | -| `offset` | Basic "pagination". Offset for the first returned result, i.e. how many first entries should be skipped. Everything is handled on API side, this library isn't doing any filtering. | -| `providers` | Providers (like Netflix, Amazon Prime Video) for which offers should returned. Requires 3-letter "short name". Check [Provider codes](caveats.md#provider-codes) page for an example of how you can get that value. +| Name | Description | +|------|-------------| +| `count` | How many entries should be returned. | +| `offset` | Basic "pagination". Offset for the first returned result, i.e. how many first entries should be skipped. Everything is handled on API side, this library isn't doing any filtering. | +| `providers` | Providers (like Netflix, Amazon Prime Video) for which offers should returned. Requires 3-letter "short name". Check [Provider codes](caveats.md#provider-codes) page for an example of how you can get that value. | +| `min_release_year` | Minimum release year for a title. | +| `max_release_year` | Maximum release year for a title. | +| `object_types` | Types of objects to filter for, such as `SHOW` or `MOVIE`. Check [Additional filtering](#additional-filtering-in-search-and-popular) section for more details. | ### Search for a title @@ -502,3 +504,47 @@ while results := popular(count=page, offset=i): this method. Check [Maximum number of entries](caveats.md#maximum-number-of-entries) page for more details. + + + +## Additional filtering in [`search`](#search-for-a-title) and [`popular`](#popular-titles) + +[`search`](#search-for-a-title) and [`popular`](#popular-titles) functions allow for +additional filtering of returned entries. + +| Argument name | Description | +|---------------|-------------| +| `min_release_year` | Minimum release year. | +| `max_release_year` | Maximum release year. | +| `object_types` | Media types, like `SHOW` or `MOVIE`. | + +All filters are optional; when not configured (or set to `None`) the filtering is +disabled. + +`object_types` is a list of strings (or for one type just a single string), +like `SHOW`, or `MOVIE`. You can check +[`examples/`](https://github.com/Electronic-Mango/simple-justwatch-python-api/tree/main/examples) +for field `object_type`, however the only "useful" ones seem to be `SHOW` and `MOVIE`. +You can use types like `SHOW_EPISODE`, or `SHOW_SEASON`, however they seem to default +to "TV shows" - same as for `SHOW`. + +!!! warning "Type values are not enforced, but must be valid" + Possible values for types are not enforced by functions in this API, but they still + **must** be valid types in JustWatch API. Using unexpected type will result in HTTP + error with code 422. + +```python +from simplejustwatchapi import popular, search + +# Get only currently popular TV shows: +popular_shows = popular(object_types=["SHOW"]) + +# Search for movies between 1990 and 2010: +movies = search( + "The Matrix", + min_release_year=1990, + max_release_year=2010, + object_types="MOVIE" +) +# "object_types" with single type can be either a list, or just a string. +``` diff --git a/src/simplejustwatchapi/justwatch.py b/src/simplejustwatchapi/justwatch.py index 58db0e0..ae6cc9f 100644 --- a/src/simplejustwatchapi/justwatch.py +++ b/src/simplejustwatchapi/justwatch.py @@ -9,11 +9,11 @@ Most functions have a number of common arguments (in addition to function-specific ones, like `title` to search for): -| Name | Description | -|-------------|-------------| -| `country` | 2-letter country code for which offers are selected, (e.g., `US`, \ +| Name | Description | +|------|-------------| +| `country` | 2-letter country code for which offers are selected, (e.g., `US`, \ `GB`, `DE`). | -| `language` | Code for language in responses. It consists of 2 lowercase letters \ +| `language` | Code for language in responses. It consists of 2 lowercase letters \ with optional uppercase alphanumeric suffix (e.g., `en`, `en-US`, \ `de`, `de-CH1901`). | | `best_only` | Whether to return only "best" offers for each provider instead of, \ @@ -22,19 +22,23 @@ Functions returning data for multiple titles ([`search`][simplejustwatchapi.justwatch.search], [`popular`][simplejustwatchapi.justwatch.popular]) -also allow for specifying number of elements, basic pagination, and filtering for -specific providers: +also allow for specifying number of elements, basic pagination, and additional +filtering: -| Name | Description | -|-------------|-------------| -| `count` | How many entries should be returned. | -| `offset` | Basic "pagination". Offset for the first returned result, i.e. how \ +| Name | Description | +|------|-------------| +| `count` | How many entries should be returned. | +| `offset` | Basic "pagination". Offset for the first returned result, i.e. how \ many first entries should be skipped. Everything is handled on API \ side, this library isn't doing any filtering. | | `providers` | Providers (like Netflix, Amazon Prime Video) for which offers should \ returned. Requires 3-letter "short name". Check \ [`providers`][simplejustwatchapi.justwatch.providers] for an example \ - of how you can get that value. + of how you can get that value. | +| `min_release_year` | Minimum release year of returned titles. | +| `max_release_year` | Maximum release year of returned titles. | +| `object_types` | Types of objects to filter for. It seems that only `SHOW` and \ + `MOVIE` are useful, but it's not strictly enforced. | Each function can raise two exceptions: @@ -79,6 +83,9 @@ def search( best_only: bool = True, offset: int = 0, providers: list[str] | str | None = None, + min_release_year: int | None = None, + max_release_year: int | None = None, + object_types: list[str] | str | None = None, ) -> list[MediaEntry]: """ Search JustWatch for the given title. @@ -148,18 +155,50 @@ def search( You can look up values through [`providers`] [simplejustwatchapi.justwatch.providers] function. + min_release_year (int | None): Minimum release year of returned titles. + + If `None` (the default value), no filtering is done. + + max_release_year (int | None): Maximum release year of returned titles. + + If `None` (the default value), no filtering is done. + + object_types (list[str] | str | None): Types of objects to filter for, like + `SHOW` or `MOVIE`. + + It seems that only `SHOW` and `MOVIE` are useful, but it's not strictly + enforced. Types like `SHOW_EPISODE`, or `SHOW_SEASON` can be used, but they + seem to return TV shows, same as `SHOW`. + + While the type value is not enforced, it **must** be a valid type, otherwise + API will respond with HTTP status code 422. + + For single type it can be a single string, or a list with one string. + + If `None` (the default value), no filtering is done. + Returns: (list[MediaEntry]): List of tuples with details of search results. Raises: exceptions.JustWatchApiError: JSON response from API has internal errors, e.g., due to invalid language or country code. + exceptions.JustWatchHttpError: HTTP error occurred, e.g., JustWatch API responded with non-`2xx` status code. """ request = prepare_search_request( - title, country, language, count, best_only, offset, providers + title, + country, + language, + count, + best_only, + offset, + providers, + min_release_year, + max_release_year, + object_types, ) response = _post_to_jw_graphql_api(request) return parse_search_response(response) @@ -172,6 +211,9 @@ def popular( best_only: bool = True, offset: int = 0, providers: list[str] | str | None = None, + min_release_year: int | None = None, + max_release_year: int | None = None, + object_types: list[str] | str | None = None, ) -> list[MediaEntry]: """ Look up all currently popular titles on JustWatch. @@ -233,18 +275,47 @@ def popular( You can look up values through [`providers`] [simplejustwatchapi.justwatch.providers] function. + min_release_year (int | None): Minimum release year of returned titles. + If `None` (the default value), no filtering is done. + + max_release_year (int | None): Maximum release year of returned titles. + If `None` (the default value), no filtering is done. + + object_types (list[str] | str | None): Types of objects to filter for, like + `SHOW` or `MOVIE`. + + It seems that only `SHOW` and `MOVIE` are useful, but it's not strictly + enforced. Types like `SHOW_EPISODE`, or `SHOW_SEASON` can be used, but they + seem to return TV shows, same as `SHOW`. + + While the type value is not enforced, it **must** be a valid type, otherwise + API will respond with HTTP status code 422. + + For single type it can be a single string, or a list with one string. + + If `None` (the default value), no filtering is done. + Returns: (list[MediaEntry]): List of tuples with details of popular titles. Raises: exceptions.JustWatchApiError: JSON response from API has internal errors, e.g., due to invalid language or country code. + exceptions.JustWatchHttpError: HTTP error occurred, e.g., JustWatch API responded with non-`2xx` status code. """ request = prepare_popular_request( - country, language, count, best_only, offset, providers + country, + language, + count, + best_only, + offset, + providers, + min_release_year, + max_release_year, + object_types, ) response = _post_to_jw_graphql_api(request) return parse_popular_response(response) @@ -309,6 +380,7 @@ def details( Raises: exceptions.JustWatchApiError: JSON response from API has internal errors, e.g., due to invalid language or country code. + exceptions.JustWatchHttpError: HTTP error occurred, e.g., JustWatch API responded with non-`2xx` status code. @@ -358,6 +430,7 @@ def seasons( Raises: exceptions.JustWatchApiError: JSON response from API has internal errors, e.g., due to invalid language or country code. + exceptions.JustWatchHttpError: HTTP error occurred, e.g., JustWatch API responded with non-`2xx` status code. @@ -408,6 +481,7 @@ def episodes( Raises: exceptions.JustWatchApiError: JSON response from API has internal errors, e.g., due to invalid language or country code. + exceptions.JustWatchHttpError: HTTP error occurred, e.g., JustWatch API responded with non-`2xx` status code. @@ -477,6 +551,7 @@ def offers_for_countries( Raises: exceptions.JustWatchApiError: JSON response from API has internal errors, e.g., due to invalid language or country code. + exceptions.JustWatchHttpError: HTTP error occurred, e.g., JustWatch API responded with non-`2xx` status code. diff --git a/src/simplejustwatchapi/query.py b/src/simplejustwatchapi/query.py index f90ad71..0efda10 100644 --- a/src/simplejustwatchapi/query.py +++ b/src/simplejustwatchapi/query.py @@ -44,6 +44,9 @@ def prepare_search_request( best_only: bool, offset: int, providers: list[str] | str | None, + min_release_year: int | None, + max_release_year: int | None, + object_types: list[str] | str | None, ) -> dict[str, Any]: """ Prepare search request for JustWatch GraphQL API. @@ -66,6 +69,10 @@ def prepare_search_request( offset (int): Search results offset. providers (list[str] | str | None): 3-letter service identifier(s), or `None` for all providers. + min_release_year (int | None): Minimum release year of returned titles. + max_release_year (int | None): Maximum release year of returned titles. + object_types (list[str] | str | None): Types of objects to filter for, it seems + that only "SHOW" and "MOVIE" make sense. Returns: (dict[str, Any]): JSON with GraphQL POST body. @@ -75,7 +82,11 @@ def prepare_search_request( "operationName": "GetSearchTitles", "variables": { "first": count, - "searchTitlesFilter": {"searchQuery": title, "packages": providers}, + "searchTitlesFilter": { + "searchQuery": title, + "packages": providers, + **_filter_variables(min_release_year, max_release_year, object_types), + }, **_common_variables(best_only), **_locale_variables(country, language), "offset": offset or None, @@ -118,6 +129,9 @@ def prepare_popular_request( best_only: bool, offset: int, providers: list[str] | str | None, + min_release_year: int | None, + max_release_year: int | None, + object_types: list[str] | str | None, ) -> dict[str, Any]: """ Prepare "get popular" request for JustWatch GraphQL API. @@ -139,6 +153,10 @@ def prepare_popular_request( offset (int): Search results offset. providers (list[str] | str | None): 3-letter service identifier(s), or `None` for all providers. + min_release_year (int | None): Minimum release year of returned titles. + max_release_year (int | None): Maximum release year of returned titles. + object_types (list[str] | str | None): Types of objects to filter for, it seems + that only "SHOW" and "MOVIE" make sense. Returns: (dict[str, Any]): JSON with GraphQL POST body. @@ -148,7 +166,10 @@ def prepare_popular_request( "operationName": "GetPopularTitles", "variables": { "first": count, - "popularTitlesFilter": {"packages": providers}, + "popularTitlesFilter": { + "packages": providers, + **_filter_variables(min_release_year, max_release_year, object_types), + }, **_common_variables(best_only), **_locale_variables(country, language), "offset": offset or None, @@ -507,6 +528,19 @@ def _locale_variables(country: str, language: str) -> dict[str, str]: return {"country": country.upper(), "language": language} +def _filter_variables( + min_release_year: int | None, + max_release_year: int | None, + object_types: list[str] | str | None, +) -> dict[str, Any]: + """Return dict with variables related to looking up lists of titles.""" + return { + "includeTitlesWithoutUrl": True, + "objectTypes": object_types, + "releaseYear": {"min": min_release_year, "max": max_release_year}, + } + + def _raise_for_errors_in_response(json: dict[str, Any]) -> None: """Raise JustWatchApiError if given JSON contains `errors` key.""" if "errors" in json: diff --git a/src/simplejustwatchapi/tuples.py b/src/simplejustwatchapi/tuples.py index a608185..38e5cd8 100644 --- a/src/simplejustwatchapi/tuples.py +++ b/src/simplejustwatchapi/tuples.py @@ -222,7 +222,8 @@ class MediaEntry(NamedTuple): object_id (int): Object ID, the numeric part of full entry ID. object_type (str): Type of entry, e.g. `MOVIE`, `SHOW`. title (str): Full title. - url (str): URL to JustWatch with details for this entry. + url (str | None): URL to JustWatch with details for this entry. Some entries + are missing dedicated JustWatch pages, for them this field is `None`. release_year (int): Release year as a number. release_date (str): Full release date as a string, e.g. `2013-12-16`. runtime_minutes (int): Runtime in minutes. @@ -255,7 +256,7 @@ class MediaEntry(NamedTuple): object_id: int object_type: str title: str - url: str + url: str | None release_year: int release_date: str runtime_minutes: int diff --git a/test/simplejustwatchapi/test_justwatch.py b/test/simplejustwatchapi/test_justwatch.py index d7c0089..e3ce9a8 100644 --- a/test/simplejustwatchapi/test_justwatch.py +++ b/test/simplejustwatchapi/test_justwatch.py @@ -16,8 +16,29 @@ JUSTWATCH_GRAPHQL_URL = "https://apis.justwatch.com/graphql" -SEARCH_INPUT = ("TITLE", "COUNTRY", "LANGUAGE", 5, True, 10, ["prov1", "prov2"]) -POPULAR_INPUT = ("COUNTRY", "LANGUAGE", 5, True, 10, ["prov1", "prov2"]) +SEARCH_INPUT = ( + "TITLE", + "COUNTRY", + "LANGUAGE", + 5, + True, + 10, + ["prov1", "prov2"], + 1990, + 2000, + ["SHOW", "MOVIE"], +) +POPULAR_INPUT = ( + "COUNTRY", + "LANGUAGE", + 5, + True, + 10, + ["prov1", "prov2"], + 2000, + None, + "SHOW", +) DETAILS_INPUT = ("NODE ID", "COUNTRY", "LANGUAGE", False) OFFERS_COUNTRIES_INPUT = {"COUNTRY1", "COUNTRY2", "COUNTRY3"} OFFERS_INPUT = ("NODE ID", OFFERS_COUNTRIES_INPUT, "LANGUAGE", True) diff --git a/test/simplejustwatchapi/test_request.py b/test/simplejustwatchapi/test_request.py index f159306..dc60950 100644 --- a/test/simplejustwatchapi/test_request.py +++ b/test/simplejustwatchapi/test_request.py @@ -39,6 +39,14 @@ def locale_variables(country, language): } +def filter_variables(min_release_year, max_release_year, object_types): + return { + "includeTitlesWithoutUrl": True, + "objectTypes": object_types, + "releaseYear": {"min": min_release_year, "max": max_release_year}, + } + + @patch("simplejustwatchapi.query.GRAPHQL_SEARCH_QUERY", DUMMY_SEARCH_QUERY) @mark.parametrize( argnames=( @@ -49,13 +57,38 @@ def locale_variables(country, language): "best_only", "offset", "providers", + "min_release_year", + "max_release_year", + "object_types", ), argvalues=[ - ("TITLE 1", "US", "en", 5, True, 0, ""), - ("TITLE 2", "gb", "fr", 10, False, 20, ["provider1", "provider2"]), - ("TITLE 3", "fr", "de-SWITZ123", 20, True, 20, "provider3"), - ("TITLE 4", "it", "ro-HELLO123", 30, True, 30, []), - ("TITLE 5", "dk", "us", 40, True, 40, None), + ("TITLE 1", "US", "en", 5, True, 0, "", 1990, 2000, ["SHOW", "MOVIE"]), + ( + "TITLE 2", + "gb", + "fr", + 10, + False, + 20, + ["provider1", "provider2"], + 2000, + None, + "MOVIE", + ), + ( + "TITLE 3", + "fr", + "de-SWITZ123", + 20, + True, + 20, + "provider3", + None, + 2020, + ["SHOW"], + ), + ("TITLE 4", "it", "ro-HELLO123", 30, True, 30, [], None, None, "SHOW"), + ("TITLE 5", "dk", "us", 40, True, 40, None, 2030, 2040, ["MOVIE"]), ], ) def test_prepare_search_request( @@ -66,12 +99,19 @@ def test_prepare_search_request( best_only, offset, providers, + min_release_year, + max_release_year, + object_types, ): expected_request = { "operationName": "GetSearchTitles", "variables": { "first": count, - "searchTitlesFilter": {"searchQuery": title, "packages": providers}, + "searchTitlesFilter": { + "searchQuery": title, + "packages": providers, + **filter_variables(min_release_year, max_release_year, object_types), + }, **common_variables(best_only), **locale_variables(country, language), "offset": offset or None, @@ -79,20 +119,39 @@ def test_prepare_search_request( "query": DUMMY_SEARCH_QUERY, } request = prepare_search_request( - title, country, language, count, best_only, offset, providers + title, + country, + language, + count, + best_only, + offset, + providers, + min_release_year, + max_release_year, + object_types, ) assert expected_request == request @patch("simplejustwatchapi.query.GRAPHQL_POPULAR_QUERY", DUMMY_POPULAR_QUERY) @mark.parametrize( - argnames=("country", "language", "count", "best_only", "offset", "providers"), + argnames=( + "country", + "language", + "count", + "best_only", + "offset", + "providers", + "min_release_year", + "max_release_year", + "object_types", + ), argvalues=[ - ("US", "en-123ASD", 5, True, 0, ""), - ("gb", "fr", 10, False, 20, ["provider1", "provider2"]), - ("fr", "de-FGH76", 20, True, 20, "provider3"), - ("it", "ro", 30, True, 30, []), - ("dk", "us", 40, True, 40, None), + ("US", "en-123ASD", 5, True, 0, "", 1990, 2000, ["SHOW", "MOVIE"]), + ("gb", "fr", 10, False, 20, ["provider1", "provider2"], 2000, 2010, "MOVIE"), + ("fr", "de-FGH76", 20, True, 20, "provider3", None, 2020, ["SHOW"]), + ("it", "ro", 30, True, 30, [], 2020, None, "SHOW"), + ("dk", "us", 40, True, 40, None, None, None, ["MOVIE"]), ], ) def test_prepare_popular_request( @@ -102,12 +161,18 @@ def test_prepare_popular_request( best_only, offset, providers, + min_release_year, + max_release_year, + object_types, ): expected_request = { "operationName": "GetPopularTitles", "variables": { "first": count, - "popularTitlesFilter": {"packages": providers}, + "popularTitlesFilter": { + "packages": providers, + **filter_variables(min_release_year, max_release_year, object_types), + }, **common_variables(best_only), **locale_variables(country, language), "offset": offset or None, @@ -115,7 +180,15 @@ def test_prepare_popular_request( "query": DUMMY_POPULAR_QUERY, } request = prepare_popular_request( - country, language, count, best_only, offset, providers + country, + language, + count, + best_only, + offset, + providers, + min_release_year, + max_release_year, + object_types, ) assert expected_request == request