import json 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 app.core.event import eventmanager, Event 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.utils.http import RequestUtils lock = threading.Lock() class EmbyCollectionSort(_PluginBase): # 插件名称 plugin_name = "Emby合集媒体排序" # 插件描述 plugin_desc = "Emby保留按照加入时间倒序的前提下,把合集中的媒体按照发布日期排序,修改加入时间已到达顺序排列的目的。" # 插件图标 plugin_icon = "Element_A.png" # 插件版本 plugin_version = "1.1" # 插件作者 plugin_author = "thsrite" # 作者主页 author_url = "https://github.com/thsrite" # 插件配置项ID前缀 plugin_config_prefix = "embycollectionsort_" # 加载顺序 plugin_order = 15 # 可使用的用户级别 auth_level = 1 # 私有属性 _enabled = False _onlyonce = False _cron = None _sort_type = None _collection_library_id = None _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() if config: self._enabled = config.get("enabled") self._onlyonce = config.get("onlyonce") self._cron = config.get("cron") self._sort_type = config.get("sort_type") or "asc" self._collection_library_id = config.get("collection_library_id") if self._EMBY_HOST: if not self._EMBY_HOST.endswith("/"): self._EMBY_HOST += "/" if not self._EMBY_HOST.startswith("http"): self._EMBY_HOST = "http://" + self._EMBY_HOST # 加载模块 if self._enabled or self._onlyonce: # 定时服务 self._scheduler = BackgroundScheduler(timezone=settings.TZ) # 立即运行一次 if self._onlyonce: logger.info(f"Emby合集媒体排序刷新服务启动,立即运行一次") self._scheduler.add_job(self.collection_sort, 'date', run_date=datetime.now( tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), name="Emby合集媒体排序") # 关闭一次性开关 self._onlyonce = False # 保存配置 self.__update_config() # 周期运行 if self._cron: try: self._scheduler.add_job(func=self.collection_sort, trigger=CronTrigger.from_crontab(self._cron), name="Emby合集媒体排序") except Exception as err: logger.error(f"定时任务配置错误:{str(err)}") # 推送实时消息 self.systemmessage.put(f"执行周期配置错误:{err}") # 启动任务 if self._scheduler.get_jobs(): self._scheduler.print_jobs() self._scheduler.start() def get_state(self) -> bool: return self._enabled def __update_config(self): self.update_config( { "onlyonce": self._onlyonce, "cron": self._cron, "enabled": self._enabled, "sort_type": self._sort_type, "collection_library_id": self._collection_library_id, } ) def collection_sort(self): """ 更改合集媒体入库时间 """ if "emby" not in settings.MEDIASERVER: logger.error("未配置Emby媒体服务器") return if not self._collection_library_id: logger.error("未配置合集所在媒体库") return # 获取合集列表 collections = self.__get_items(self._collection_library_id) handle_times = [] for collection in collections: logger.info(f"开始处理合集: {collection.get('Name')} {collection.get('Id')}") items = self.__get_items(collection.get("Id")) item_dict = [] for item in items: item_info = self.__get_item_info(item.get("Id")) item_dict.append({"Name": item.get("Name"), "Id": item.get("Id"), "item_info": item_info}) # 按照发布时间排序 sorted_items = sorted(item_dict, key=lambda x: x.get("item_info").get("PremiereDate"), reverse=self._sort_type == "降序") # 初始化时间 current_time = datetime.strptime(sorted_items[0]["item_info"]["DateCreated"], "%Y-%m-%dT%H:%M:%S.%f0Z") # 更新每个 item 的 DateCreated,规则为 updated_items = [] while sorted_items: sub_update_items = [] for item in sorted_items: with lock: new_date_created = current_time.strftime("%Y-%m-%dT%H:%M:%S.%f0Z") # 时间相同,跳过 if str(new_date_created) == str(item['item_info']['DateCreated']): logger.debug( f"合集媒体: {item.get('Name')} 原入库时间 {item['item_info']['DateCreated']} 新入库时间 {new_date_created} 时间相同,跳过") handle_times.append(str(current_time)) sub_update_items.append(str(current_time)) # 时间减一秒,用于下一个 item 的更新 current_time -= timedelta(seconds=1) continue if str(current_time) in handle_times: logger.warn( f"合集媒体: {item.get('Name')} {current_time} 时间已被占用,开始增加 {len(sorted_items) + 1} 秒,重新尝试处理") # 处理完成的 items 从列表中移除 handle_times = [str(_time) for _time in handle_times if _time not in sub_update_items] # 如果时间已被占用,增加 len(sorted_items) + 1 秒 current_time += timedelta(seconds=len(sorted_items) + 1) # 重置已处理的 items 列表和 handle_times 集合 updated_items.clear() # 时间已被占用,跳出 for 循环 break logger.debug( f"合集媒体: {item.get('Name')} 原入库时间 {item['item_info']['DateCreated']} 新入库时间 {new_date_created}") item["item_info"]["DateCreated"] = new_date_created updated_items.append(item["item_info"]) handle_times.append(str(current_time)) sub_update_items.append(str(current_time)) # 时间减一秒,用于下一个 item 的更新 current_time -= timedelta(seconds=1) else: # 所有 item 处理完成,跳出 while 循环 break time.sleep(1) if not updated_items: logger.warn(f"合集: {collection.get('Name')} {collection.get('Id')} 无需更新入库时间") continue logger.debug(f"获取合集排序后最新的入库时间: {current_time}") # 更新入库时间 for item_info in updated_items: update_flag = self.__update_item_info(item_info.get("Id"), item_info) if update_flag: logger.info(f"{item_info.get('Name')} 更新入库时间到{item_info.get('DateCreated')}成功") else: logger.error(f"{item_info.get('Name')} 更新入库时间到{item_info.get('DateCreated')}失败") logger.info(f"合集处理完成: {collection.get('Name')} {collection.get('Id')}") logger.info(f"更新Emby合集媒体排序完成") @eventmanager.register(EventType.PluginAction) def remote_sync(self, event: Event): """ 远程刷新媒体库 """ if event: event_data = event.event_data if not event_data or event_data.get("action") != "collection_sort": return self.post_message(channel=event.event_data.get("channel"), title="开始更新Emby合集媒体排序 ...", userid=event.event_data.get("user")) self.collection_sort() if event: self.post_message(channel=event.event_data.get("channel"), title="更新Emby合集媒体排序完成!", userid=event.event_data.get("user")) def __get_items(self, parent_id): res = RequestUtils().get_res( f"{self._EMBY_HOST}/emby/Users/{self._EMBY_USER}/Items?ParentId={parent_id}&api_key={self._EMBY_APIKEY}") if res and res.status_code == 200: results = res.json().get("Items") or [] return results return [] 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 {} 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": "/collection_sort", "event": EventType.PluginAction, "desc": "更新Emby合集媒体排序", "category": "", "data": { "action": "collection_sort" } }] def get_api(self) -> List[Dict[str, Any]]: pass def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: """ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 """ return [ { "component": "VForm", "content": [ { 'component': 'VRow', 'content': [ { 'component': 'VCol', 'props': { 'cols': 12, 'md': 4 }, 'content': [ { 'component': 'VSwitch', 'props': { 'model': 'enabled', 'label': '启用插件', } } ] }, { 'component': 'VCol', 'props': { 'cols': 12, 'md': 4 }, 'content': [ { 'component': 'VSwitch', 'props': { 'model': 'onlyonce', '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': 'VTextField', 'props': { 'model': 'collection_library_id', 'label': '合集媒体库ID' } } ] }, { 'component': 'VCol', 'props': { 'cols': 12, 'md': 4 }, 'content': [ { 'component': 'VSelect', 'props': { 'model': 'sort_type', 'label': '发布日期', 'items': [ {'title': '升序', 'value': '升序'}, {'title': '降序', 'value': '降序'}, ] } } ] }, ], }, { 'component': 'VRow', 'content': [ { 'component': 'VCol', 'props': { 'cols': 12, }, 'content': [ { 'component': 'VAlert', 'props': { 'type': 'info', 'variant': 'tonal', 'text': '保留按照加入时间倒序的前提下,把合集中的媒体放一块,不用到处找。注:只支持Emby。' } } ] } ] } ], } ], { "enabled": False, "onlyonce": False, "sort_type": "降序", "cron": "5 1 * * *", "collection_library_id": "", } def get_page(self) -> List[dict]: pass def stop_service(self): """ 退出插件 """ try: if self._scheduler: self._scheduler.remove_all_jobs() if self._scheduler.running: self._scheduler.shutdown() self._scheduler = None except Exception as e: logger.error("退出插件失败:%s" % str(e))