diff --git a/README.md b/README.md index 5d79e33..ac429cf 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ MoviePilot三方插件市场:https://github.com/thsrite/MoviePilot-Plugins/ - 插件彻底卸载 v1.0 - 实时软连接 v2.0.1 - 订阅规则自动填充 v2.7 -- Emby元数据刷新 v1.3 +- Emby元数据刷新 v1.4 - Emby媒体标签 v1.2 - 热门媒体订阅 v1.7 - [HomePage v1.2](docs%2FHomePage.md) diff --git a/package.json b/package.json index d6a0ed7..c755d8f 100644 --- a/package.json +++ b/package.json @@ -446,11 +446,12 @@ "name": "Emby元数据刷新", "description": "定时刷新Emby媒体库元数据。", "labels": "Emby", - "version": "1.3", + "version": "1.4", "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/emby-icon.png", "author": "thsrite", "level": 1, "history": { + "v1.4": "支持刷新演员中文", "v1.3": "支持自定义覆盖元数据、图片", "v1.2": "支持获取Emby最新媒体刷新", "v1.1": "添加远程交互命令", diff --git a/plugins/embymetarefresh/__init__.py b/plugins/embymetarefresh/__init__.py index 17e1796..cc772e2 100644 --- a/plugins/embymetarefresh/__init__.py +++ b/plugins/embymetarefresh/__init__.py @@ -1,18 +1,30 @@ +import base64 +import copy +import json +import re +import threading +import time from datetime import datetime, timedelta from typing import Optional, Any, List, Dict, Tuple import pytz from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger +from requests import RequestException +from zhconv import zhconv +from app import schemas +from app.chain.tmdb import TmdbChain from app.core.event import eventmanager, Event from app.db.transferhistory_oper import TransferHistoryOper from app.core.config import settings from app.log import logger from app.plugins import _PluginBase from app.modules.emby import Emby -from app.schemas.types import EventType +from app.schemas.types import EventType, MediaType +from app.utils.common import retry from app.utils.http import RequestUtils +from app.utils.string import StringUtils class EmbyMetaRefresh(_PluginBase): @@ -23,7 +35,7 @@ class EmbyMetaRefresh(_PluginBase): # 插件图标 plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/emby-icon.png" # 插件版本 - plugin_version = "1.3" + plugin_version = "1.4" # 插件作者 plugin_author = "thsrite" # 作者主页 @@ -35,26 +47,33 @@ class EmbyMetaRefresh(_PluginBase): # 可使用的用户级别 auth_level = 1 + # 退出事件 + _event = threading.Event() # 私有属性 _enabled = False + tmdbchain = None _onlyonce = False _cron = None + _actor_chi = False _num = None _refresh_type = None _ReplaceAllMetadata = "true" _ReplaceAllImages = "true" _EMBY_HOST = settings.EMBY_HOST + _EMBY_USER = Emby().get_user() _EMBY_APIKEY = settings.EMBY_API_KEY _scheduler: Optional[BackgroundScheduler] = None def init_plugin(self, config: dict = None): # 停止现有任务 self.stop_service() + self.tmdbchain = TmdbChain() if config: self._enabled = config.get("enabled") self._onlyonce = config.get("onlyonce") self._cron = config.get("cron") + self._actor_chi = config.get("actor_chi") self._num = config.get("num") or 5 self._refresh_type = config.get("refresh_type") or "历史记录" self._ReplaceAllMetadata = config.get("ReplaceAllMetadata") or "true" @@ -110,6 +129,7 @@ class EmbyMetaRefresh(_PluginBase): "onlyonce": self._onlyonce, "cron": self._cron, "enabled": self._enabled, + "actor_chi": self._actor_chi, "num": self._num, "refresh_type": self._refresh_type, "ReplaceAllMetadata": self._ReplaceAllMetadata, @@ -150,8 +170,371 @@ class EmbyMetaRefresh(_PluginBase): for item in latest: logger.info(f"开始刷新媒体库元数据,最新媒体:{item.type} {item.title} ({item.subtitle})") self.__refresh_emby_library_by_id(item.id) + + # 刮演员中文 + if self._actor_chi: + self.__update_people_chi(item_id=item.id, title=item.title, type=item.type) + logger.info(f"刷新媒体库元数据完成") + def __update_people_chi(self, item_id, title, type): + """ + 刮削演员中文名 + """ + # 刮演员中文 + item_info = self.__get_item_info(item_id) + if item_info: + imdb_id = item_info.get("ProviderIds", {}).get("Imdb") + if imdb_id and self.__need_trans_actor(item_info): + logger.info(f"开始获取 {title} ({item_info.get('ProductionYear')}) 的豆瓣演员信息 ...") + douban_actors = self.__get_douban_actors(title=title, + imdb_id=imdb_id, + type=type, + year=item_info.get("ProductionYear")) + logger.debug(f"获取 {title} ({item_info.get('ProductionYear')}) 的豆瓣演员信息 完成,演员:{douban_actors}") + self.__update_peoples(itemid=item_id, iteminfo=item_info, + douban_actors=douban_actors) + + + def __update_peoples(self, itemid: str, iteminfo: dict, douban_actors): + # 处理媒体项中的人物信息 + """ + "People": [ + { + "Name": "丹尼尔·克雷格", + "Id": "33625", + "Role": "James Bond", + "Type": "Actor", + "PrimaryImageTag": "bef4f764540f10577f804201d8d27918" + } + ] + """ + peoples = [] + # 更新当前媒体项人物 + for people in iteminfo["People"] or []: + if self._event.is_set(): + logger.info(f"演职人员刮削服务停止") + return + if not people.get("Name"): + continue + if StringUtils.is_chinese(people.get("Name")) \ + and StringUtils.is_chinese(people.get("Role")): + peoples.append(people) + continue + info = self.__update_people(people=people, + douban_actors=douban_actors) + if info: + peoples.append(info) + # 保存媒体项信息 + if peoples: + iteminfo["People"] = peoples + self.set_iteminfo(itemid=itemid, iteminfo=iteminfo) + + def __update_people(self, people: dict, douban_actors: list = None) -> Optional[dict]: + """ + 更新人物信息,返回替换后的人物信息 + """ + + def __get_emby_iteminfo() -> dict: + """ + 获得Emby媒体项详情 + """ + try: + url = f'[HOST]emby/Users/[USER]/Items/{people.get("Id")}?' \ + f'Fields=ChannelMappingInfo&api_key=[APIKEY]' + res = Emby().get_data(url=url) + if res: + return res.json() + except Exception as err: + logger.error(f"获取Emby媒体项详情失败:{str(err)}") + return {} + + def __get_peopleid(p: dict) -> Tuple[Optional[str], Optional[str]]: + """ + 获取人物的TMDBID、IMDBID + """ + if not p.get("ProviderIds"): + return None, None + peopletmdbid, peopleimdbid = None, None + if "Tmdb" in p["ProviderIds"]: + peopletmdbid = p["ProviderIds"]["Tmdb"] + if "tmdb" in p["ProviderIds"]: + peopletmdbid = p["ProviderIds"]["tmdb"] + if "Imdb" in p["ProviderIds"]: + peopleimdbid = p["ProviderIds"]["Imdb"] + if "imdb" in p["ProviderIds"]: + peopleimdbid = p["ProviderIds"]["imdb"] + return peopletmdbid, peopleimdbid + + # 返回的人物信息 + ret_people = copy.deepcopy(people) + + try: + # 查询媒体库人物详情 + personinfo = __get_emby_iteminfo() + if not personinfo: + logger.debug(f"未找到人物 {people.get('Name')} 的信息") + return None + + # 是否更新标志 + updated_name = False + updated_overview = False + update_character = False + profile_path = None + + # 从TMDB信息中更新人物信息 + person_tmdbid, person_imdbid = __get_peopleid(personinfo) + if person_tmdbid: + person_detail = self.tmdbchain.person_detail(int(person_tmdbid)) + if person_detail: + cn_name = self.__get_chinese_name(person_detail) + # 图片优先从TMDB获取 + profile_path = person_detail.profile_path + if profile_path: + logger.debug(f"{people.get('Name')} 从TMDB获取到图片:{profile_path}") + profile_path = f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{profile_path}" + if cn_name: + # 更新中文名 + logger.debug(f"{people.get('Name')} 从TMDB获取到中文名:{cn_name}") + personinfo["Name"] = cn_name + ret_people["Name"] = cn_name + updated_name = True + # 更新中文描述 + biography = person_detail.biography + if biography and StringUtils.is_chinese(biography): + logger.debug(f"{people.get('Name')} 从TMDB获取到中文描述") + personinfo["Overview"] = biography + updated_overview = True + + # 从豆瓣信息中更新人物信息 + """ + { + "name": "丹尼尔·克雷格", + "roles": [ + "演员", + "制片人", + "配音" + ], + "title": "丹尼尔·克雷格(同名)英国,英格兰,柴郡,切斯特影视演员", + "url": "https://movie.douban.com/celebrity/1025175/", + "user": null, + "character": "饰 詹姆斯·邦德 James Bond 007", + "uri": "douban://douban.com/celebrity/1025175?subject_id=27230907", + "avatar": { + "large": "https://qnmob3.doubanio.com/view/celebrity/raw/public/p42588.jpg?imageView2/2/q/80/w/600/h/3000/format/webp", + "normal": "https://qnmob3.doubanio.com/view/celebrity/raw/public/p42588.jpg?imageView2/2/q/80/w/200/h/300/format/webp" + }, + "sharing_url": "https://www.douban.com/doubanapp/dispatch?uri=/celebrity/1025175/", + "type": "celebrity", + "id": "1025175", + "latin_name": "Daniel Craig" + } + """ + if douban_actors and (not updated_name + or not updated_overview + or not update_character): + # 从豆瓣演员中匹配中文名称、角色和简介 + for douban_actor in douban_actors: + if douban_actor.get("latin_name") == people.get("Name") \ + or douban_actor.get("name") == people.get("Name"): + # 名称 + if not updated_name: + logger.debug(f"{people.get('Name')} 从豆瓣中获取到中文名:{douban_actor.get('name')}") + personinfo["Name"] = douban_actor.get("name") + ret_people["Name"] = douban_actor.get("name") + updated_name = True + # 描述 + if not updated_overview: + if douban_actor.get("title"): + logger.debug(f"{people.get('Name')} 从豆瓣中获取到中文描述:{douban_actor.get('title')}") + personinfo["Overview"] = douban_actor.get("title") + updated_overview = True + # 饰演角色 + if not update_character: + if douban_actor.get("character"): + # "饰 詹姆斯·邦德 James Bond 007" + character = re.sub(r"饰\s+", "", + douban_actor.get("character")) + character = re.sub("演员", "", + character) + if character: + logger.debug(f"{people.get('Name')} 从豆瓣中获取到饰演角色:{character}") + ret_people["Role"] = character + update_character = True + # 图片 + if not profile_path: + avatar = douban_actor.get("avatar") or {} + if avatar.get("large"): + logger.debug(f"{people.get('Name')} 从豆瓣中获取到图片:{avatar.get('large')}") + profile_path = avatar.get("large") + break + + # 更新人物图片 + if profile_path: + logger.debug(f"更新人物 {people.get('Name')} 的图片:{profile_path}") + self.set_item_image(itemid=people.get("Id"), imageurl=profile_path) + + # 锁定人物信息 + if updated_name: + if "Name" not in personinfo["LockedFields"]: + personinfo["LockedFields"].append("Name") + if updated_overview: + if "Overview" not in personinfo["LockedFields"]: + personinfo["LockedFields"].append("Overview") + + # 更新人物信息 + if updated_name or updated_overview or update_character: + logger.info(f"更新人物 {people.get('Name')} 的信息:{personinfo}") + ret = self.set_iteminfo(itemid=people.get("Id"), iteminfo=personinfo) + if ret: + return ret_people + else: + logger.debug(f"人物 {people.get('Name')} 未找到中文数据") + except Exception as err: + logger.error(f"更新人物信息失败:{str(err)}") + return None + + @staticmethod + def set_iteminfo(itemid: str, iteminfo: dict): + """ + 更新媒体项详情 + """ + + def __set_emby_iteminfo(): + """ + 更新Emby媒体项详情 + """ + try: + res = Emby().post_data( + url=f'[HOST]emby/Items/{itemid}?api_key=[APIKEY]&reqformat=json', + data=json.dumps(iteminfo), + headers={ + "Content-Type": "application/json" + } + ) + if res and res.status_code in [200, 204]: + return True + else: + logger.error(f"更新Emby媒体项详情失败,错误码:{res.status_code}") + return False + except Exception as err: + logger.error(f"更新Emby媒体项详情失败:{str(err)}") + return False + + return __set_emby_iteminfo() + + @staticmethod + @retry(RequestException, logger=logger) + def set_item_image(itemid: str, imageurl: str): + """ + 更新媒体项图片 + """ + + def __download_image(): + """ + 下载图片 + """ + try: + if "doubanio.com" in imageurl: + r = RequestUtils(headers={ + 'Referer': "https://movie.douban.com/" + }, ua=settings.USER_AGENT).get_res(url=imageurl, raise_exception=True) + else: + r = RequestUtils().get_res(url=imageurl, raise_exception=True) + if r: + return base64.b64encode(r.content).decode() + else: + logger.warn(f"{imageurl} 图片下载失败,请检查网络连通性") + except Exception as err: + logger.error(f"下载图片失败:{str(err)}") + return None + + def __set_emby_item_image(_base64: str): + """ + 更新Emby媒体项图片 + """ + try: + url = f'[HOST]emby/Items/{itemid}/Images/Primary?api_key=[APIKEY]' + res = Emby().post_data( + url=url, + data=_base64, + headers={ + "Content-Type": "image/png" + } + ) + if res and res.status_code in [200, 204]: + return True + else: + logger.error(f"更新Emby媒体项图片失败,错误码:{res.status_code}") + return False + except Exception as result: + logger.error(f"更新Emby媒体项图片失败:{result}") + return False + + # 下载图片获取base64 + image_base64 = __download_image() + if image_base64: + return __set_emby_item_image(image_base64) + + return None + + @staticmethod + def __get_chinese_name(personinfo: schemas.MediaPerson) -> str: + """ + 获取TMDB别名中的中文名 + """ + try: + also_known_as = personinfo.also_known_as or [] + if also_known_as: + for name in also_known_as: + if name and StringUtils.is_chinese(name): + # 使用cn2an将繁体转化为简体 + return zhconv.convert(name, "zh-hans") + except Exception as err: + logger.error(f"获取人物中文名失败:{err}") + return "" + + def __get_douban_actors(self, title, imdb_id, type, year, season: int = None) -> List[dict]: + """ + 获取豆瓣演员信息 + """ + # 随机休眠 3-10 秒 + sleep_time = 3 + int(time.time()) % 7 + logger.debug(f"随机休眠 {sleep_time}秒 ...") + time.sleep(sleep_time) + # 匹配豆瓣信息 + doubaninfo = self.chain.match_doubaninfo(name=title, + imdbid=imdb_id, + mtype=type, + year=year, + season=season) + # 豆瓣演员 + if doubaninfo: + doubanitem = self.chain.douban_info(doubaninfo.get("id")) or {} + return (doubanitem.get("actors") or []) + (doubanitem.get("directors") or []) + else: + logger.debug(f"未找到豆瓣信息:{title} {year}") + return [] + + @staticmethod + def __need_trans_actor(item): + """ + 是否需要处理人物信息 + """ + _peoples = [x for x in item.get("People", []) if + (x.get("Name") and not StringUtils.is_chinese(x.get("Name"))) + or (x.get("Role") and not StringUtils.is_chinese(x.get("Role")))] + if _peoples: + return True + return False + + def __get_item_info(self, item_id): + res = RequestUtils().get_res( + f"{self._EMBY_HOST}/emby/Users/{self._EMBY_USER}/Items/{item_id}?api_key={self._EMBY_APIKEY}") + if res and res.status_code == 200: + return res.json() + return {} + @eventmanager.register(EventType.PluginAction) def remote_sync(self, event: Event): """ @@ -181,6 +564,8 @@ class EmbyMetaRefresh(_PluginBase): for movie in movies: self.__refresh_emby_library_by_id(item_id=movie.item_id) logger.info(f"已通知刷新Emby电影:{movie.title} ({movie.year}) item_id:{movie.item_id}") + if self._actor_chi: + self.__update_people_chi(item_id=movie.item_id, title=movie.title, type=MediaType.MOVIE) else: item_id = self.__get_emby_series_id_by_name(name=transferinfo.title, year=transferinfo.year) if not item_id or item_id is None: @@ -207,6 +592,8 @@ class EmbyMetaRefresh(_PluginBase): self.__refresh_emby_library_by_id(item_id=episode_item_id) logger.info( f"已通知刷新Emby电视剧:{transferinfo.title} ({transferinfo.year}) {transferinfo.seasons}{transferinfo.episodes} item_id:{episode_item_id}") + if self._actor_chi: + self.__update_people_chi(item_id=item_id, title=transferinfo.title, type=MediaType.TV) def __get_emby_episode_item_id(self, item_id: str, season: int, episode: int) -> Optional[str]: """ @@ -453,6 +840,22 @@ class EmbyMetaRefresh(_PluginBase): } ] }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'actor_chi', + 'label': '刮削演员中文', + } + } + ] + }, ], }, { @@ -481,6 +884,7 @@ class EmbyMetaRefresh(_PluginBase): ], { "enabled": False, "onlyonce": False, + "actor_chi": False, "ReplaceAllMetadata": "true", "ReplaceAllImages": "true", "cron": "5 1 * * *", @@ -499,7 +903,9 @@ class EmbyMetaRefresh(_PluginBase): if self._scheduler: self._scheduler.remove_all_jobs() if self._scheduler.running: + self._event.set() self._scheduler.shutdown() + self._event.clear() self._scheduler = None except Exception as e: logger.error("退出插件失败:%s" % str(e))