From f27913898cce5fe94113b24b5bc1e5069aa95b6f Mon Sep 17 00:00:00 2001 From: wumode Date: Thu, 3 Jul 2025 00:32:35 +0800 Subject: [PATCH] =?UTF-8?q?update(ImdbSource):=20=E6=B7=BB=E5=8A=A0=20IMDb?= =?UTF-8?q?=20=E7=BC=96=E8=BE=91=E7=B2=BE=E9=80=89=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 3 +- .../__federation_expose_Page-Bt1EwqOk.js | 2 +- plugins.v2/imdbsource/__init__.py | 427 +++++++++++++++++- .../{imdb_helper.py => imdbhelper.py} | 212 +++++---- 4 files changed, 539 insertions(+), 105 deletions(-) rename plugins.v2/imdbsource/{imdb_helper.py => imdbhelper.py} (70%) diff --git a/package.v2.json b/package.v2.json index 4cc9535..84ce046 100644 --- a/package.v2.json +++ b/package.v2.json @@ -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": "修复按日期排序错误", diff --git a/plugins.v2/clashruleprovider/dist/assets/__federation_expose_Page-Bt1EwqOk.js b/plugins.v2/clashruleprovider/dist/assets/__federation_expose_Page-Bt1EwqOk.js index 00fc6b9..57c477e 100644 --- a/plugins.v2/clashruleprovider/dist/assets/__federation_expose_Page-Bt1EwqOk.js +++ b/plugins.v2/clashruleprovider/dist/assets/__federation_expose_Page-Bt1EwqOk.js @@ -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" }; diff --git a/plugins.v2/imdbsource/__init__.py b/plugins.v2/imdbsource/__init__.py index 8764f59..3d4c3d2 100644 --- a/plugins.v2/imdbsource/__init__.py +++ b/plugins.v2/imdbsource/__init__.py @@ -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', '')} {year_and_type(entry)[1]}", + }, + { + '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 [] diff --git a/plugins.v2/imdbsource/imdb_helper.py b/plugins.v2/imdbsource/imdbhelper.py similarity index 70% rename from plugins.v2/imdbsource/imdb_helper.py rename to plugins.v2/imdbsource/imdbhelper.py index 7f3fe18..0dc1059 100644 --- a/plugins.v2/imdbsource/imdb_helper.py +++ b/plugins.v2/imdbsource/imdbhelper.py @@ -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 \ No newline at end of file