diff --git a/plugins.v2/embyaudiobook/__init__.py b/plugins.v2/embyaudiobook/__init__.py new file mode 100644 index 0000000..92fa5f4 --- /dev/null +++ b/plugins.v2/embyaudiobook/__init__.py @@ -0,0 +1,620 @@ +import datetime +import json +import re +import threading +import time +from pathlib import Path +from typing import List, Tuple, Dict, Any + +import pytz +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from app.core.config import settings +from app.core.event import eventmanager, Event +from app.helper.mediaserver import MediaServerHelper +from app.log import logger +from app.modules.emby import Emby +from app.plugins import _PluginBase +from app.schemas import NotificationType +from app.schemas.types import EventType +from app.utils.http import RequestUtils + + +class EmbyAudioBook(_PluginBase): + # 插件名称 + plugin_name = "Emby有声书整理" + # 插件描述 + plugin_desc = "还在为Emby有声书整理烦恼吗?入库存在很多单集?" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/audiobook.png" + # 插件版本 + plugin_version = "1.1" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "embyaudiobook_" + # 加载顺序 + plugin_order = 30 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _scheduler = None + _enabled = False + _notify = False + _rename = False + _onlyonce = False + _cron = None + _library_id = None + _msgtype = None + + mediaserver_helper = None + _EMBY_HOST = None + _EMBY_USER = None + _EMBY_APIKEY = None + + # 退出事件 + _event = threading.Event() + + def init_plugin(self, config: dict = None): + self.mediaserver_helper = MediaServerHelper() + + # 读取配置 + if config: + self._enabled = config.get("enabled") + self._onlyonce = config.get("onlyonce") + self._cron = config.get("cron") + self._library_id = config.get("library_id") + self._notify = config.get("notify") + self._rename = config.get("rename") + self._msgtype = config.get("msgtype") + + emby_server = self.mediaserver_helper.get_service(name="emby") + if not emby_server: + logger.error("未配置Emby媒体服务器") + return + + self._EMBY_USER = emby_server.instance.get_user() + self._EMBY_HOST = emby_server.config.get("host") + self._EMBY_APIKEY = emby_server.config.get("apikey") + + if not self._EMBY_HOST.endswith("/"): + self._EMBY_HOST += "/" + if not self._EMBY_HOST.startswith("http"): + self._EMBY_HOST = "http://" + self._EMBY_HOST + + # 停止现有任务 + self.stop_service() + + if self._enabled or self._onlyonce: + # 定时服务管理器 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + # 启用目录监控 + if self._cron: + try: + self._scheduler.add_job(func=self.check, + trigger=CronTrigger.from_crontab(self._cron), + name="Emby有声书整理") + except Exception as err: + logger.error(f"定时任务配置错误:{str(err)}") + # 推送实时消息 + self.systemmessage.put(f"执行周期配置错误:{err}") + + # 运行一次定时服务 + if self._onlyonce: + logger.info("Emby有声书整理服务启动,立即运行一次") + self._scheduler.add_job(name="Emby有声书整理", func=self.check, trigger='date', + run_date=datetime.datetime.now( + tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3) + ) + # 关闭一次性开关 + self._onlyonce = False + # 保存配置 + self.__update_config() + + # 启动定时服务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def __update_config(self): + """ + 更新配置 + """ + self.update_config({ + "enabled": self._enabled, + "onlyonce": self._onlyonce, + "library_id": self._library_id, + "rename": self._rename, + "cron": self._cron, + "notify": self._notify, + "msgtype": self._msgtype, + }) + + def check(self): + if not self._library_id: + logger.error("请设置有声书文件夹ID!") + return + + # 获取所有有声书 + items = self.__get_items(parent_id=int(self._library_id)) + if not items: + logger.error(f"获取媒体库 {self._library_id} 有声书列表失败!") + return + + # 检查有声书是否需要整理 + for item in items: + book_items = self.__get_items(item.get("Id")) + if not book_items: + logger.error(f"获取 {item.get('Name')} {item.get('Id')} 有声书失败!") + return + + # 检查有声书是否需要整理 + __need_zl = False + for book_item in book_items: + if not book_item.get("AlbumId"): + __need_zl = True + break + + # 需要整理的提示需要整理 + if __need_zl: + logger.info(f"有声书 {item.get('Name')} 需要整理,共 {len(book_items)} 集") + # self.__zl(items, -1) + # 发送通知 + if self._notify: + mtype = NotificationType.Manual + if self._msgtype: + mtype = NotificationType.__getitem__(str(self._msgtype)) or NotificationType.Manual + self.post_message(title="Emby有声书整理", + mtype=mtype, + text=f"有声书 {item.get('Name')} 需要整理,共 {len(book_items)} 集") + else: + # 不需要整理的锁定 + other_book_info = self.__get_item_info(item.get("Id")) + other_book_info.update({ + "LockData": True, + }) + self.__update_item_info(item.get("Id"), other_book_info) + logger.info(f"有声书 {item.get('Name')} 不需要整理,已锁定") + + logger.info("Emby有声书整理服务执行完毕") + + @eventmanager.register(EventType.PluginAction) + def audiobook(self, event: Event = None): + if not self._enabled: + return + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "audiobook": + return + + if not self._library_id: + logger.error("请设置有声书文件夹ID!") + self.post_message(channel=event.event_data.get("channel"), + title="请设置有声书文件夹ID!", + userid=event.event_data.get("user")) + return + + args = event_data.get("args") + if not args: + logger.error(f"缺少参数:{event_data}") + return + + args_list = args.split(" ") + if len(args_list) != 2: + logger.error(f"参数错误:{args_list}") + self.post_message(channel=event.event_data.get("channel"), + title=f"参数错误! /ab 书名 正确信息集数", + userid=event.event_data.get("user")) + return + + book_name = args_list[0] + book_idx = args_list[1] + logger.info(f"有声书整理:{book_name} - 正确信息从集数 {book_idx} 获取") + + # 获取所有有声书 + items = self.__get_items(self._library_id) + if not items: + logger.error(f"获取媒体库 {self._library_id} 有声书列表失败!") + self.post_message(channel=event.event_data.get("channel"), + title=f"获取 {self._library_id} 有声书失败!", + userid=event.event_data.get("user")) + return + + # 获取指定有声书 + book_id = None + book_info = None + for item in items: + if book_name in item.get("Name"): + book_id = item.get("Id") + book_info = self.__get_item_info(book_id) + break + + if not book_id: + logger.error(f"未找到 {book_name} 有声书!") + self.post_message(channel=event.event_data.get("channel"), + title=f"未找到 {book_name} 有声书!", + userid=event.event_data.get("user")) + return + + items = self.__get_items(book_id) + if not items: + logger.error(f"获取 {book_name} {book_id} 有声书失败!") + self.post_message(channel=event.event_data.get("channel"), + title=f"获取 {book_name} {book_id} 有声书失败!", + userid=event.event_data.get("user")) + return + + self.__zl(items, int(book_idx)) + if book_info: + book_info.update({ + "LockData": True, + }) + self.__update_item_info(book_id, book_info) + self.post_message(channel=event.event_data.get("channel"), + title=f"{book_name} 有声书整理完成!", + userid=event.event_data.get("user")) + + def __zl(self, items, book_idx): + """ + 有声书整理 + """ + AlbumId = None + Album = None + AlbumPrimaryImageTag = None + Artists = None + ArtistItems = None + Composers = None + AlbumArtist = None + AlbumArtists = None + ParentIndexNumber = None + + if book_idx == -1: + for item in items: + AlbumId = item.get("AlbumId") + if not AlbumId: + continue + + Album = item.get("Album") + AlbumPrimaryImageTag = item.get("AlbumPrimaryImageTag") + Artists = item.get("Artists") + ArtistItems = item.get("ArtistItems") + Composers = item.get("Composers") + AlbumArtist = item.get("AlbumArtist") + AlbumArtists = item.get("AlbumArtists") + ParentIndexNumber = item.get("ParentIndexNumber") + if AlbumId and Album and Artists and AlbumArtist and AlbumArtists and ParentIndexNumber: + logger.info( + f"从集数 {item.get('IndexNumber')} 获取到有声书信息:{Album} - {Artists} - {Composers} - {AlbumArtist} - {AlbumArtists} - {ParentIndexNumber}") + break + else: + Album = items[book_idx - 1].get("Album") + AlbumId = items[book_idx - 1].get("AlbumId") + AlbumPrimaryImageTag = items[book_idx - 1].get("AlbumPrimaryImageTag") + Artists = items[book_idx - 1].get("Artists") + ArtistItems = items[book_idx - 1].get("ArtistItems") + Composers = items[book_idx - 1].get("Composers") + AlbumArtist = items[book_idx - 1].get("AlbumArtist") + AlbumArtists = items[book_idx - 1].get("AlbumArtists") + ParentIndexNumber = items[book_idx - 1].get("ParentIndexNumber") + logger.info( + f"从集数 {book_idx} 获取到有声书信息:{Album} - {Artists} - {Composers} - {AlbumArtist} - {AlbumArtists} - {ParentIndexNumber}") + + # 更新有声书信息 + for i, item in enumerate(items): + episode = i + 1 + # 使用正则表达式匹配集数 + match = re.search(r'第(\d+)集', item.get("Name")) + if match: + episode = int(match.group(1)) + else: + # 使用正则表达式匹配数字 + match = re.search(r'\d+', item.get("Name")) + if match: + # 提取数字 + episode = match.group() + + if Album == item.get("Album") and \ + AlbumId == item.get("AlbumId") and \ + AlbumPrimaryImageTag == item.get("AlbumPrimaryImageTag") and \ + Artists == item.get("Artists") and \ + ArtistItems == item.get("ArtistItems") and \ + Composers == item.get("Composers") and \ + AlbumArtist == item.get("AlbumArtist") and \ + AlbumArtists == item.get("AlbumArtists") and not self._rename: + logger.info(f"有声书 第{episode}集 {item.get('Name')} 信息完整,跳过!") + continue + + retry = 0 + while retry < 3: + try: + # 获取有声书信息 + item_info = self.__get_item_info(item.get("Id")) + + # 重命名前判断名称是否一致 + if self._rename and item.get("Name") == Path(Path(item_info.get("Path")).name).stem: + logger.info(f"有声书 第{episode}集 {item.get('Name')} 名称相同,跳过!") + continue + + item_info.update({ + "Album": Album, + "AlbumId": AlbumId, + "AlbumPrimaryImageTag": AlbumPrimaryImageTag, + "Artists": Artists, + "ArtistItems": ArtistItems, + "Composers": Composers, + "AlbumArtist": AlbumArtist, + "AlbumArtists": AlbumArtists, + "ParentIndexNumber": ParentIndexNumber, + "IndexNumber": episode, + "LockData": True, + }) + retry = 3 + except Exception as e: + retry += 1 + logger.error(f"更新有声书 第{episode}集 {item.get('Name')} 信息出错:{e} 开始重试...{retry} / 3") + continue + + if item_info.get("Name") == "filename" or self._rename: + item_info.update({ + "Name": Path(Path(item_info.get("Path")).name).stem + }) + flag = self.__update_item_info(item.get("Id"), item_info) + logger.info(f"{Album} 第{episode}集 {item_info.get('Name')} 更新{'成功' if flag else '失败'}") + time.sleep(0.5) + + def get_state(self) -> bool: + return self._enabled + + def __get_items(self, parent_id) -> list: + """ + 获取有声书剧集 + """ + if not self._EMBY_HOST or not self._EMBY_APIKEY: + return [] + req_url = f"%semby/Users/%s/Items?ParentId=%s&api_key=%s" % ( + self._EMBY_HOST, self._EMBY_USER, parent_id, self._EMBY_APIKEY) + try: + with RequestUtils().get_res(req_url) as res: + if res: + return res.json().get("Items") + else: + logger.info(f"获取有声书剧集失败,无法连接Emby!") + return [] + except Exception as e: + logger.error(f"连接有声书Items出错:" + str(e)) + return [] + + def __get_item_info(self, item_id) -> dict: + """ + 获取有声书剧集详情 + """ + if not self._EMBY_HOST or not self._EMBY_APIKEY: + return {} + req_url = f"%semby/Users/%s/Items/%s?fields=ShareLevel&ExcludeFields=Chapters,Overview,People,MediaStreams,Subviews&api_key=%s" % ( + self._EMBY_HOST, self._EMBY_USER, item_id, self._EMBY_APIKEY) + with RequestUtils().get_res(req_url) as res: + if res: + return res.json() + else: + logger.info(f"获取有声书剧集详情失败,无法连接Emby!") + return {} + + def __update_item_info(self, item_id, data): + headers = { + 'accept': '*/*', + 'Content-Type': 'application/json' + } + res = RequestUtils(headers=headers).post( + f"{self._EMBY_HOST}/emby/Items/{item_id}?api_key={self._EMBY_APIKEY}", + data=json.dumps(data)) + if res and res.status_code == 204: + return True + return False + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + return [ + { + "cmd": "/ab", + "event": EventType.PluginAction, + "desc": "emby有声书整理", + "category": "", + "data": { + "action": "audiobook" + } + } + ] + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_service(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + MsgTypeOptions = [] + for item in NotificationType: + MsgTypeOptions.append({ + "title": item.value, + "value": item.name + }) + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '开启通知', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'rename', + 'label': '重命名有声书', + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '定时全量同步周期', + 'placeholder': '5位cron表达式,留空关闭' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': False, + 'chips': True, + 'model': 'msgtype', + 'label': '消息类型', + 'items': MsgTypeOptions + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'library_id', + 'label': '有声书文件夹ID', + 'placeholder': '媒体库有声书-->文件夹-->看URL里的ParentId' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '仅支持交互命令运行: /ab 书名 正确信息集数。' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "notify": False, + "onlyonce": False, + "rename": False, + "cron": "", + "msgtype": "", + "library_id": "", + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._event.set() + self._scheduler.shutdown() + self._event.clear() + self._scheduler = None