diff --git a/VERSION b/VERSION
index 951fdaf7..75750685 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.19.1-develop40
+1.19.1-develop41
diff --git a/docs/builders/imdb.md b/docs/builders/imdb.md
index dbd7a7bf..293cee3c 100644
--- a/docs/builders/imdb.md
+++ b/docs/builders/imdb.md
@@ -50,9 +50,9 @@ collections:
## IMDb List
-Finds every item in an IMDb List, [Keyword Search](https://www.imdb.com/search/keyword/), [Title Search](https://www.imdb.com/search/title/), or [Topic Search](https://www.imdb.com/search/title-text/).
+Finds every item in an IMDb List or [Keyword Search](https://www.imdb.com/search/keyword/).
-The expected input is an IMDb List URL or IMDb Search URL. Multiple values are supported as a list only a comma-separated string will not work.
+The expected input is an IMDb List URL or IMDb Keyword Search URL. Multiple values are supported as a list only a comma-separated string will not work.
The `sync_mode: sync` and `collection_order: custom` Details are recommended since the lists are continuously updated and in a specific order.
@@ -151,4 +151,100 @@ collections:
- ur12345678
collection_order: custom
sync_mode: sync
-```
\ No newline at end of file
+```
+
+## IMDb Search
+
+Finds every item using an [IMDb Advance Title Search](https://www.imdb.com/search/title/)
+
+The `sync_mode: sync` and `collection_order: custom` Details are recommended since the lists are continuously updated and in a specific order.
+
+| Search Parameter | Description |
+|:-------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `limit` | Specify how items you want returned by the query.
**Options:** Any Integer greater then `0`
**Default:** `100` |
+| `sort_by` | Choose from one of the many available sort options.
**Options:** `popularity.asc`, `popularity.desc`, `title.asc`, `title.desc`, `rating.asc`, `rating.desc`, `votes.asc`, `votes.desc`, `box_office.asc`, `box_office.desc`, `runtime.asc`, `runtime.desc`, `year.asc`, `year.desc`, `release.asc`, `release.desc`
**Default:** `popularity.asc` |
+| `title` | Search by title name.
**Options:** Any String |
+| `type` | Item must match at least one given type. Can be a comma-separated list.
**Options:** `movie`, `tv_series`, `short`, `tv_episode`, `tv_mini_series`, `tv_movie`, `tv_special`, `tv_short`, `video_game`, `video`, `music_video`, `podcast_series`, `podcast_episode` |
+| `type.not` | Item must not match any of the given types. Can be a comma-separated list.
**Options:** `movie`, `tv_series`, `short`, `tv_episode`, `tv_mini_series`, `tv_movie`, `tv_special`, `tv_short`, `video_game`, `video`, `music_video`, `podcast_series`, `podcast_episode` |
+| `release.after` | Item must have been released after the given date.
**Options:** `today` or Date in the format `YYYY-MM-DD` |
+| `release.before` | Item must have been released before the given date.
**Options:** `today` or Date in the format `YYYY-MM-DD` |
+| `rating.gte` | Item must have an IMDb Rating greater then or equal to the given number.
**Options:** Any Number `0.1` - `10.0`
**Example:** `7.5` |
+| `rating.lte` | Item must have an IMDb Rating less then or equal to the given number.
**Options:** Any Number `0.1` - `10.0`
**Example:** `7.5` |
+| `votes.gte` | Item must have a Number of Votes greater then or equal to the given number.
**Options:** Any Integer greater then `0`
**Example:** `1000` |
+| `votes.lte` | Item must have a Number of Votes less then or equal to the given number.
**Options:** Any Integer greater then `0`
**Example:** `1000` |
+| `genre` | Item must match all genres given. Can be a comma-separated list.
**Options:** `action`, `adventure`, `animation`, `biography`, `comedy`, `documentary`, `drama`, `crime`, `family`, `history`, `news`, `short`, `western`, `sport`, `reality-tv`, `horror`, `fantasy`, `film-noir`, `music`, `romance`, `talk-show`, `thriller`, `war`, `sci-fi`, `musical`, `mystery`, `game-show` |
+| `genre.any` | Item must match at least one given genre. Can be a comma-separated list.
**Options:** `action`, `adventure`, `animation`, `biography`, `comedy`, `documentary`, `drama`, `crime`, `family`, `history`, `news`, `short`, `western`, `sport`, `reality-tv`, `horror`, `fantasy`, `film-noir`, `music`, `romance`, `talk-show`, `thriller`, `war`, `sci-fi`, `musical`, `mystery`, `game-show` |
+| `genre.not` | Item must not match any og the given genres. Can be a comma-separated list.
**Options:** `action`, `adventure`, `animation`, `biography`, `comedy`, `documentary`, `drama`, `crime`, `family`, `history`, `news`, `short`, `western`, `sport`, `reality-tv`, `horror`, `fantasy`, `film-noir`, `music`, `romance`, `talk-show`, `thriller`, `war`, `sci-fi`, `musical`, `mystery`, `game-show` |
+| `event` | Item must have been nominated for a category at the event given. Can be a comma-separated list.
**Options:** `cannes`, `choice`, `spirit`, `sundance`, `bafta`, `oscar`, `emmy`, `golden`, `oscar_picture`, `oscar_director`, `national_film_board_preserved`, `razzie`, or any [IMDb Event ID](https://www.imdb.com/event/all/) (ex. `ev0050888`) |
+| `event.winning` | Item must have won a category at the event given. Can be a comma-separated list.
**Options:** `cannes`, `choice`, `spirit`, `sundance`, `bafta`, `oscar`, `emmy`, `golden`, `oscar_picture`, `oscar_director`, `national_film_board_preserved`, `razzie`, or any [IMDb Event ID](https://www.imdb.com/event/all/) (ex. `ev0050888`) |
+| `imdb_top` | Item must be in the top number of given Movies.
**Options:** Any Integer greater then `0` |
+| `imdb_bottom` | Item must be in the bottom number of given Movies.
**Options:** Any Integer greater then `0` |
+| `company` | Item must have been released by any company given. Can be a comma-separated list.
**Options:** `fox`, `dreamworks`, `mgm`, `paramount`, `sony`, `universal`, `disney`, `warner`, or any IMDb Company ID (ex. `co0023400`) |
+| `content_rating` | Item must have the given content rating. Can be a list.
**Options:** Dictionary with two attributes `rating` and `region`
`rating`: Any String to match the content rating
`region`: [2 Digit ISO 3166 Country Code](https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes) |
+| `country` | Item must match with every given country. Can be a comma-separated list.
**Options:** [2 Digit ISO 3166 Country Code](https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes) |
+| `country.any` | Item must match at least one given country. Can be a comma-separated list.
**Options:** [2 Digit ISO 3166 Country Code](https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes) |
+| `country.not` | Item must not match any given country. Can be a comma-separated list.
**Options:** [2 Digit ISO 3166 Country Code](https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes) |
+| `country.origin` | Item must match any given country as the origin country. Can be a comma-separated list.
**Options:** [2 Digit ISO 3166 Country Code](https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes) |
+| `keyword` | Item must match with every given keyword. Can be a comma-separated list.
**Options:** Any String |
+| `keyword.any` | Item must match at least one given keyword. Can be a comma-separated list.
**Options:** Any String |
+| `keyword.not` | Item must not match any given keyword. Can be a comma-separated list.
**Options:** Any String |
+| `series` | Item must match with every given series. Can be a comma-separated list.
**Options:** Any IMDb ID (ex. `tt0096697`) |
+| `series.any` | Item must match at least one given series. Can be a comma-separated list.
**Options:** Any IMDb ID (ex. `tt0096697`) |
+| `series.not` | Item must not match any given series. Can be a comma-separated list.
**Options:** Any IMDb ID (ex. `tt0096697`) |
+| `list` | Item must be on every given list. Can be a comma-separated list.
**Options:** Any IMDb List ID (ex. `ls000024621`) |
+| `list.any` | Item must be on at least one given lists. Can be a comma-separated list.
**Options:** Any IMDb List ID (ex. `ls000024621`) |
+| `list.not` | Item must not be on any given lists. Can be a comma-separated list.
**Options:** Any IMDb List ID (ex. `ls000024621`) |
+| `language` | Item must match any given language. Can be a comma-separated list.
**Options:** [ISO 639-2 Language Codes](https://en.wikipedia.org/wiki/List_of_ISO_639-2_codes) |
+| `language.any` | Item must match at least one given language. Can be a comma-separated list.
**Options:** [ISO 639-2 Language Codes](https://en.wikipedia.org/wiki/List_of_ISO_639-2_codes) |
+| `language.not` | Item must not match any given language. Can be a comma-separated list.
**Options:** [ISO 639-2 Language Codes](https://en.wikipedia.org/wiki/List_of_ISO_639-2_codes) |
+| `language.primary` | Item must match any given language as the primary language. Can be a comma-separated list.
**Options:** [ISO 639-2 Language Codes](https://en.wikipedia.org/wiki/List_of_ISO_639-2_codes) |
+| `popularity.gte` | Item must have a Popularity greater then or equal to the given number.
**Options:** Any Integer greater then `0`
**Example:** `1000` |
+| `popularity.lte` | Item must have a Popularity less then or equal to the given number.
**Options:** Any Integer greater then `0`
**Example:** `1000` |
+| `cast` | Item must have all the given cast members. Can be a comma-separated list.
**Options:** Any IMDb Person ID (ex. `nm0000138`) |
+| `cast.any` | Item must have any of the given cast members. Can be a comma-separated list.
**Options:** Any IMDb Person ID (ex. `nm0000138`) |
+| `cast.not` | Item must not have any of the given cast members. Can be a comma-separated list.
**Options:** Any IMDb Person ID (ex. `nm0000138`) |
+| `runtime.gte` | Item must have a Runtime greater then or equal to the given number.
**Options:** Any Integer greater then `0`
**Example:** `1000` |
+| `runtime.lte` | Item must have a Runtime less then or equal to the given number.
**Options:** Any Integer greater then `0`
**Example:** `1000` |
+| `adult` | Include adult titles in the search results.
**Options:** `true`/`false` |
+
+### Examples
+
+```yaml
+collections:
+ IMDb Popular:
+ imdb_search:
+ type: movie
+ sort_by: popularity.asc
+ limit: 50
+ collection_order: custom
+ sync_mode: sync
+```
+
+```yaml
+collections:
+ Top Action:
+ imdb_search:
+ type: movie
+ release.after: 1990-01-01
+ rating.gte: 5
+ votes.gte: 100000
+ genre: action
+ sort_by: rating.desc
+ limit: 100
+```
+
+You can also find episodes using `imdb_search` like so.
+
+```yaml
+collections:
+ The Simpsons Top 100 Episodes:
+ collection_order: custom
+ builder_level: episode
+ sync_mode: sync
+ imdb_search:
+ type: tv_episode
+ series: tt0096697
+ sort: rating.desc
+ limit: 100
+ summary: The top 100 Simpsons episodes by IMDb user rating
+```
diff --git a/docs/builders/tmdb.md b/docs/builders/tmdb.md
index f34441d5..d6f6a42c 100644
--- a/docs/builders/tmdb.md
+++ b/docs/builders/tmdb.md
@@ -435,45 +435,45 @@ The `sync_mode: sync` and `collection_order: custom` Details are recommended sin
### Discover Movies Parameters
-| Movie Parameters | Description |
-|:--------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| `limit` | Specify how many movies you want returned by the query.
**Type:** Integer
**Default:** 100 |
-| `region` | Specify a [ISO 3166-1 code](https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes) to filter release dates. Must be uppercase. Will use the `region` specified in the [TMDb Config](../config/tmdb.md) by default.
**Type:** `^[A-Z]{2}$` |
-| `sort_by` | Choose from one of the many available sort options.
**Type:** Any [sort options](#sort-options) below
**Default:** `popularity.desc` |
-| `certification_country` | Used in conjunction with the certification parameter, use this to specify a country with a valid certification.
**Type:** String |
-| `certification` | Filter results with a valid certification from the `certification_country` parameter.
**Type:** String |
-| `certification.lte` | Filter and only include movies that have a certification that is less than or equal to the specified value.
**Type:** String |
-| `certification.gte` | Filter and only include movies that have a certification that is greater than or equal to the specified value.
**Type:** String |
-| `include_adult` | A filter and include or exclude adult movies.
**Type:** Boolean |
-| `include_video` | A filter and include or exclude videos.
**Type:** Boolean |
-| `primary_release_year` | A filter to limit the results to a specific primary release year.
**Type:** Year: YYYY |
-| `primary_release_date.gte` | Filter and only include movies that have a primary release date that is greater or equal to the specified value.
**Type:** Date: `MM/DD/YYYY` |
-| `primary_release_date.lte` | Filter and only include movies that have a primary release date that is less than or equal to the specified value.
**Type:** Date: `MM/DD/YYYY` |
-| `release_date.gte` | Filter and only include movies that have a release date (looking at all release dates) that is greater or equal to the specified value.
**Type:** Date: `MM/DD/YYYY` |
-| `release_date.lte` | Filter and only include movies that have a release date (looking at all release dates) that is less than or equal to the specified value.
**Type:** Date: `MM/DD/YYYY` |
-| `with_release_type` | Specify a comma (AND) or pipe (OR) separated value to filter release types by.
**Type:** String
**Values:** `1`: Premiere, `2`: Theatrical (limited), `3`: Theatrical, `4`: Digital, `5`: Physical, `6`: TV |
-| `year` | A filter to limit the results to a specific year (looking at all release dates).
**Type:** Year: `YYYY` |
-| `vote_count.gte` | Filter and only include movies that have a vote count that is greater or equal to the specified value.
**Type:** Integer |
-| `vote_count.lte` | Filter and only include movies that have a vote count that is less than or equal to the specified value.
**Type:** Integer |
-| `vote_average.gte` | Filter and only include movies that have a rating that is greater or equal to the specified value.
**Type:** Number |
-| `vote_average.lte` | Filter and only include movies that have a rating that is less than or equal to the specified value.
**Type:** Number |
-| `with_cast` | A comma-separated list of person ID's. Only include movies that have one of the ID's added as an actor.
**Type:** String |
-| `with_crew` | A comma-separated list of person ID's. Only include movies that have one of the ID's added as a crew member.
**Type:** String |
-| `with_people` | A comma-separated list of person ID's. Only include movies that have one of the ID's added as either an actor or a crew member.
**Type:** String |
-| `with_companies` | A comma-separated list of production company ID's. Only include movies that have one of the ID's added as a production company.
**Type:** String |
-| `without_companies` | Filter the results to exclude the specific production companies you specify here. AND / OR filters are supported.
**Type:** String |
-| `with_genres` | Comma-separated value of genre ids that you want to include in the results.
**Type:** String |
-| `without_genres` | Comma-separated value of genre ids that you want to exclude from the results.
**Type:** String |
-| `with_keywords` | A comma-separated list of keyword ID's. Only includes movies that have one of the ID's added as a keyword.
**Type:** String |
-| `without_keywords` | Exclude items with certain keywords. You can comma and pipe separate these values to create an 'AND' or 'OR' logic.
**Type:** String |
-| `with_runtime.gte` | Filter and only include movies that have a runtime that is greater or equal to a value.
**Type:** Integer |
-| `with_runtime.lte` | Filter and only include movies that have a runtime that is less than or equal to a value.
**Type:** Integer |
-| `with_original_language` | Specify an ISO 639-1 string to filter results by their original language value.
**Type:** String |
-| `with_title_translation` | Specify a language/country string to filter the results by if the item has a type of title translation.
**Type:** String
**Values:** `ar-AE`, `ar-SA`, `bg-BG`, `bn-BD`, `ca-ES`, `ch-GU`, `cs-CZ`, `da-DK`, `de-DE`, `el-GR`, `en-US`, `eo-EO`, `es-ES`, `es-MX`, `eu-ES`, `fa-IR`, `fi-FI`, `fr-CA`, `fr-FR`, `he-IL`, `hi-IN`, `hu-HU`, `id-ID`, `it-IT`, `ja-JP`, `ka-GE`, `kn-IN`, `ko-KR`, `lt-LT`, `ml-IN`, `nb-NO`, `nl-NL`, `no-NO`, `pl-PL`, `pt-BR`, `pt-PT`, `ro-RO`, `ru-RU`, `sk-SK`, `sl-SI`, `sr-RS`, `sv-SE`, `ta-IN`, `te-IN`, `th-TH`, `tr-TR`, `uk-UA`, `vi-VN`, `zh-CN`, `zh-TW` |
-| `with_overview_translation` | Specify a language/country string to filter the results by if the item has a type of overview translation.
**Type:** String
**Values:** `ar-AE`, `ar-SA`, `bg-BG`, `bn-BD`, `ca-ES`, `ch-GU`, `cs-CZ`, `da-DK`, `de-DE`, `el-GR`, `en-US`, `eo-EO`, `es-ES`, `es-MX`, `eu-ES`, `fa-IR`, `fi-FI`, `fr-CA`, `fr-FR`, `he-IL`, `hi-IN`, `hu-HU`, `id-ID`, `it-IT`, `ja-JP`, `ka-GE`, `kn-IN`, `ko-KR`, `lt-LT`, `ml-IN`, `nb-NO`, `nl-NL`, `no-NO`, `pl-PL`, `pt-BR`, `pt-PT`, `ro-RO`, `ru-RU`, `sk-SK`, `sl-SI`, `sr-RS`, `sv-SE`, `ta-IN`, `te-IN`, `th-TH`, `tr-TR`, `uk-UA`, `vi-VN`, `zh-CN`, `zh-TW` |
-| `with_watch_providers` | A comma or pipe separated list of watch provider ID's. Combine this filter with `watch_region` in order to filter your results by a specific watch provider in a specific region.
**Type:** String |
-| `watch_region` | An [ISO 3166-1 code](https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes). Combine this filter with `with_watch_providers` in order to filter your results by a specific watch provider in a specific region.
**Type:** String
**Values:** [ISO 3166-1 code](https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes) |
-| `with_watch_monetization_types` | In combination with `watch_region`, you can filter by monetization type.
**Type:** String
**Values:** `flatrate`, `free`, `ads`, `rent`, `buy` |
+| Movie Parameters | Description |
+|:--------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `limit` | Specify how many movies you want returned by the query.
**Type:** Integer
**Default:** 100 |
+| `region` | Specify a [ISO 3166-1 code](https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes) to filter release dates. Must be uppercase. Will use the `region` specified in the [TMDb Config](../config/tmdb.md) by default.
**Type:** `^[A-Z]{2}$` |
+| `sort_by` | Choose from one of the many available sort options.
**Type:** Any [sort options](#sort-options) below
**Default:** `popularity.desc` |
+| `certification_country` | Used in conjunction with the certification parameter, use this to specify a country with a valid certification.
**Type:** String |
+| `certification` | Filter results with a valid certification from the `certification_country` parameter.
**Type:** String |
+| `certification.lte` | Filter and only include movies that have a certification that is less than or equal to the specified value.
**Type:** String |
+| `certification.gte` | Filter and only include movies that have a certification that is greater than or equal to the specified value.
**Type:** String |
+| `include_adult` | A filter and include or exclude adult movies.
**Type:** Boolean |
+| `include_video` | A filter and include or exclude videos.
**Type:** Boolean |
+| `primary_release_year` | A filter to limit the results to a specific primary release year.
**Type:** Year: YYYY |
+| `primary_release_date.gte` | Filter and only include movies that have a primary release date that is greater or equal to the specified value.
**Type:** Date: `MM/DD/YYYY` |
+| `primary_release_date.lte` | Filter and only include movies that have a primary release date that is less than or equal to the specified value.
**Type:** Date: `MM/DD/YYYY` |
+| `release_date.gte` | Filter and only include movies that have a release date (looking at all release dates) that is greater or equal to the specified value.
**Type:** Date: `MM/DD/YYYY` |
+| `release_date.lte` | Filter and only include movies that have a release date (looking at all release dates) that is less than or equal to the specified value.
**Type:** Date: `MM/DD/YYYY` |
+| `with_release_type` | Specify a comma (AND) or pipe (OR) separated value to filter release types by.
**Type:** String
**Values:** `1`: Premiere, `2`: Theatrical (limited), `3`: Theatrical, `4`: Digital, `5`: Physical, `6`: TV |
+| `year` | A filter to limit the results to a specific year (looking at all release dates).
**Type:** Year: `YYYY` |
+| `vote_count.gte` | Filter and only include movies that have a vote count that is greater or equal to the specified value.
**Type:** Integer |
+| `vote_count.lte` | Filter and only include movies that have a vote count that is less than or equal to the specified value.
**Type:** Integer |
+| `vote_average.gte` | Filter and only include movies that have a rating that is greater or equal to the specified value.
**Type:** Number |
+| `vote_average.lte` | Filter and only include movies that have a rating that is less than or equal to the specified value.
**Type:** Number |
+| `with_cast` | A comma-separated list of person ID's. Only include movies that have one of the ID's added as an actor.
**Type:** String |
+| `with_crew` | A comma-separated list of person ID's. Only include movies that have one of the ID's added as a crew member.
**Type:** String |
+| `with_people` | A comma-separated list of person ID's. Only include movies that have one of the ID's added as either an actor or a crew member.
**Type:** String |
+| `with_companies` | A comma-separated list of production company ID's. Only include movies that have one of the ID's added as a production company.
**Type:** String |
+| `without_companies` | Filter the results to exclude the specific production companies you specify here. AND / OR filters are supported.
**Type:** String |
+| `with_genres` | Comma-separated value of genre ids that you want to include in the results.
**Type:** String |
+| `without_genres` | Comma-separated value of genre ids that you want to exclude from the results.
**Type:** String |
+| `with_keywords` | A comma-separated list of keyword ID's. Only includes movies that have one of the ID's added as a keyword.
**Type:** String |
+| `without_keywords` | Exclude items with certain keywords. You can comma and pipe separate these values to create an 'AND' or 'OR' logic.
**Type:** String |
+| `with_runtime.gte` | Filter and only include movies that have a runtime that is greater or equal to a value.
**Type:** Integer |
+| `with_runtime.lte` | Filter and only include movies that have a runtime that is less than or equal to a value.
**Type:** Integer |
+| `with_original_language` | Specify an ISO 639-1 string to filter results by their original language value.
**Type:** String |
+| `with_title_translation` | Specify a language/country string to filter the results by if the item has a type of title translation.
**Type:** String
**Values:** `ar-AE`, `ar-SA`, `bg-BG`, `bn-BD`, `ca-ES`, `ch-GU`, `cs-CZ`, `da-DK`, `de-DE`, `el-GR`, `en-US`, `eo-EO`, `es-ES`, `es-MX`, `eu-ES`, `fa-IR`, `fi-FI`, `fr-CA`, `fr-FR`, `he-IL`, `hi-IN`, `hu-HU`, `id-ID`, `it-IT`, `ja-JP`, `ka-GE`, `kn-IN`, `ko-KR`, `lt-LT`, `ml-IN`, `nb-NO`, `nl-NL`, `no-NO`, `pl-PL`, `pt-BR`, `pt-PT`, `ro-RO`, `ru-RU`, `sk-SK`, `sl-SI`, `sr-RS`, `sv-SE`, `ta-IN`, `te-IN`, `th-TH`, `tr-TR`, `uk-UA`, `vi-VN`, `zh-CN`, `zh-TW` |
+| `with_overview_translation` | Specify a language/country string to filter the results by if the item has a type of overview translation.
**Type:** String
**Values:** `ar-AE`, `ar-SA`, `bg-BG`, `bn-BD`, `ca-ES`, `ch-GU`, `cs-CZ`, `da-DK`, `de-DE`, `el-GR`, `en-US`, `eo-EO`, `es-ES`, `es-MX`, `eu-ES`, `fa-IR`, `fi-FI`, `fr-CA`, `fr-FR`, `he-IL`, `hi-IN`, `hu-HU`, `id-ID`, `it-IT`, `ja-JP`, `ka-GE`, `kn-IN`, `ko-KR`, `lt-LT`, `ml-IN`, `nb-NO`, `nl-NL`, `no-NO`, `pl-PL`, `pt-BR`, `pt-PT`, `ro-RO`, `ru-RU`, `sk-SK`, `sl-SI`, `sr-RS`, `sv-SE`, `ta-IN`, `te-IN`, `th-TH`, `tr-TR`, `uk-UA`, `vi-VN`, `zh-CN`, `zh-TW` |
+| `with_watch_providers` | A comma or pipe separated list of watch provider ID's. Combine this filter with `watch_region` in order to filter your results by a specific watch provider in a specific region.
**Type:** String |
+| `watch_region` | An [ISO 3166-1 code](https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes). Combine this filter with `with_watch_providers` in order to filter your results by a specific watch provider in a specific region.
**Type:** String
**Values:** [ISO 3166-1 code](https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes) |
+| `with_watch_monetization_types` | In combination with `watch_region`, you can filter by monetization type.
**Type:** String
**Values:** `flatrate`, `free`, `ads`, `rent`, `buy` |
diff --git a/modules/anilist.py b/modules/anilist.py
index 58557a39..2175a295 100644
--- a/modules/anilist.py
+++ b/modules/anilist.py
@@ -137,7 +137,10 @@ class AniList:
ani_attr = attr_translation[attr] if attr in attr_translation else attr
final = ani_attr if attr in no_mod_searches else f"{ani_attr}_{mod_translation[mod]}"
if attr in ["start", "end"]:
- value = int(util.validate_date(value, f"anilist_search {key}", return_as="%Y%m%d"))
+ try:
+ value = int(util.validate_date(value, return_as="%Y%m%d"))
+ except Failed as e:
+ raise Failed(f"Collection Error: anilist_search {key}: {e}")
elif attr in ["format", "status", "genre", "tag", "tag_category"]:
temp_value = [self.options[attr.replace('_', ' ').title()][v.lower().replace(' / ', '-').replace(' ', '-')] for v in value]
if attr in ["format", "status"]:
diff --git a/modules/builder.py b/modules/builder.py
index f244f27c..90e541c7 100644
--- a/modules/builder.py
+++ b/modules/builder.py
@@ -1419,8 +1419,9 @@ class CollectionBuilder:
dict_methods = {dm.lower(): dm for dm in dict_data}
new_dictionary = {}
for search_method, search_data in dict_data.items():
- search_attr, modifier = os.path.splitext(str(search_method).lower())
- if search_method not in anilist.searches:
+ lower_method = str(search_method).lower()
+ search_attr, modifier = os.path.splitext(lower_method)
+ if lower_method not in anilist.searches:
raise Failed(f"{self.Type} Error: {method_name} {search_method} attribute not supported")
elif search_attr == "season":
new_dictionary[search_attr] = util.parse(self.Type, search_attr, search_data, parent=method_name, default=current_season, options=util.seasons)
@@ -1440,16 +1441,16 @@ class CollectionBuilder:
elif search_attr == "source":
new_dictionary[search_attr] = util.parse(self.Type, search_attr, search_data, options=anilist.media_source, parent=method_name)
elif search_attr in ["episodes", "duration", "score", "popularity"]:
- new_dictionary[search_method] = util.parse(self.Type, search_method, search_data, datatype="int", parent=method_name)
+ new_dictionary[lower_method] = util.parse(self.Type, search_method, search_data, datatype="int", parent=method_name)
elif search_attr in ["format", "status", "genre", "tag", "tag_category"]:
- new_dictionary[search_method] = self.config.AniList.validate(search_attr.replace("_", " ").title(), util.parse(self.Type, search_method, search_data))
+ new_dictionary[lower_method] = self.config.AniList.validate(search_attr.replace("_", " ").title(), util.parse(self.Type, search_method, search_data))
elif search_attr in ["start", "end"]:
- new_dictionary[search_method] = util.validate_date(search_data, f"{method_name} {search_method} attribute", return_as="%m/%d/%Y")
+ new_dictionary[search_attr] = util.parse(self.Type, search_attr, search_data, datatype="date", parent=method_name, date_return="%m/%d/%Y")
elif search_attr == "min_tag_percent":
new_dictionary[search_attr] = util.parse(self.Type, search_attr, search_data, datatype="int", parent=method_name, minimum=0, maximum=100)
elif search_attr == "search":
new_dictionary[search_attr] = str(search_data)
- elif search_method not in ["sort_by", "limit"]:
+ elif lower_method not in ["sort_by", "limit"]:
raise Failed(f"{self.Type} Error: {method_name} {search_method} attribute not supported")
if len(new_dictionary) == 0:
raise Failed(f"{self.Type} Error: {method_name} must have at least one valid search option")
@@ -1498,6 +1499,129 @@ class CollectionBuilder:
elif method_name == "imdb_watchlist":
for imdb_user in self.config.IMDb.validate_imdb_watchlists(self.Type, method_data, self.language):
self.builders.append((method_name, imdb_user))
+ elif method_name == "imdb_search":
+ for dict_data in util.parse(self.Type, method_name, method_data, datatype="listdict"):
+ dict_methods = {dm.lower(): dm for dm in dict_data}
+ new_dictionary = {"limit": util.parse(self.Type, "limit", dict_data, datatype="int", methods=dict_methods, minimum=0, default=100, parent=method_name)}
+ for search_method, search_data in dict_data.items():
+ lower_method = str(search_method).lower()
+ search_attr, modifier = os.path.splitext(lower_method)
+ if search_data is None:
+ raise Failed(f"{self.Type} Error: {method_name} {search_method} attribute is blank")
+ elif lower_method not in imdb.imdb_search_attributes:
+ raise Failed(f"{self.Type} Error: {method_name} {search_method} attribute not supported")
+ elif search_attr == "sort_by":
+ new_dictionary[lower_method] = util.parse(self.Type, search_method, search_data, parent=method_name, options=imdb.sort_options)
+ elif search_attr == "title":
+ new_dictionary[lower_method] = util.parse(self.Type, search_method, search_data, parent=method_name)
+ elif search_attr == "type":
+ new_dictionary[lower_method] = util.parse(self.Type, search_method, search_data, datatype="lowerlist", parent=method_name, options=imdb.title_type_options)
+ elif search_attr == "release":
+ new_dictionary[lower_method] = util.parse(self.Type, search_method, search_data, datatype="date", parent=method_name, date_return="%Y-%m-%d")
+ elif search_attr == "rating":
+ new_dictionary[lower_method] = util.parse(self.Type, search_method, search_data, datatype="float", parent=method_name, minimum=0.1, maximum=10)
+ elif search_attr in ["votes", "imdb_top", "imdb_bottom", "popularity", "runtime"]:
+ new_dictionary[lower_method] = util.parse(self.Type, search_method, search_data, datatype="int", parent=method_name, minimum=0)
+ elif search_attr == "genre":
+ new_dictionary[lower_method] = util.parse(self.Type, search_method, search_data, datatype="lowerlist", parent=method_name, options=imdb.genre_options)
+ elif search_attr == "event":
+ events = []
+ for event in util.parse(self.Type, search_method, search_data, datatype="lowerlist", parent=method_name):
+ if event in imdb.event_options:
+ events.append(event)
+ else:
+ res = re.search(r'(ev\d+)', event)
+ if res:
+ events.append(res.group(1))
+ else:
+ raise Failed(f"{method_name} {search_method} attribute: {search_data} must match pattern ev\d+ e.g. ev0000292 or be one of {', '.join([e for e in imdb.event_options])}")
+ if events:
+ new_dictionary[lower_method] = events
+ elif search_attr == "company":
+ companies = []
+ for company in util.parse(self.Type, search_method, search_data, datatype="lowerlist", parent=method_name):
+ if company in imdb.company_options:
+ companies.append(company)
+ else:
+ res = re.search(r'(co\d+)', company)
+ if res:
+ companies.append(res.group(1))
+ else:
+ raise Failed(f"{method_name} {search_method} attribute: {search_data} must match pattern co\d+ e.g. co0098836 or be one of {', '.join([e for e in imdb.company_options])}")
+ if companies:
+ new_dictionary[lower_method] = companies
+ elif search_attr == "content_rating":
+ final_list = []
+ for content in util.get_list(search_data):
+ if content:
+ final_dict = {"region": "US", "rating": None}
+ if not isinstance(content, dict):
+ final_dict["rating"] = str(content)
+ else:
+ if "rating" not in content or not content["rating"]:
+ raise Failed(f"{method_name} {search_method} attribute: rating attribute is required")
+ final_dict["rating"] = str(content["rating"])
+ if "region" not in content or not content["region"]:
+ logger.warning(f"{method_name} {search_method} attribute: region attribute not found defaulting to 'US'")
+ elif len(str(content["region"])) != 2:
+ logger.warning(f"{method_name} {search_method} attribute: region attribute: {str(content['region'])} must be only 2 characters defaulting to 'US'")
+ else:
+ final_dict["region"] = str(content["region"]).upper()
+ final_list.append(final_dict)
+ if final_list:
+ new_dictionary[lower_method] = final_list
+ elif search_attr == "country":
+ countries = []
+ for country in util.parse(self.Type, search_method, search_data, datatype="upperlist", parent=method_name):
+ if country:
+ if len(str(country)) != 2:
+ raise Failed(f"{method_name} {search_method} attribute: {country} must be only 2 characters i.e. 'US'")
+ countries.append(str(country))
+ if countries:
+ new_dictionary[lower_method] = countries
+ elif search_attr == "keyword":
+ new_dictionary[lower_method] = util.parse(self.Type, search_method, search_data, datatype="strlist", parent=method_name)
+ elif search_attr == "language":
+ new_dictionary[lower_method] = util.parse(self.Type, search_method, search_data, datatype="lowerlist", parent=method_name)
+ elif search_attr == "cast":
+ casts = []
+ for cast in util.parse(self.Type, search_method, search_data, datatype="lowerlist", parent=method_name):
+ res = re.search(r'(nm\d+)', cast)
+ if res:
+ casts.append(res.group(1))
+ else:
+ raise Failed(f"{method_name} {search_method} attribute: {search_data} must match pattern nm\d+ e.g. nm00988366")
+ if casts:
+ new_dictionary[lower_method] = casts
+ elif search_attr == "series":
+ series = []
+ for show in util.parse(self.Type, search_method, search_data, datatype="lowerlist", parent=method_name):
+ res = re.search(r'(tt\d+)', show)
+ if res:
+ series.append(res.group(1))
+ else:
+ raise Failed(f"{method_name} {search_method} attribute: {search_data} must match pattern tt\d+ e.g. tt00988366")
+ if series:
+ new_dictionary[lower_method] = series
+ elif search_attr == "list":
+ lists = []
+ for new_list in util.parse(self.Type, search_method, search_data, datatype="lowerlist", parent=method_name):
+ res = re.search(r'(ls\d+)', new_list)
+ if res:
+ lists.append(res.group(1))
+ else:
+ raise Failed(f"{method_name} {search_method} attribute: {search_data} must match pattern ls\d+ e.g. ls000024621")
+ if lists:
+ new_dictionary[lower_method] = lists
+ elif search_attr == "adult":
+ if util.parse(self.Type, search_method, search_data, datatype="bool", parent=method_name):
+ new_dictionary[lower_method] = True
+ else:
+ raise Failed(f"{self.Type} Error: {method_name} {search_method} attribute not supported")
+ if len(new_dictionary) > 1:
+ self.builders.append((method_name, new_dictionary))
+ else:
+ raise Failed(f"{self.Type} Error: {method_name} had no valid fields")
def _letterboxd(self, method_name, method_data):
if method_name.startswith("letterboxd_list"):
@@ -1682,56 +1806,57 @@ class CollectionBuilder:
dict_methods = {dm.lower(): dm for dm in dict_data}
new_dictionary = {"limit": util.parse(self.Type, "limit", dict_data, datatype="int", methods=dict_methods, default=100, parent=method_name)}
for discover_method, discover_data in dict_data.items():
- discover_attr, modifier = os.path.splitext(str(discover_method).lower())
+ lower_method = str(discover_method).lower()
+ discover_attr, modifier = os.path.splitext(lower_method)
if discover_data is None:
raise Failed(f"{self.Type} Error: {method_name} {discover_method} attribute is blank")
- elif discover_method not in tmdb.discover_all:
+ elif discover_method.lower() not in tmdb.discover_all:
raise Failed(f"{self.Type} Error: {method_name} {discover_method} attribute not supported")
elif self.library.is_movie and discover_attr in tmdb.discover_tv_only:
raise Failed(f"{self.Type} Error: {method_name} {discover_method} attribute only works for show libraries")
elif self.library.is_show and discover_attr in tmdb.discover_movie_only:
raise Failed(f"{self.Type} Error: {method_name} {discover_method} attribute only works for movie libraries")
elif discover_attr == "region":
- new_dictionary[discover_attr] = util.parse(self.Type, discover_attr, discover_data, parent=method_name, regex=("^[A-Z]{2}$", "US"))
+ new_dictionary[discover_attr] = util.parse(self.Type, discover_method, discover_data, parent=method_name, regex=("^[A-Z]{2}$", "US"))
elif discover_attr == "sort_by":
options = tmdb.discover_movie_sort if self.library.is_movie else tmdb.discover_tv_sort
- new_dictionary[discover_method] = util.parse(self.Type, discover_attr, discover_data, parent=method_name, options=options)
+ new_dictionary[lower_method] = util.parse(self.Type, discover_method, discover_data, parent=method_name, options=options)
elif discover_attr == "certification_country":
if "certification" in dict_data or "certification.lte" in dict_data or "certification.gte" in dict_data:
- new_dictionary[discover_method] = discover_data
+ new_dictionary[lower_method] = discover_data
else:
raise Failed(f"{self.Type} Error: {method_name} {discover_attr} attribute: must be used with either certification, certification.lte, or certification.gte")
elif discover_attr == "certification":
if "certification_country" in dict_data:
- new_dictionary[discover_method] = discover_data
+ new_dictionary[lower_method] = discover_data
else:
raise Failed(f"{self.Type} Error: {method_name} {discover_method} attribute: must be used with certification_country")
elif discover_attr == "watch_region":
if "with_watch_providers" in dict_data or "without_watch_providers" in dict_data or "with_watch_monetization_types" in dict_data:
- new_dictionary[discover_method] = discover_data
+ new_dictionary[lower_method] = discover_data
else:
raise Failed(f"{self.Type} Error: {method_name} {discover_method} attribute: must be used with either with_watch_providers, without_watch_providers, or with_watch_monetization_types")
elif discover_attr == "with_watch_monetization_types":
if "watch_region" in dict_data:
- new_dictionary[discover_method] = util.parse(self.Type, discover_attr, discover_data, parent=method_name, options=tmdb.discover_monetization_types)
+ new_dictionary[lower_method] = util.parse(self.Type, discover_method, discover_data, parent=method_name, options=tmdb.discover_monetization_types)
else:
raise Failed(f"{self.Type} Error: {method_name} {discover_method} attribute: must be used with watch_region")
elif discover_attr in tmdb.discover_booleans:
- new_dictionary[discover_method] = util.parse(self.Type, discover_attr, discover_data, datatype="bool", parent=method_name)
+ new_dictionary[lower_method] = util.parse(self.Type, discover_method, discover_data, datatype="bool", parent=method_name)
elif discover_attr == "vote_average":
- new_dictionary[discover_method] = util.parse(self.Type, discover_method, discover_data, datatype="float", parent=method_name)
+ new_dictionary[lower_method] = util.parse(self.Type, discover_method, discover_data, datatype="float", parent=method_name)
elif discover_attr == "with_status":
- new_dictionary[discover_method] = util.parse(self.Type, discover_attr, discover_data, datatype="int", parent=method_name, minimum=0, maximum=5)
+ new_dictionary[lower_method] = util.parse(self.Type, discover_method, discover_data, datatype="int", parent=method_name, minimum=0, maximum=5)
elif discover_attr == "with_type":
- new_dictionary[discover_method] = util.parse(self.Type, discover_attr, discover_data, datatype="int", parent=method_name, minimum=0, maximum=6)
+ new_dictionary[lower_method] = util.parse(self.Type, discover_method, discover_data, datatype="int", parent=method_name, minimum=0, maximum=6)
elif discover_attr in tmdb.discover_dates:
- new_dictionary[discover_method] = util.validate_date(discover_data, f"{method_name} {discover_method} attribute", return_as="%m/%d/%Y")
+ new_dictionary[lower_method] = util.parse(self.Type, discover_method, discover_data, datatype="date", parent=method_name, date_return="%m/%d/%Y")
elif discover_attr in tmdb.discover_years:
- new_dictionary[discover_method] = util.parse(self.Type, discover_attr, discover_data, datatype="int", parent=method_name, minimum=1800, maximum=self.current_year + 1)
+ new_dictionary[lower_method] = util.parse(self.Type, discover_method, discover_data, datatype="int", parent=method_name, minimum=1800, maximum=self.current_year + 1)
elif discover_attr in tmdb.discover_ints:
- new_dictionary[discover_method] = util.parse(self.Type, discover_method, discover_data, datatype="int", parent=method_name)
+ new_dictionary[lower_method] = util.parse(self.Type, discover_method, discover_data, datatype="int", parent=method_name)
elif discover_attr in tmdb.discover_strings:
- new_dictionary[discover_method] = discover_data
+ new_dictionary[lower_method] = discover_data
elif discover_attr != "limit":
raise Failed(f"{self.Type} Error: {method_name} {discover_method} attribute not supported")
if len(new_dictionary) > 1:
@@ -2449,10 +2574,10 @@ class CollectionBuilder:
logger.error(error)
return valid_list
elif attribute in date_attributes and modifier in [".before", ".after"]:
- if data == "today":
- return datetime.strftime(datetime.now(), "%Y-%m-%d")
- else:
- return util.validate_date(data, final, return_as="%Y-%m-%d")
+ try:
+ return util.validate_date(datetime.now() if data == "today" else data, return_as="%Y-%m-%d")
+ except Failed as e:
+ raise Failed(f"{self.Type} Error: {final}: {e}")
elif attribute in date_attributes and modifier in ["", ".not"]:
search_mod = "d"
if plex_search and data and str(data)[-1] in ["s", "m", "h", "d", "w", "o", "y"]:
diff --git a/modules/imdb.py b/modules/imdb.py
index bc0d86f9..56f70c55 100644
--- a/modules/imdb.py
+++ b/modules/imdb.py
@@ -5,7 +5,7 @@ from urllib.parse import urlparse, parse_qs
logger = util.logger
-builders = ["imdb_list", "imdb_id", "imdb_chart", "imdb_watchlist"]
+builders = ["imdb_list", "imdb_id", "imdb_chart", "imdb_watchlist", "imdb_search"]
movie_charts = ["box_office", "popular_movies", "top_movies", "top_english", "top_indian", "lowest_rated"]
show_charts = ["popular_shows", "top_shows"]
charts = {
@@ -18,11 +18,62 @@ charts = {
"top_indian": "Top Rated Indian Movies",
"lowest_rated": "Lowest Rated Movies"
}
+imdb_search_attributes = [
+ "sort_by", "title", "type", "type.not", "release.after", "release.before", "rating.gte", "rating.lte",
+ "votes.gte", "votes.lte", "genre", "genre.any", "genre.not", "event", "event.winning", "series", "series.any", "series.not",
+ "imdb_top", "imdb_bottom", "company", "content_rating", "country", "country.any", "country.not", "country.origin",
+ "keyword", "keyword.any", "keyword.not", "language", "language.any", "language.not", "language.primary",
+ "popularity.gte", "popularity.lte", "cast", "cast.any", "cast.not", "runtime.gte", "runtime.lte", "adult",
+]
+sort_by_options = {
+ "popularity": "POPULARITY",
+ "title": "TITLE_REGIONAL",
+ "rating": "USER_RATING",
+ "votes": "USER_RATING_COUNT",
+ "box_office": "BOX_OFFICE_GROSS_DOMESTIC",
+ "runtime": "RUNTIME",
+ "year": "YEAR",
+ "release": "RELEASE_DATE",
+}
+sort_options = [f"{a}.{d}"for a in sort_by_options for d in ["asc", "desc"]]
+title_type_options = {
+ "movie": "movie", "tv_series": "tvSeries", "short": "short", "tv_episode": "tvEpisode", "tv_mini_series": "tvMiniSeries",
+ "tv_movie": "tvMovie", "tv_special": "tvSpecial", "tv_short": "tvShort", "video_game": "videoGame", "video": "video",
+ "music_video": "musicVideo", "podcast_series": "podcastSeries", "podcast_episode": "podcastEpisode"
+}
+genre_options = {a.lower(): a for a in [
+ "Action", "Adventure", "Animation", "Biography", "Comedy", "Documentary", "Drama", "Crime", "Family", "History",
+ "News", "Short", "Western", "Sport", "Reality-TV", "Horror", "Fantasy", "Film-Noir", "Music", "Romance",
+ "Talk-Show", "Thriller", "War", "Sci-Fi", "Musical", "Mystery", "Game-Show"
+]}
+company_options = {
+ "fox": ["co0000756", "co0176225", "co0201557", "co0017497"],
+ "dreamworks": ["co0067641", "co0040938", "co0252576", "co0003158"],
+ "mgm": ["co0007143", "co0026841"],
+ "paramount": ["co0023400"],
+ "sony": ["co0050868", "co0026545", "co0121181"],
+ "universal": ["co0005073", "co0055277", "co0042399"],
+ "disney": ["co0008970", "co0017902", "co0098836", "co0059516", "co0092035", "co0049348"],
+ "warner": ["co0002663", "co0005035", "co0863266", "co0072876", "co0080422", "co0046718"],
+}
+event_options = {
+ "cannes": {"eventId": "ev0000147"},
+ "choice": {"eventId": "ev0000133"},
+ "spirit": {"eventId": "ev0000349"},
+ "sundance": {"eventId": "ev0000631"},
+ "bafta": {"eventId": "ev0000123"},
+ "oscar": {"eventId": "ev0000003"},
+ "emmy": {"eventId": "ev0000223"},
+ "golden": {"eventId": "ev0000292"},
+ "oscar_picture": {"eventId": "ev0000003", "searchAwardCategoryId": "bestPicture"},
+ "oscar_director": {"eventId": "ev0000003", "searchAwardCategoryId": "bestDirector"},
+ "national_film_board_preserved": {"eventId": "ev0000468"},
+ "razzie": {"eventId": "ev0000558"},
+}
base_url = "https://www.imdb.com"
+graphql_url = "https://api.graphql.imdb.com/"
urls = {
"lists": f"{base_url}/list/ls",
- "searches": f"{base_url}/search/title/",
- "title_text_searches": f"{base_url}/search/title-text/",
"keyword_searches": f"{base_url}/search/keyword/",
"filmography_searches": f"{base_url}/filmosearch/"
}
@@ -42,6 +93,9 @@ class IMDb:
response = self.config.get_html(url, headers=headers, params=params)
return response.xpath(xpath) if xpath else response
+ def _graph_request(self, json_data):
+ return self.config.post_json(graphql_url, headers={"content-type": "application/json"}, json=json_data)
+
def validate_imdb_lists(self, err_type, imdb_lists, language):
valid_lists = []
for imdb_dict in util.get_list(imdb_lists, split=False):
@@ -102,12 +156,6 @@ class IMDb:
if imdb_url.startswith(urls["lists"]):
xpath_total = "//div[@class='desc lister-total-num-results']/text()"
per_page = 100
- elif imdb_url.startswith(urls["searches"]):
- xpath_total = "//div[@class='desc']/span/text()"
- per_page = 250
- elif imdb_url.startswith(urls["title_text_searches"]):
- xpath_total = "//div[@class='desc']/span/text()"
- per_page = 50
else:
xpath_total = "//div[@class='desc']/text()"
per_page = 50
@@ -135,7 +183,6 @@ class IMDb:
params.pop("page", None) # noqa
logger.trace(f"URL: {imdb_base}")
logger.trace(f"Params: {params}")
- search_url = imdb_base.startswith(urls["searches"])
if limit < 1 or total < limit:
limit = total
remainder = limit % item_count
@@ -145,15 +192,9 @@ class IMDb:
for i in range(1, num_of_pages + 1):
start_num = (i - 1) * item_count + 1
logger.ghost(f"Parsing Page {i}/{num_of_pages} {start_num}-{limit if i == num_of_pages else i * item_count}")
- if search_url:
- params["count"] = remainder if i == num_of_pages else item_count # noqa
- params["start"] = start_num # noqa
- elif imdb_base.startswith(urls["title_text_searches"]):
- params["start"] = start_num # noqa
- else:
- params["page"] = i # noqa
+ params["page"] = i # noqa
ids_found = self._request(imdb_base, language=language, xpath="//div[contains(@class, 'lister-item-image')]//a/img//@data-tconst", params=params)
- if not search_url and i == num_of_pages:
+ if i == num_of_pages:
ids_found = ids_found[:remainder]
imdb_ids.extend(ids_found)
time.sleep(2)
@@ -162,6 +203,206 @@ class IMDb:
return imdb_ids
raise Failed(f"IMDb Error: No IMDb IDs Found at {imdb_url}")
+ def _search_json(self, data):
+ out = {
+ "locale": "en-US",
+ "first": data["limit"] if "limit" in data and data["limit"] < 250 else 250,
+ "titleTypeConstraint": {"anyTitleTypeIds": [title_type_options[t] for t in data["type"]] if "type" in data else []},
+ }
+ sort = data["sort_by"] if "sort_by" in data else "popularity.asc"
+ sort_by, sort_order = sort.split(".")
+ out["sortBy"] = sort_by_options[sort_by]
+ out["sortOrder"] = sort_order.upper()
+
+ if "type.not" in data:
+ out["titleTypeConstraint"]["excludeTitleTypeIds"] = [title_type_options[t] for t in data["type.not"]]
+
+ if "release.after" in data or "release.before" in data:
+ num_range = {}
+ if "release.after" in data:
+ num_range["start"] = data["release.after"]
+ if "release.before" in data:
+ num_range["end"] = data["release.before"]
+ out["releaseDateConstraint"] = {"releaseDateRange": num_range}
+
+ if "title" in data:
+ out["titleTextConstraint"] = {"searchTerm": data["title"]}
+
+ if any([a in data for a in ["rating.gte", "rating.lte", "votes.gte", "votes.lte"]]):
+ out["userRatingsConstraint"] = {}
+ num_range = {}
+ if "rating.gte" in data:
+ num_range["min"] = data["rating.gte"]
+ if "rating.lte" in data:
+ num_range["max"] = data["rating.lte"]
+ out["userRatingsConstraint"]["aggregateRatingRange"] = num_range
+ num_range = {}
+ if "votes.gte" in data:
+ num_range["min"] = data["votes.gte"]
+ if "votes.lte" in data:
+ num_range["max"] = data["votes.lte"]
+ out["userRatingsConstraint"]["ratingsCountRange"] = num_range
+
+ if any([a in data for a in ["genre", "genre.any", "genre.not"]]):
+ out["genreConstraint"] = {}
+ if "genre" in data:
+ out["genreConstraint"]["allGenreIds"] = [genre_options[g] for g in data["genre"]]
+ if "genre.any" in data:
+ out["genreConstraint"]["anyGenreIds"] = [genre_options[g] for g in data["genre.any"]]
+ if "genre.not" in data:
+ out["genreConstraint"]["excludeGenreIds"] = [genre_options[g] for g in data["genre.not"]]
+
+ if "event" in data or "event.winning" in data:
+ input_list = []
+ if "event" in data:
+ input_list.extend([event_options[a] if a in event_options else {"eventId": a} for a in data["event"]])
+ if "event.winning" in data:
+ for a in data["event.winning"]:
+ award_dict = event_options[a] if a in event_options else {"eventId": a}
+ award_dict["winnerFilter"] = "WINNER_ONLY"
+ input_list.append(award_dict)
+ out["awardConstraint"] = {"allEventNominations": input_list}
+
+ if any([a in data for a in ["imdb_top", "imdb_bottom", "popularity.gte", "popularity.lte"]]):
+ ranges = []
+ if "imdb_top" in data:
+ ranges.append({"rankRange": {"max": data["imdb_top"]}, "rankedTitleListType": "TOP_RATED_MOVIES"})
+ if "imdb_bottom" in data:
+ ranges.append({"rankRange": {"max": data["imdb_bottom"]}, "rankedTitleListType": "LOWEST_RATED_MOVIES"})
+ if "popularity.gte" in data or "popularity.lte" in data:
+ num_range = {}
+ if "popularity.lte" in data:
+ num_range["max"] = data["popularity.lte"]
+ if "popularity.gte" in data:
+ num_range["min"] = data["popularity.gte"]
+ ranges.append({"rankRange": num_range, "rankedTitleListType": "TITLE_METER"})
+ out["rankedTitleListConstraint"] = {"allRankedTitleLists": ranges}
+
+ if any([a in data for a in ["series", "series.any", "series.not"]]):
+ out["episodicConstraint"] = {}
+ if "series" in data:
+ out["episodicConstraint"]["allSeriesIds"] = data["series"]
+ if "series.any" in data:
+ out["episodicConstraint"]["anySeriesIds"] = data["series.any"]
+ if "series.not" in data:
+ out["episodicConstraint"]["excludeSeriesIds"] = data["series.not"]
+
+ if any([a in data for a in ["list", "list.any", "list.not"]]):
+ out["listConstraint"] = {}
+ if "list" in data:
+ out["listConstraint"]["inAllLists"] = data["list"]
+ if "list.any" in data:
+ out["listConstraint"]["inAnyList"] = data["list.any"]
+ if "list.not" in data:
+ out["listConstraint"]["notInAnyList"] = data["list.not"]
+
+ if "company" in data:
+ company_ids = []
+ for c in data["company"]:
+ if c in company_options:
+ company_ids.extend(company_options[c])
+ else:
+ company_ids.append(c)
+ out["creditedCompanyConstraint"] = {"anyCompanyIds": company_ids}
+
+ if "content_rating" in data:
+ out["certificateConstraint"] = {"anyRegionCertificateRatings": data["content_rating"]}
+
+ if any([a in data for a in ["country", "country.any", "country.not", "country.origin"]]):
+ out["originCountryConstraint"] = {}
+ if "country" in data:
+ out["originCountryConstraint"]["allCountries"] = data["country"]
+ if "country.any" in data:
+ out["originCountryConstraint"]["anyCountries"] = data["country.any"]
+ if "country.not" in data:
+ out["originCountryConstraint"]["excludeCountries"] = data["country.not"]
+ if "country.origin" in data:
+ out["originCountryConstraint"]["anyPrimaryCountries"] = data["country.origin"]
+
+ if any([a in data for a in ["keyword", "keyword.any", "keyword.not"]]):
+ out["keywordConstraint"] = {}
+ if "keyword" in data:
+ out["keywordConstraint"]["allKeywords"] = data["keyword"]
+ if "keyword.any" in data:
+ out["keywordConstraint"]["anyKeywords"] = data["keyword.any"]
+ if "keyword.not" in data:
+ out["keywordConstraint"]["excludeKeywords"] = data["keyword.not"]
+
+ if any([a in data for a in ["language", "language.any", "language.not", "language.primary"]]):
+ out["languageConstraint"] = {}
+ if "language" in data:
+ out["languageConstraint"]["allLanguages"] = data["language"]
+ if "language.any" in data:
+ out["languageConstraint"]["anyLanguages"] = data["language.any"]
+ if "language.not" in data:
+ out["languageConstraint"]["excludeLanguages"] = data["language.not"]
+ if "language.primary" in data:
+ out["languageConstraint"]["anyPrimaryLanguages"] = data["language.primary"]
+
+ if any([a in data for a in ["cast", "cast.any", "cast.not"]]):
+ out["creditedNameConstraint"] = {}
+ if "cast" in data:
+ out["creditedNameConstraint"]["allNameIds"] = data["cast"]
+ if "cast.any" in data:
+ out["creditedNameConstraint"]["anyNameIds"] = data["cast.any"]
+ if "cast.not" in data:
+ out["creditedNameConstraint"]["excludeNameIds"] = data["cast.not"]
+
+ if "runtime.gte" in data or "runtime.lte" in data:
+ num_range = {}
+ if "runtime.gte" in data:
+ num_range["min"] = data["runtime.gte"]
+ if "runtime.lte" in data:
+ num_range["max"] = data["runtime.lte"]
+ out["runtimeConstraint"] = {"runtimeRangeMinutes": num_range}
+
+ if "adult" in data and data["adult"]:
+ out["explicitContentConstraint"] = {"explicitContentFilter": "INCLUDE_ADULT"}
+
+ logger.trace(out)
+ return {
+ "operationName": "AdvancedTitleSearch",
+ "variables": out,
+ "extensions": {
+ "persistedQuery": {
+ "version": 1,
+ "sha256Hash": "7327d144ec84b57c93f761affe0d0609b0d495f85e8e47fdc76291679850cfda"
+ }
+ }
+ }
+
+ def _search(self, data):
+ json_obj = self._search_json(data)
+ item_count = 250
+ imdb_ids = []
+ logger.ghost("Parsing Page 1")
+ response_json = self._graph_request(json_obj)
+ total = response_json["data"]["advancedTitleSearch"]["total"]
+ limit = data["limit"] if "limit" in data else 0
+ if limit < 1 or total < limit:
+ limit = total
+ remainder = limit % item_count
+ if remainder == 0:
+ remainder = item_count
+ num_of_pages = math.ceil(int(limit) / item_count)
+ end_cursor = response_json["data"]["advancedTitleSearch"]["pageInfo"]["endCursor"]
+ imdb_ids.extend([n["node"]["title"]["id"] for n in response_json["data"]["advancedTitleSearch"]["edges"]])
+ if num_of_pages > 1:
+ for i in range(2, num_of_pages + 1):
+ start_num = (i - 1) * item_count + 1
+ logger.ghost(f"Parsing Page {i}/{num_of_pages} {start_num}-{limit if i == num_of_pages else i * item_count}")
+ json_obj["variables"]["after"] = end_cursor
+ response_json = self._graph_request(json_obj)
+ end_cursor = response_json["data"]["advancedTitleSearch"]["pageInfo"]["endCursor"]
+ ids_found = [n["node"]["title"]["id"] for n in response_json["data"]["advancedTitleSearch"]["edges"]]
+ if i == num_of_pages:
+ ids_found = ids_found[:remainder]
+ imdb_ids.extend(ids_found)
+ logger.exorcise()
+ if len(imdb_ids) > 0:
+ return imdb_ids
+ raise Failed("IMDb Error: No IMDb IDs Found")
+
def keywords(self, imdb_id, language, ignore_cache=False):
imdb_keywords = {}
expired = None
@@ -238,6 +479,11 @@ class IMDb:
elif method == "imdb_watchlist":
logger.info(f"Processing IMDb Watchlist: {data}")
return [(_i, "imdb") for _i in self._watchlist(data, language)]
+ elif method == "imdb_search":
+ logger.info(f"Processing IMDb Search:")
+ for k, v in data.items():
+ logger.info(f" {k}: {v}")
+ return [(_i, "imdb") for _i in self._search(data)]
else:
raise Failed(f"IMDb Error: Method {method} not supported")
diff --git a/modules/meta.py b/modules/meta.py
index be6b32cd..e912da9f 100644
--- a/modules/meta.py
+++ b/modules/meta.py
@@ -1631,7 +1631,10 @@ class MetadataFile(DataFile):
current = str(getattr(current_item, key, ""))
final_value = None
if var_type == "date":
- final_value = util.validate_date(value, name, return_as="%Y-%m-%d")
+ try:
+ final_value = util.validate_date(value, return_as="%Y-%m-%d")
+ except Failed as ei:
+ raise Failed(f"{self.type_str} Error: {name} {ei}")
current = current[:-9]
elif var_type == "float":
try:
diff --git a/modules/operations.py b/modules/operations.py
index 3a371909..5d986b40 100644
--- a/modules/operations.py
+++ b/modules/operations.py
@@ -857,7 +857,7 @@ class Operations:
if self.library.radarr_remove_by_tag:
logger.info("")
- logger.separator(f"Radarr Remove {len(self.library.sonarr_remove_by_tag)} Movies with Tags: {', '.join(self.library.sonarr_remove_by_tag)}", space=False, border=False)
+ logger.separator(f"Radarr Remove {len(self.library.radarr_remove_by_tag)} Movies with Tags: {', '.join(self.library.radarr_remove_by_tag)}", space=False, border=False)
logger.info("")
self.library.Radarr.remove_all_with_tags(self.library.radarr_remove_by_tag)
if self.library.sonarr_remove_by_tag:
diff --git a/modules/tmdb.py b/modules/tmdb.py
index 6fb0f193..c56f9ae1 100644
--- a/modules/tmdb.py
+++ b/modules/tmdb.py
@@ -135,7 +135,7 @@ class TMDbMovie(TMDBObj):
raise Failed(f"TMDb Error: No Movie found for TMDb ID {self.tmdb_id}")
except TMDbException as e:
logger.stacktrace()
- raise Failed(f"TMDb Error: Unexpected Error with TMDb ID {self.tmdb_id}: {e}")
+ raise TMDbException(f"TMDb Error: Unexpected Error with TMDb ID {self.tmdb_id}: {e}")
class TMDbShow(TMDBObj):
@@ -172,7 +172,7 @@ class TMDbShow(TMDBObj):
raise Failed(f"TMDb Error: No Show found for TMDb ID {self.tmdb_id}")
except TMDbException as e:
logger.stacktrace()
- raise Failed(f"TMDb Error: Unexpected Error with TMDb ID {self.tmdb_id}: {e}")
+ raise TMDbException(f"TMDb Error: Unexpected Error with TMDb ID {self.tmdb_id}: {e}")
class TMDb:
def __init__(self, config, params):
@@ -344,7 +344,10 @@ class TMDb:
limit = int(attrs.pop("limit"))
for date_attr in date_methods:
if date_attr in attrs:
- attrs[date_attr] = util.validate_date(attrs[date_attr], f"tmdb_discover attribute {date_attr}", return_as="%Y-%m-%d")
+ try:
+ attrs[date_attr] = util.validate_date(attrs[date_attr], return_as="%Y-%m-%d")
+ except Failed as e:
+ raise Failed(f"Collection Error: tmdb_discover attribute {date_attr}: {e}")
if is_movie and region and "region" not in attrs:
attrs["region"] = region
logger.trace(f"Params: {attrs}")
diff --git a/modules/util.py b/modules/util.py
index 05a084ee..27a8dae6 100644
--- a/modules/util.py
+++ b/modules/util.py
@@ -262,14 +262,14 @@ def get_int_list(data, id_type):
except Failed as e: logger.error(e)
return int_values
-def validate_date(date_text, method, return_as=None):
+def validate_date(date_text, return_as=None):
if isinstance(date_text, datetime):
date_obg = date_text
else:
try:
date_obg = datetime.strptime(str(date_text), "%Y-%m-%d" if "-" in str(date_text) else "%m/%d/%Y")
except ValueError:
- raise Failed(f"Collection Error: {method}: {date_text} must match pattern YYYY-MM-DD (e.g. 2020-12-25) or MM/DD/YYYY (e.g. 12/25/2020)")
+ raise Failed(f"{date_text} must match pattern YYYY-MM-DD (e.g. 2020-12-25) or MM/DD/YYYY (e.g. 12/25/2020)")
return datetime.strftime(date_obg, return_as) if return_as else date_obg
def validate_regex(data, col_type, validate=True):
@@ -513,7 +513,10 @@ def is_date_filter(value, modifier, data, final, current_time):
or (modifier == ".not" and value and value >= threshold_date):
return True
elif modifier in [".before", ".after"]:
- filter_date = validate_date(data, final)
+ try:
+ filter_date = validate_date(data)
+ except Failed as e:
+ raise Failed(f"Collection Error: {final}: {e}")
if (modifier == ".before" and value >= filter_date) or (modifier == ".after" and value <= filter_date):
return True
elif modifier == ".regex":
@@ -741,21 +744,23 @@ def parse_and_or(error, attribute, data, test_list):
out += ")"
return out, final
-def parse(error, attribute, data, datatype=None, methods=None, parent=None, default=None, options=None, translation=None, minimum=1, maximum=None, regex=None, range_split=None):
+def parse(error, attribute, data, datatype=None, methods=None, parent=None, default=None, options=None, translation=None, minimum=1, maximum=None, regex=None, range_split=None, date_return=None):
display = f"{parent + ' ' if parent else ''}{attribute} attribute"
if options is None and translation is not None:
options = [o for o in translation]
value = data[methods[attribute]] if methods and attribute in methods else data
- if datatype in ["list", "commalist", "strlist", "lowerlist"]:
+ if datatype in ["list", "commalist", "strlist", "lowerlist", "upperlist"]:
final_list = []
if value:
- if datatype in ["commalist", "strlist"] and isinstance(value, dict):
+ if isinstance(value, dict):
raise Failed(f"{error} Error: {display} {value} must be a list or string")
if datatype == "commalist":
value = get_list(value)
if datatype == "lowerlist":
value = get_list(value, lower=True)
+ if datatype == "upperlist":
+ value = get_list(value, upper=True)
if not isinstance(value, list):
value = [value]
for v in value:
@@ -840,11 +845,16 @@ def parse(error, attribute, data, datatype=None, methods=None, parent=None, defa
message = f"{message} {minimum} or greater" if maximum is None else f"{message} between {minimum} and {maximum}"
if range_split:
message = f"{message} separated by a {range_split}"
+ elif datatype == "date":
+ try:
+ return validate_date(datetime.now() if data == "today" else data, return_as=date_return)
+ except Failed as e:
+ message = f"{e}"
elif (translation is not None and str(value).lower() not in translation) or \
(options is not None and translation is None and str(value).lower() not in options):
message = f"{display} {value} must be in {', '.join([str(o) for o in options])}"
else:
- return translation[value] if translation is not None else value
+ return translation[str(value).lower()] if translation is not None else value
if default is None:
raise Failed(f"{error} Error: {message}")