import html
import re
from datetime import timedelta
import requests
from lib.db.cached import cache
from lib.utils.kodi.utils import kodilog
GQL_URL = "https://graphql.imdb.com/"
GQL_HEADERS = {
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Accept": "application/json",
"Origin": "https://www.imdb.com",
"Referer": "https://www.imdb.com/",
"x-imdb-client-name": "imdb-web-next-localized",
"x-imdb-user-language": "en-US",
"x-imdb-user-country": "US",
}
def _clean(text):
if not text:
return ""
text = text.replace("<br/><br/>", "\n").replace("<br/>", "\n").replace("<br>", "\n")
text = re.sub(r"<a[^>]*>", "", text).replace("</a>", "")
text = re.sub(r"<[^>]+>", "", text)
text = html.unescape(text)
return text.strip()
def get_imdb_extras(imdb_id):
"""
Fetch IMDb extras (reviews, trivia, goofs, parental guide) via GraphQL.
Returns a dict with keys: reviews, trivia, blunders, parentsguide.
"""
if not imdb_id:
return {"reviews": [], "trivia": [], "blunders": [], "parentsguide": []}
cache_key = f"imdb_extras_gql_{imdb_id}"
cached = cache.get(cache_key)
if cached is not None:
return cached
try:
payload = {"query": IMDB_EXTRAS_QUERY % imdb_id}
response = requests.post(GQL_URL, json=payload, headers=GQL_HEADERS, timeout=10)
data = response.json().get("data", {}).get("title", {})
reviews, trivia, blunders, parentsguide = [], [], [], []
# Reviews
try:
count = 1
for edge in sorted(
data["reviews"]["edges"],
key=lambda k: k["node"]["submissionDate"],
reverse=True,
):
try:
content = html.unescape(
_clean(edge["node"]["text"]["originalText"]["plaidHtml"])
)
if not content:
continue
spoiler = edge["node"].get("spoiler", False)
rating = edge["node"].get("authorRating")
rating_str = str(rating) if rating is not None else "-"
title = edge["node"].get("summary", {}).get("originalText", "-----")
date = edge["node"].get("submissionDate", "-----")
review_text = (
f"[B]{count:02d}. [I]{rating_str}/10 - {date} - {title}[/I][/B]"
f"[CR][CR]{content}"
)
if spoiler:
review_text = (
"[B][COLOR red][CONTAINS SPOILERS][/COLOR][CR][/B]" + review_text
)
count += 1
reviews.append(review_text)
except Exception:
pass
except Exception:
pass
# Trivia
try:
count = 1
for edge in sorted(
data["trivia"]["edges"],
key=lambda k: k["node"]["interestScore"]["usersVoted"],
reverse=True,
):
try:
content = html.unescape(
_clean(edge["node"]["displayableArticle"]["body"]["plaidHtml"])
)
trivia.append(f"[B]TRIVIA {count:02d}.[/B][CR][CR]{content}")
count += 1
except Exception:
pass
except Exception:
pass
# Goofs / Blunders
try:
count = 1
for edge in sorted(
data["goofs"]["edges"],
key=lambda k: k["node"]["interestScore"]["usersVoted"],
reverse=True,
):
try:
content = html.unescape(
_clean(edge["node"]["displayableArticle"]["body"]["plaidHtml"])
)
blunders.append(f"[B]BLUNDERS {count:02d}.[/B][CR][CR]{content}")
count += 1
except Exception:
pass
except Exception:
pass
# Parents Guide
try:
title_converter = {
"nudity": "Sex & Nudity",
"violence": "Violence & Gore",
"profanity": "Profanity",
"alcohol": "Alcohol, Drugs & Smoking",
"frightening": "Frightening & Intense Scenes",
}
for category in data["parentsGuide"]["categories"]:
try:
cat_id = category["category"]["id"].lower()
title = title_converter.get(cat_id, cat_id.capitalize())
ranking = category["severity"]["id"].replace("Votes", "")
try:
listings = [
html.unescape(_clean(x["node"]["text"]["plaidHtml"]))
for x in category["guideItems"]["edges"]
]
content = "\n\n".join([f"{c:02d}. {i}" for c, i in enumerate(listings, 1)])
except Exception:
listings = []
content = ""
total_count = len(listings)
parentsguide.append(
{
"title": title,
"ranking": ranking,
"content": content,
"total_count": total_count,
}
)
except Exception:
pass
except Exception:
pass
result = {
"reviews": reviews,
"trivia": trivia,
"blunders": blunders,
"parentsguide": parentsguide,
}
cache.set(cache_key, result, timedelta(hours=168))
return result
except Exception as e:
kodilog(f"Error fetching IMDb GraphQL extras for {imdb_id}: {e}")
return {"reviews": [], "trivia": [], "blunders": [], "parentsguide": []}
def get_imdb_more_like_this(imdb_id):
"""
Fetch "More Like This" titles from IMDb via GraphQL.
Returns a list of IMDb IDs (tt...).
"""
if not imdb_id:
return []
cache_key = f"imdb_more_like_this_{imdb_id}"
cached = cache.get(cache_key)
if cached is not None:
return cached
try:
payload = {
"query": "query($id:ID!){title(id:$id){moreLikeThisTitles(first:12){edges{node{id}}}}}",
"variables": {"id": imdb_id},
}
response = requests.post(GQL_URL, json=payload, headers=GQL_HEADERS, timeout=10)
edges = response.json()["data"]["title"]["moreLikeThisTitles"]["edges"]
result = [edge["node"]["id"] for edge in edges if edge["node"]["id"].startswith("tt")]
# Deduplicate
seen = set()
result = [x for x in result if not (x in seen or seen.add(x))]
cache.set(cache_key, result, timedelta(hours=168))
return result
except Exception as e:
kodilog(f"Error fetching IMDb More Like This for {imdb_id}: {e}")
return []
# Keep legacy functions for backward compatibility until fully migrated
def get_imdb_trivia(imdb_id):
"""Legacy wrapper. Use get_imdb_extras() for new code."""
return get_imdb_extras(imdb_id).get("trivia", [])
def get_imdb_goofs(imdb_id):
"""Legacy wrapper. Use get_imdb_extras() for new code."""
return get_imdb_extras(imdb_id).get("blunders", [])
def get_imdb_parentsguide(imdb_id):
"""Legacy wrapper. Use get_imdb_extras() for new code."""
extras = get_imdb_extras(imdb_id)
guide = extras.get("parentsguide", [])
# Convert to legacy text format for compatibility
result = []
for item in guide:
header = f"[B]{item['title']}[/B]"
if item.get("ranking"):
header += f" — {item['ranking']}"
if item.get("content"):
result.append(f"{header}\n{item['content']}")
else:
result.append(header)
return result
IMDB_EXTRAS_QUERY = """\
query {
title(id: "%s") {
id
titleText {
text
}
trivia(first: 20) {
edges {
node {
displayableArticle {
body {
plaidHtml
}
}
interestScore {
usersVoted
}
}
}
}
goofs(first: 20) {
edges {
node {
displayableArticle {
body {
plaidHtml
}
}
interestScore {
usersVoted
}
}
}
}
reviews(first: 50) {
edges {
node {
spoiler
author {
nickName
}
authorRating
summary {
originalText
}
text {
originalText {
plaidHtml
}
}
submissionDate
}
}
}
parentsGuide {
categories {
category {
id
}
guideItems(first: 10) {
edges {
node {
isSpoiler
text {
plaidHtml
}
}
}
}
severity {
id
votedFor
}
}
}
}
}"""