Files
archived-MoviePilot-Plugins/plugins.v2/imdbsource/officialapi.py

614 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import re
from textwrap import dedent
from typing import Any, Dict, List, Optional, Final, AsyncGenerator
import httpx
import requests
from pydantic import ValidationError
from app.core.cache import cached
from app.log import logger
from app.utils.common import retry
from app.utils.http import RequestUtils, AsyncRequestUtils
from .schema.imdbtypes import ImdbType
from .schema import VerticalList, AdvancedTitleSearchResponse, AdvancedTitleSearch, TitleEdge, SearchParams
INTERESTS_ID: Final[Dict[str, Dict[str, str]]] = {
"Action": {
"Action": "in0000001",
"Action Epic": "in0000002",
"B-Action": "in0000003",
"Car Action": "in0000004",
"Disaster": "in0000005",
"Gun Fu": "in0000197",
"Kung Fu": "in0000198",
"Martial Arts": "in0000006",
"One-Person Army Action": "in0000007",
"Samurai": "in0000199",
"Superhero": "in0000008",
"Sword & Sandal": "in0000009",
"War": "in0000010",
"War Epic": "in0000011",
"Wuxia": "in0000200"
},
"Adventure": {
"Adventure": "in0000012",
"Adventure Epic": "in0000015",
"Desert Adventure": "in0000013",
"Dinosaur Adventure": "in0000014",
"Globetrotting Adventure": "in0000016",
"Jungle Adventure": "in0000017",
"Mountain Adventure": "in0000018",
"Quest": "in0000019",
"Road Trip": "in0000020",
"Sea Adventure": "in0000021",
"Swashbuckler": "in0000022",
"Teen Adventure": "in0000023",
"Urban Adventure": "in0000024"
},
"Animation": {
"Adult Animation": "in0000025",
"Animation": "in0000026",
"Computer Animation": "in0000028",
"Hand-Drawn Animation": "in0000029",
"Stop Motion Animation": "in0000030"
},
"Anime": {
"Anime": "in0000027",
"Isekai": "in0000201",
"Iyashikei": "in0000202",
"Josei": "in0000203",
"Mecha": "in0000204",
"Seinen": "in0000205",
"Shōjo": "in0000207",
"Shōnen": "in0000206",
"Slice of Life": "in0000208"
},
"Comedy": {
"Body Swap Comedy": "in0000031",
"Buddy Comedy": "in0000032",
"Buddy Cop": "in0000033",
"Comedy": "in0000034",
"Dark Comedy": "in0000035",
"Farce": "in0000036",
"High-Concept Comedy": "in0000037",
"Mockumentary": "in0000038",
"Parody": "in0000039",
"Quirky Comedy": "in0000040",
"Raunchy Comedy": "in0000041",
"Satire": "in0000042",
"Screwball Comedy": "in0000043",
"Sitcom": "in0000044",
"Sketch Comedy": "in0000045",
"Slapstick": "in0000046",
"Stand-Up": "in0000047",
"Stoner Comedy": "in0000048",
"Teen Comedy": "in0000049"
},
"Crime": {
"Caper": "in0000050",
"Cop Drama": "in0000051",
"Crime": "in0000052",
"Drug Crime": "in0000053",
"Film Noir": "in0000054",
"Gangster": "in0000055",
"Heist": "in0000056",
"Police Procedural": "in0000057",
"True Crime": "in0000058"
},
"Documentary": {
"Crime Documentary": "in0000059",
"Documentary": "in0000060",
"Docuseries": "in0000061",
"Faith & Spirituality Documentary": "in0000062",
"Food Documentary": "in0000063",
"History Documentary": "in0000064",
"Military Documentary": "in0000065",
"Music Documentary": "in0000066",
"Nature Documentary": "in0000067",
"Political Documentary": "in0000068",
"Science & Technology Documentary": "in0000069",
"Sports Documentary": "in0000070",
"Travel Documentary": "in0000071"
},
"Drama": {
"Biography": "in0000072",
"Coming-of-Age": "in0000073",
"Costume Drama": "in0000074",
"Docudrama": "in0000075",
"Drama": "in0000076",
"Epic": "in0000077",
"Financial Drama": "in0000078",
"Historical Epic": "in0000079",
"History": "in0000080",
"Korean Drama": "in0000209",
"Legal Drama": "in0000081",
"Medical Drama": "in0000082",
"Period Drama": "in0000083",
"Political Drama": "in0000084",
"Prison Drama": "in0000085",
"Psychological Drama": "in0000086",
"Showbiz Drama": "in0000087",
"Soap Opera": "in0000088",
"Teen Drama": "in0000089",
"Telenovela": "in0000210",
"Tragedy": "in0000090",
"Workplace Drama": "in0000091"
},
"Family": {
"Animal Adventure": "in0000092",
"Family": "in0000093"
},
"Fantasy": {
"Dark Fantasy": "in0000095",
"Fairy Tale": "in0000097",
"Fantasy": "in0000098",
"Fantasy Epic": "in0000096",
"Supernatural Fantasy": "in0000099",
"Sword & Sorcery": "in0000100",
"Teen Fantasy": "in0000101"
},
"Game Show": {
"Beauty Competition": "in0000102",
"Cooking Competition": "in0000103",
"Game Show": "in0000105",
"Quiz Show": "in0000104",
"Survival Competition": "in0000106",
"Talent Competition": "in0000107"
},
"Horror": {
"B-Horror": "in0000108",
"Body Horror": "in0000109",
"Folk Horror": "in0000110",
"Found Footage Horror": "in0000111",
"Horror": "in0000112",
"Monster Horror": "in0000113",
"Psychological Horror": "in0000114",
"Slasher Horror": "in0000115",
"Splatter Horror": "in0000116",
"Supernatural Horror": "in0000117",
"Teen Horror": "in0000118",
"Vampire Horror": "in0000119",
"Werewolf Horror": "in0000120",
"Witch Horror": "in0000121",
"Zombie Horror": "in0000122"
},
"Lifestyle": {
"Beauty Makeover": "in0000123",
"Cooking & Food": "in0000124",
"Home Improvement": "in0000125",
"Lifestyle": "in0000126",
"News": "in0000211",
"Talk Show": "in0000127",
"Travel": "in0000128"
},
"Music": {
"Concert": "in0000129",
"Music": "in0000130"
},
"Musical": {
"Classic Musical": "in0000131",
"Jukebox Musical": "in0000132",
"Musical": "in0000133",
"Pop Musical": "in0000134",
"Rock Musical": "in0000135"
},
"Mystery": {
"Bumbling Detective": "in0000136",
"Cozy Mystery": "in0000137",
"Hard-boiled Detective": "in0000138",
"Mystery": "in0000139",
"Suspense Mystery": "in0000140",
"Whodunnit": "in0000141"
},
"Reality TV": {
"Business Reality TV": "in0000142",
"Crime Reality TV": "in0000143",
"Dating Reality TV": "in0000144",
"Docusoap Reality TV": "in0000145",
"Hidden Camera": "in0000146",
"Paranormal Reality TV": "in0000147",
"Reality TV": "in0000148"
},
"Romance": {
"Dark Romance": "in0000149",
"Feel-Good Romance": "in0000151",
"Romance": "in0000152",
"Romantic Comedy": "in0000153",
"Romantic Epic": "in0000150",
"Steamy Romance": "in0000154",
"Teen Romance": "in0000155",
"Tragic Romance": "in0000156"
},
"Sci-Fi": {
"Alien Invasion": "in0000157",
"Artificial Intelligence": "in0000158",
"Cyberpunk": "in0000159",
"Dystopian Sci-Fi": "in0000160",
"Kaiju": "in0000161",
"Sci-Fi": "in0000162",
"Sci-Fi Epic": "in0000163",
"Space Sci-Fi": "in0000164",
"Steampunk": "in0000165",
"Time Travel": "in0000166"
},
"Seasonal": {
"Holiday": "in0000192",
"Holiday Animation": "in0000193",
"Holiday Comedy": "in0000194",
"Holiday Family": "in0000195",
"Holiday Romance": "in0000196"
},
"Short": {
"Short": "in0000212"
},
"Sport": {
"Baseball": "in0000167",
"Basketball": "in0000168",
"Boxing": "in0000169",
"Extreme Sport": "in0000170",
"Football": "in0000171",
"Motorsport": "in0000172",
"Soccer": "in0000173",
"Sport": "in0000174",
"Water Sport": "in0000175"
},
"Thriller": {
"Conspiracy Thriller": "in0000176",
"Cyber Thriller": "in0000177",
"Erotic Thriller": "in0000178",
"Giallo": "in0000179",
"Legal Thriller": "in0000180",
"Political Thriller": "in0000181",
"Psychological Thriller": "in0000182",
"Serial Killer": "in0000183",
"Spy": "in0000184",
"Survival": "in0000185",
"Thriller": "in0000186"
},
"Western": {
"Classical Western": "in0000187",
"Contemporary Western": "in0000188",
"Spaghetti Western": "in0000190",
"Western": "in0000191",
"Western Epic": "in0000189"
}
}
COMPANY_ID = {
"20th Century Fox": ["co0000756", "co0176225", "co0201557", "co0017497"],
"DreamWorks": ["co0067641", "co0040938", "co0252576", "co0003158"],
"MGM": ["co0007143", "co0026841"],
"Paramount": ["co0023400"],
"Sony": ["co0050868", "co0026545", "co0121181"],
"Universal": ["co0005073", "co0055277", "co0042399"],
"Walt Disney": ["co0008970", "co0017902", "co0098836", "co0059516", "co0092035", "co0049348"],
"Warner Bros.": ["co0002663", "co0005035", "co0863266", "co0072876", "co0080422", "co0046718"],
"HBO": ["co0008693", "co0754095", "co0306346", "co0148466", "co0909975", "co0638197", "co0391378"],
"Netflix": ["co0144901", "co0805756"],
"Hulu": ["co0218858", "co0381648"],
"Amazon Prime Video": ["co0476953", "co1160313", "co0939864", "co0931938"],
"Apple TV": ["co0931939", "co0546168"],
"British Broadcasting Corporation (BBC)": ['co0043107'],
"Tencent Video": ["co0487058"],
"Youku": ["co0264223"],
"iQIYI": ["co0493506", "co0691262"],
"China Central Television (CCTV)": ['co0001524'],
"Huayi Brothers Media": ["co0099734"],
"Beijing Enlight Pictures": ["co0208796"],
"Bona Film Group": ["co0452101"],
}
CACHE_LIFETIME: Final[int] = 86400
IMDB_GRAPHQL_QUERY: Final[str] = dedent("""
query VerticalListPageItems( $titles: [ID!]! $names: [ID!]! $images: [ID!]! $videos: [ID!]!) {
titles(ids: $titles) { ...TitleParts meterRanking { currentRank meterType rankChange {changeDirection difference} } ratingsSummary { aggregateRating voteCount} }
names(ids: $names) { ...NameParts }
videos(ids: $videos) { ...VideoParts }
images(ids: $images) { ...ImageParts }
}
fragment TitleParts on Title {
id
titleText { text }
titleType { id }
releaseYear { year }
akas(first: 50) { edges { node { text country { id text } language { text } } } }
plot { plotText {plainText}}
primaryImage { id url width height }
releaseDate {day month year}
titleGenres {genres {genre { text }}}
certificate { rating }
originalTitleText{ text }
runtime { seconds }
}
fragment NameParts on Name {
id
nameText { text }
primaryImage { id url width height }
}
fragment ImageParts on Image {
id
height
width
url
}
fragment VideoParts on Video {
id
name { value }
contentType { displayName { value } id }
previewURLs { displayName { value } url videoDefinition videoMimeType }
playbackURLs { displayName { value } url videoDefinition videoMimeType }
thumbnail { height url width }
}
""")
class PersistedQueryNotFound(Exception):
def __init__(self, message: str, code: int = None):
super().__init__(message)
self.code = code
class OfficialApiClient:
BASE_URL = "https://caching.graphql.imdb.com/"
def __init__(self, proxies: Optional[Dict[str, str]] = None,
ua: Optional[str] = None):
self._req = RequestUtils(accept_type="application/json",
content_type="application/json",
timeout=10,
ua=ua,
proxies=proxies,
session=requests.Session())
if proxies:
proxy_url = proxies.get("https") or proxies.get("http")
else:
proxy_url = None
self._client = httpx.AsyncClient(timeout=10, proxy=proxy_url)
self._async_req = AsyncRequestUtils(accept_type="application/json", content_type="application/json",
client=self._client, ua=ua)
self.flat_interest_id = {}
for category, value in INTERESTS_ID.items():
for name, in_id in value.items():
self.flat_interest_id[name] = in_id
@cached(maxsize=1024, ttl=CACHE_LIFETIME)
async def _async_request(self, params: Dict[str, Any], sha256: str) -> Optional[Dict]:
params["extensions"] = {"persistedQuery": {"sha256Hash": sha256, "version": 1}}
data = await self._async_req.post_json(f"{self.BASE_URL}", json=params, raise_exception=True)
if not data:
return None
if "errors" in data:
error = data.get("errors")[0] if data.get("errors") else {}
return {'error': error}
return data.get("data")
@retry(Exception, logger=logger, delay=1)
@cached(maxsize=1024, ttl=CACHE_LIFETIME)
def _query_graphql(self, query: str, variables: Dict[str, Any]) -> Optional[dict]:
params = {'query': query, 'variables': variables}
data = self._req.post_json(f"{self.BASE_URL}", json=params, raise_exception=True)
if not data:
return {'error': 'Query failed.'}
if "errors" in data:
error = data.get("errors")[0] if data.get("errors") else {}
return {'error': error}
return data.get("data")
@retry(Exception, logger=logger, delay=1)
@cached(maxsize=1024, ttl=CACHE_LIFETIME)
async def _async_query_graphql(self, query: str, variables: Dict[str, Any]) -> Optional[Dict]:
params = {'query': query, 'variables': variables}
data = await self._async_req.post_json(f"{self.BASE_URL}", json=params, raise_exception=True)
if not data:
return None
if "errors" in data:
error = data.get("errors")[0] if data.get("errors") else {}
return {'error': error}
return data.get("data")
@cached(maxsize=1024, ttl=CACHE_LIFETIME)
def vertical_list_page_items(self,
titles: Optional[List[str]] = None,
names: Optional[List[str]] = None,
images: Optional[List[str]] = None,
videos: Optional[List[str]] = None,
is_registered: bool = False
) -> Optional[VerticalList]:
variables = {'images': images or [],
'titles': titles or [],
'names': names or [],
'videos': videos or [],
'isRegistered': is_registered,
}
try:
data = self._query_graphql(IMDB_GRAPHQL_QUERY, variables)
if 'error' in data:
error = data['error']
if error:
logger.error(f"Error querying VerticalListPageItems: {error}")
return None
ret = VerticalList.model_validate(data)
except Exception as e:
logger.debug(f"An error occurred while querying VerticalListPageItems: {e}")
return None
return ret
@cached(maxsize=1024, ttl=CACHE_LIFETIME)
async def async_vertical_list_page_items(self,
titles: Optional[List[str]] = None,
names: Optional[List[str]] = None,
images: Optional[List[str]] = None,
videos: Optional[List[str]] = None,
is_registered: bool = False
) -> Optional[VerticalList]:
variables = {'images': images or [],
'titles': titles or [],
'names': names or [],
'videos': videos or [],
'isRegistered': is_registered,
}
try:
data = await self._async_query_graphql(IMDB_GRAPHQL_QUERY, variables)
if 'error' in data:
error = data['error']
if error:
logger.error(f"Error querying VerticalListPageItems: {error}")
return None
ret = VerticalList.model_validate(data)
except Exception as e:
logger.debug(f"An error occurred while querying VerticalListPageItems: {e}")
return None
return ret
@retry(Exception, logger=logger, delay=1)
async def async_advanced_title_search(self,
params: SearchParams,
sha256: str,
last_cursor: Optional[str] = None,
) -> Optional[AdvancedTitleSearch]:
variables: Dict[str, Any] = {"first": 50,
"locale": "en-US",
"sortBy": params.sort_by,
"sortOrder": params.sort_order,
}
operation_name = 'AdvancedTitleSearch'
if params.title_types:
title_type_ids = []
for title_type in params.title_types:
if title_type in ImdbType._value2member_map_:
title_type_ids.append(title_type)
if len(title_type_ids):
variables["titleTypeConstraint"] = {"anyTitleTypeIds": title_type_ids}
if params.genres:
variables["genreConstraint"] = {"allGenreIds": params.genres, "excludeGenreIds": []}
if params.countries:
variables["originCountryConstraint"] = {"allCountries": params.countries}
if params.languages:
variables["languageConstraint"] = {"anyPrimaryLanguages": params.languages}
if params.rating_min or params.rating_max:
rating_min = params.rating_min if params.rating_min else 1
rating_min = max(rating_min, 1)
rating_max = params.rating_max if params.rating_max else 10
rating_max = min(rating_max, 10)
variables["userRatingsConstraint"] = {"aggregateRatingRange": {"max": rating_max, "min": rating_min}}
if params.release_date_start or params.release_date_end:
release_dict = {}
if params.release_date_start:
release_dict["start"] = params.release_date_start
if params.release_date_end:
release_dict["end"] = params.release_date_end
variables["releaseDateConstraint"] = {"releaseDateRange": release_dict}
if params.award_constraint:
constraints = []
for award in params.award_constraint:
c = self._award_to_constraint(award)
if c:
constraints.append(c)
variables["awardConstraint"] = {"allEventNominations": constraints}
if params.ranked:
constraints = []
for r in params.ranked:
c = OfficialApiClient._ranked_list_to_constraint(r)
if c:
constraints.append(c)
variables["rankedTitleListConstraint"] = {"allRankedTitleLists": constraints,
"excludeRankedTitleLists": []}
if params.interests:
constraints = []
for interest in params.interests:
in_id = self.flat_interest_id.get(interest)
if in_id:
constraints.append(in_id)
variables["interestConstraint"] = {"allInterestIds": constraints, "excludeInterestIds": []}
if params.company:
company_ids = COMPANY_ID.get(params.company)
if company_ids:
variables["creditedCompanyConstraint"] = {"anyCompanyIds": company_ids, "excludeCompanyIds": []}
if last_cursor:
variables["after"] = last_cursor
params = {"operationName": operation_name,
"variables": variables}
data = await self._async_request(params, sha256)
if not data:
return None
if 'error' in data:
error = data['error']
if error:
if error.get('message') == 'PersistedQueryNotFound':
await self._async_request.cache_clear()
raise PersistedQueryNotFound(error['message'])
return None
try:
ret = AdvancedTitleSearchResponse.model_validate(data)
except ValidationError as err:
logger.error(f"{err}")
return None
return ret.advanced_title_search
async def advanced_title_search_generator(self, params: SearchParams, sha256: str) -> AsyncGenerator[
TitleEdge, None]:
last_cursor = None
while True:
response = await self.async_advanced_title_search(params, sha256, last_cursor=last_cursor)
if not response:
return
for edge in response.edges:
yield edge
last_cursor = response.page_info.end_cursor
if not last_cursor or not response.page_info.has_next_page:
break
@staticmethod
def _ranked_list_to_constraint(ranked: str) -> Optional[Dict]:
"""
"TOP_RATED_MOVIES-100": "IMDb Top 100",
"TOP_RATED_MOVIES-250": "IMDb Top 250",
"TOP_RATED_MOVIES-1000": "IMDb Top 1000",
"LOWEST_RATED_MOVIES-100": "IMDb Bottom 100",
"LOWEST_RATED_MOVIES-250": "IMDb Bottom 250",
"LOWEST_RATED_MOVIES-1000": "IMDb Bottom 1000"
"""
pattern = r'^(TOP_RATED_MOVIES|LOWEST_RATED_MOVIES)-(\d+)$'
match = re.match(pattern, ranked)
if match:
ranked_title_list_type = match.group(1)
rank_range = int(match.group(2))
constraint = {"rankRange": {"max": rank_range}, "rankedTitleListType": ranked_title_list_type}
return constraint
return None
@staticmethod
def _award_to_constraint(award: str) -> Optional[Dict]:
pattern = r'^(ev\d+)(?:-(best\w+))?-(Winning|Nominated)$'
match = re.match(pattern, award)
constraint = {}
if match:
# 第一部分evXXXXXXXX
ev_id = match.group(1)
# 第二部分bestXX可选
best = match.group(2)
# 第三部分Winning/Nominated
status = match.group(3)
constraint["eventId"] = ev_id
if status == "Winning":
constraint["winnerFilter"] = "WINNER_ONLY"
if best:
constraint["searchAwardCategoryId"] = best
return constraint
else:
return None
@property
def interests_id(self) -> Dict[str, str]:
return self.flat_interest_id