Merge pull request #829 from wumode/imdbsource

This commit is contained in:
jxxghp
2025-07-03 06:42:45 +08:00
committed by GitHub
4 changed files with 539 additions and 105 deletions

View File

@@ -434,11 +434,12 @@
"name": "IMDb源",
"description": "让探索支持IMDb数据源。",
"labels": "探索",
"version": "1.3.3",
"version": "1.4.0",
"icon": "IMDb_IOS-OSX_App.png",
"author": "wumode",
"level": 1,
"history": {
"v1.4.0":"添加仪表盘组件: IMDb 编辑精选",
"v1.3.3": "修复依赖问题",
"v1.3.2": "更新 API query hash",
"v1.3.1": "修复按日期排序错误",

View File

@@ -3909,7 +3909,7 @@ const _hoisted_39 = { style: {"position":"absolute","right":"0","bottom":"0"} };
const _hoisted_40 = { class: "d-flex flex-column justify-space-between gap-1" };
const _hoisted_41 = { class: "d-flex justify-space-between text-body-2 border-b pb-1" };
const _hoisted_42 = { class: "d-flex justify-space-between text-body-2 border-b pb-1" };
const _hoisted_43 = { class: "text-white" };
const _hoisted_43 = { };
const _hoisted_44 = { class: "d-flex justify-space-between text-body-2 border-b pb-1" };
const _hoisted_45 = { class: "d-flex justify-space-between text-body-2 border-b pb-1" };
const _hoisted_46 = { class: "d-flex justify-space-between text-body-2 border-b pb-1" };

View File

@@ -1,12 +1,13 @@
from typing import Optional, Any, List, Dict, Tuple
from datetime import datetime
import re
from app.core.config import settings
from app.core.event import eventmanager, Event
from app.plugins import _PluginBase
from app.schemas import DiscoverSourceEventData, MediaRecognizeConvertEventData, RecommendSourceEventData
from app.schemas.types import ChainEventType, MediaType
from app.plugins.imdbsource.imdb_helper import ImdbHelper
from app.plugins.imdbsource.imdbhelper import ImdbHelper
from app import schemas
from app.utils.http import RequestUtils
@@ -19,7 +20,7 @@ class ImdbSource(_PluginBase):
# 插件图标
plugin_icon = "IMDb_IOS-OSX_App.png"
# 插件版本
plugin_version = "1.3.3"
plugin_version = "1.4.0"
# 插件作者
plugin_author = "wumode"
# 作者主页
@@ -31,10 +32,13 @@ class ImdbSource(_PluginBase):
# 可使用的用户级别
auth_level = 1
# 私有属性
_enabled = False
_proxy = False
# 插件配置
_enabled: bool = False
_proxy: bool = False
_staff_picks: bool = False
_component_size: str = 'medium'
# 私有属性
_imdb_helper = None
_cache = {"discover": [], "trending": [], "trending_in_anime": [], "trending_in_sitcom": [],
"trending_in_documentary": [], "imdb_top_250": []}
@@ -43,6 +47,9 @@ class ImdbSource(_PluginBase):
if config:
self._enabled = config.get("enabled")
self._proxy = config.get("proxy")
self._staff_picks = config.get("staff_picks")
self._component_size = config.get("component_size", "medium")
self._imdb_helper = ImdbHelper()
self._imdb_helper = ImdbHelper(proxies=settings.PROXY if self._proxy else None)
if "media-amazon.com" not in settings.SECURITY_IMAGE_DOMAINS:
settings.SECURITY_IMAGE_DOMAINS.append("media-amazon.com")
@@ -52,6 +59,358 @@ class ImdbSource(_PluginBase):
def get_state(self) -> bool:
return self._enabled
def get_dashboard_meta(self) -> Optional[List[Dict[str, str]]]:
if not self._staff_picks:
return []
return [
{
"key": "Staff Picks",
"name": "IMDb 编辑精选"
},
]
def get_dashboard(self, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], List[dict]]]:
"""
获取插件仪表盘页面需要返回1、仪表板col配置字典2、全局配置自动刷新等3、仪表板页面元素配置json含数据
1、col配置参考
{
"cols": 12, "md": 6
}
2、全局配置参考
{
"refresh": 10 // 自动刷新时间,单位秒
}
3、页面配置使用Vuetify组件拼装参考https://vuetifyjs.com/
"""
if not self._staff_picks:
return None
def year_and_type(entry: Dict) -> Tuple[MediaType, str]:
title = next((t for t in titles if t.get("id") == entry.get('ttconst')), None)
if not title:
return MediaType.MOVIE, datetime.now().date().strftime("%Y")
media_id = title.get('titleType', {}).get('id')
release_year = title.get('releaseYear', {}).get('year') or datetime.now().date().strftime("%Y")
media_type = ImdbSource.title_id_to_mtype(media_id)
return media_type, release_year
# 列配置
size_config = {
"small": {"cols": {"cols": 12, "md": 4}, "height": 335},
"medium": {"cols": {"cols": 12, "md": 8}, "height": 335},
}
config = size_config.get(self._component_size, 'medium')
cols = config["cols"]
height = config["height"]
is_mobile = ImdbSource.is_mobile(kwargs.get('user_agent'))
cast_num = 8
if self._component_size == "small":
cast_num = 4
if is_mobile:
height *= 2
cast_num = 3
# 全局配置
attrs = {
"border": False
}
# 获取流行越势数据
entries = self._imdb_helper.staff_picks()
items = None
if entries:
items = self._imdb_helper.vertical_list_page_items(
titles=[entry.get('ttconst', '') for entry in entries],
names=[item for entry in entries for item in entry.get("relatedconst", [])],
images=[entry.get('rmconst', '') for entry in entries],
)
if not entries or not items:
elements = [
{
'component': 'VCard',
'content': [
{
'component': 'VCardText',
'props': {
'class': 'text-center',
},
'content': [
{
'component': 'span',
'props': {
'class': 'text-h6'
},
'text': '无数据'
}
]
}
]
}
]
return cols, attrs, elements
images = items.get('images') or []
names = items.get('names') or []
titles = items.get('titles') or []
contents = []
for entry in entries:
cast = [name for related in entry.get('relatedconst', []) for name in names if name.get('id') == related]
mtype, year = year_and_type(entry)
mp_url = f"/media?mediaid=imdb:{entry.get('ttconst')}&title='{entry.get('name')}'&year={year}&type={mtype.value}"
item1 = {
'component': 'VCarouselItem',
'props': {
'src': next((f"{image.get('url')}" for image in images
if image.get("id") == entry.get('rmconst')), None),
'cover': True,
'position': 'center',
},
'content': [
{
'component': 'VCardText',
'props': {
'class': 'w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 pa-4',
},
'content': [
{
'component': 'RouterLink',
'props': {
'to': mp_url,
'class': 'no-underline'
},
'content': [{
'component': 'h1',
'props': {
'class': 'mb-1 text-white text-shadow font-extrabold text-2xl line-clamp-2 overflow-hidden text-ellipsis ...'
},
'html': f"{entry.get('name', '')} <span class='text-base font-normal'>{year_and_type(entry)[1]}</span>",
},
{
'component': 'span',
'props': {
'class': 'text-shadow line-clamp-2 overflow-hidden text-ellipsis ...'
},
'html': entry.get('description', ''),
}
]
},
]
}
]
}
cast_ui = {
'component': 'div',
'props': {
'class': 'd-flex flex-row align-center flex-wrap mt-4 gap-4',
},
'content':
[
{
'component': 'div',
'props': {'class': 'd-flex flex-column align-center'},
'content': [
{
'component': 'a',
'props': {
'href': f"https://www.imdb.com/name/{cs.get('id', '')}",
'target': '_blank',
'rel': 'noopener noreferrer',
'class': 'text-h4 font-weight-bold mb-2 d-flex align-center',
},
'content': [
{
'component': 'VAvatar',
'props': {
'size': f'{48 if (is_mobile or self._component_size == "small") else 64}',
'class': 'mb-1'
},
'content': [
{
'component': 'VImg',
'props': {
'src': cs.get('primaryImage', {}).get('url',
''),
'alt': cs.get('nameText', {}).get('text', 'Avatar'),
'cover': True
}
}
]
},
]
},
{
'component': 'span',
'props': {
'class': 'text-caption text-center d-inline-block text-truncate',
'style': 'max-width: 72px;'
},
'html': cs.get('nameText', {}).get('text', ''),
}
]
} for cs in cast[:cast_num]
]
}
poster_com = {
'component': 'VImg',
'props': {
'src': next(
(f"{title.get('primaryImage', {}).get('url')}" for title in titles if
title.get("id") == entry.get('ttconst')), None),
'class': 'ma-4 rounded-lg',
'width': '160',
'height': '250',
'cover': True,
}
}
poster_ui = {
'component': 'div',
'props': {
'class': 'd-flex flex-column align-center',
},
'content': [
{
'component': 'a',
'props': {
'href': f"#{mp_url}",
'class': 'no-underline d-flex',
# 'style': 'width: 160px;'
},
'content': [
poster_com
]
}
]
}
title_ui = {
'component': 'div',
'props': {
'class': 'd-flex flex-column justify-end',
},
'content': [
{
'component': 'a',
'props': {
'href': f"https://www.imdb.com/title/{entry.get('ttconst', '')}",
'target': '_blank',
'rel': 'noopener noreferrer',
'class': 'text-h4 font-weight-bold mb-2 d-flex align-center',
},
'content': [
{
'component': 'span',
'html': f"{entry.get('name', '')}"
},
{
'component': 'v-icon',
'props': {
'class': 'ml-2',
'size': 'small'
},
'text': 'mdi-chevron-right'
}
]
},
{
'component': 'div',
'props': {
'class': 'text-yellow font-weight-bold mb-2',
},
'html': entry.get('detail', ''),
},
{
'component': 'span',
'props': {
'class': 'text-shadow text-body-2 line-clamp-4 overflow-hidden',
'style': 'text-align: justify; hyphens: auto;'
},
'html': entry.get('description', ''),
},
cast_ui
]
}
item2 = {
'component': 'VCarouselItem',
'props': {
'src': next((f"{image.get('url')}" for image in images
if image.get("id") == entry.get('rmconst')), None),
'cover': True,
'position': 'center',
},
'content': [
{
'component': 'div',
'props': {
'class': 'absolute top-0 left-0 right-0 bottom-0 bg-black opacity-70',
'style': 'z-index: 1;'
}
},
{
'component': 'VCardText',
'props': {
'class': 'd-flex flex-row absolute pa-4 text-white',
'style': 'z-index: 2; bottom: 0;',
},
'content': [
{
'component': 'VRow',
'content': [
# 左图:海报
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
poster_ui
]
},
# 右侧内容区域
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 8,
'class': 'd-flex'
},
'content': [
title_ui
]
}
]
},
]
}
]
}
contents.append(item1)
contents.append(item2)
elements = [
{
'component': 'VCard',
'props': {
'class': 'p-0'
},
'content': [
{
'component': 'VCarousel',
'props': {
'continuous': True,
'show-arrows': 'hover',
'hide-delimiters': True,
'cycle': True,
'interval': 10000,
'height': height
},
'content': contents
}
]
}]
return cols, attrs, elements
@staticmethod
def get_command() -> List[Dict[str, Any]]:
pass
@@ -95,14 +454,57 @@ class ImdbSource(_PluginBase):
}
}
]
}
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'staff_picks',
'label': 'IMDb 编辑精选组件',
}
}
]
},
],
},
{
"component": "VRow",
"content": [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 3
},
'content': [
{
'component': 'VSelect',
'props': {
'model': 'component_size',
'label': '组件规格',
'items': [
{"title": "小型", "value": "small"},
{"title": "中型", "value": "medium"},
]
}
}
]
}
]
}
],
}
], {
"enabled": False,
"proxy": False
"proxy": False,
"staff_picks": False,
"component_size": "medium"
}
def get_page(self) -> List[dict]:
@@ -122,7 +524,6 @@ class ImdbSource(_PluginBase):
"id2": self.xxx2,
}
"""
# return {"recognize_media": (self.recognize_media, ModuleExecutionType.Hijack)}
pass
@staticmethod
@@ -211,6 +612,16 @@ class ImdbSource(_PluginBase):
return MediaType.MOVIE
return MediaType.UNKNOWN
@staticmethod
def is_mobile(user_agent):
mobile_keywords = [
'Mobile', 'iPhone', 'Android', 'Kindle', 'Opera Mini', 'Opera Mobi'
]
for keyword in mobile_keywords:
if re.search(keyword, user_agent, re.IGNORECASE):
return True
return False
def trending_in_documentary(self, apikey: str, page: int = 1, count: int = 30) -> List[schemas.MediaInfo]:
if apikey != settings.API_TOKEN:
return []

View File

@@ -1,14 +1,14 @@
import re
from typing import Optional, Any, Dict, Tuple
from typing import Optional, Any, Dict, Tuple, List
from collections import OrderedDict
from dataclasses import dataclass
import json
import requests
from app.log import logger
from app.utils.http import RequestUtils
from app.utils.common import retry
from app.schemas.types import MediaType
from app.core.cache import cached
@@ -36,94 +36,12 @@ class SearchState:
class ImdbHelper:
_query_by_id = """query queryWithVariables($id: ID!) {
title(id: $id) {
id
type
is_adult
primary_title
original_title
start_year
end_year
runtime_minutes
plot
rating {
aggregate_rating
votes_count
}
genres
posters {
url
width
height
}
certificates {
country {
code
name
}
rating
}
spoken_languages {
code
name
}
origin_countries {
code
name
}
critic_review {
score
review_count
}
directors: credits(first: 5, categories: ["director"]) {
name {
id
display_name
avatars {
url
width
height
}
}
}
writers: credits(first: 5, categories: ["writer"]) {
name {
id
display_name
avatars {
url
width
height
}
}
}
casts: credits(first: 5, categories: ["actor", "actress"]) {
name {
id
display_name
avatars {
url
width
height
}
}
characters
}
}
}"""
_endpoint = "https://graph.imdbapi.dev/v1"
_search_endpoint = "https://v3.sg.media-imdb.com/suggestion/x/%s.json?includeVideos=0"
_official_endpoint = "https://caching.graphql.imdb.com/"
_hash_update_url = ("https://raw.githubusercontent.com/wumode/MoviePilot-Plugins/"
"refs/heads/imdbsource_assets/plugins.v2/imdbsource/imdb_hash.json")
_qid_map = {
MediaType.TV: ["tvSeries", "tvMiniSeries", "tvShort", "tvEpisode"],
MediaType.MOVIE: ["movie"]
}
_imdb_headers = {
"Accept": "application/json, text/plain, */*",
"Accept": "text/html,application/json,text/plain,*/*",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome"
"/84.0.4147.105 Safari/537.36",
"Referer": "https://www.imdb.com/",
@@ -153,20 +71,18 @@ class ImdbHelper:
self._search_states = OrderedDict()
self._max_states = 30
def imdbid(self, imdbid: str) -> Optional[Dict]:
params = {"operationName": "queryWithVariables", "query": self._query_by_id, "variables": {"id": imdbid}}
ret = RequestUtils(
accept_type="application/json", content_type="application/json"
).post_res(f"{self._endpoint}", json=params)
@retry(Exception, logger=logger)
@cached(maxsize=32, ttl=1800)
def __query_graphql (self, query: str, variables: Dict[str, Any]) -> Optional[Dict]:
params = {'query': query, 'variables': variables}
ret = self._imdb_req.post_res(f"{self._official_endpoint}", json=params, raise_exception=True)
if not ret:
return None
data = ret.json()
if "errors" in data:
logger.error(f"Imdb query ({imdbid}) errors {data.get('errors')}")
logger.error(f"{params}")
return None
info = data.get("data").get("title", None)
return info
error = data.get("errors")[0] if data.get("errors") else {}
return {'error': error}
return data.get("data")
@retry(Exception, logger=logger)
@cached(maxsize=32, ttl=1800)
@@ -390,3 +306,109 @@ class ImdbHelper:
return None
self.hash_status[operation_name] = True
return data.get('advancedTitleSearch')
def staff_picks(self) -> Optional[List[Dict[str, Any]]]:
"""
{
'name': 'Jurassic World Rebirth',
'editor': 'SWG',
'complete': 'TRUE',
'ttconst': 'tt31036941',
'rmconst': 'rm1150392066',
'imagealign': 'center top',
'detail': 'In theaters Wednesday, July 2',
'description': '',
'viconst': 'vi3122317593',
'relatedconst': ['nm0424060', 'nm0991810']
}
"""
url = 'https://www.imdb.com/imdbpicks/staff-picks/'
html = self._imdb_req.get(url)
if not html:
return None
pattern = r'"jsonData":"{.*?}"'
json_strings = re.findall(pattern, html)
if not json_strings:
return None
try:
json_data = json.loads(f"{{{json_strings[0]}}}")
if json_data and 'jsonData' in json_data:
data = json.loads(json_data['jsonData'])
if 'entries' in data:
entries = data['entries']
for entry in entries:
entry['description'] = re.sub(r'\[(/?)[iI]]', r'<\1i>', entry.get('description', ''))
return entries
except Exception as e:
logger.error(f"Error parsing json: {e}")
return None
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[Dict[str, Any]]:
"""
{
'titles': [
{
'id': 'tt31036941',
'titleText': {
'text': 'Jurassic World: Rebirth'
},
'titleType': {'id': 'movie'},
'releaseYear': {'year': 2025},
'primaryImage': {
'id': 'rm3920935426',
'url': '',
'width': 1257,
'height': 1800
},
'meterRanking': {
'currentRank': 8,
'meterType': 'MOVIE_METER',
'rankChange': {
'changeDirection': 'UP',
'difference': 15
}
},
'ratingsSummary': {'aggregateRating': 6.5}},
],
'images': [
{
'id': 'rm1150392066',
'height': 5504,
'width': 8256,
'url': ''
},
]
'names': [
{
'id': 'nm0424060',
'nameText': {'text': 'Scarlett Johansson'},
'primaryImage': {
'id': 'rm1916122112',
'url': '',
'width': 1689,
'height': 2048
}
},
]
}
"""
query = "query VerticalListPageItems( $titles: [ID!]! $names: [ID!]! $images: [ID!]! $videos: [ID!]! ) {\n titles(ids: $titles) { ...TitleParts meterRanking { currentRank meterType rankChange {changeDirection difference} } ratingsSummary { aggregateRating } }\n names(ids: $names) { ...NameParts }\n videos(ids: $videos) { ...VideoParts }\n images(ids: $images) { ...ImageParts }\n }\n fragment TitleParts on Title {\n id\n titleText { text }\n titleType { id }\n releaseYear { year }\n primaryImage { id url width height }\n}\n fragment NameParts on Name {\n id\n nameText { text }\n primaryImage { id url width height }\n}\n fragment ImageParts on Image {\n id\n height\n width\n url \n}\n fragment VideoParts on Video {\n id\n name { value }\n contentType { displayName { value } id }\n previewURLs { displayName { value } url videoDefinition videoMimeType }\n playbackURLs { displayName { value } url videoDefinition videoMimeType }\n thumbnail { height url width }\n}\n "
variables = {'images': images or [],
'titles': titles or [],
'names': names or [],
'videos': videos or [],
'isRegistered': is_registered,
}
data = self.__query_graphql(query, variables)
if 'error' in data:
error = data['error']
if error:
logger.error(f"Error querying VerticalListPageItems: {error}")
return None
return data