diff --git a/icons/Actual_A.png b/icons/Actual_A.png new file mode 100644 index 0000000..5742cd0 Binary files /dev/null and b/icons/Actual_A.png differ diff --git a/icons/Apache_webdav_A.png b/icons/Apache_webdav_A.png new file mode 100644 index 0000000..c110151 Binary files /dev/null and b/icons/Apache_webdav_A.png differ diff --git a/icons/Chandao_A.png b/icons/Chandao_A.png new file mode 100644 index 0000000..519ad85 Binary files /dev/null and b/icons/Chandao_A.png differ diff --git a/icons/Chandao_B.png b/icons/Chandao_B.png new file mode 100644 index 0000000..f3dc348 Binary files /dev/null and b/icons/Chandao_B.png differ diff --git a/icons/Cloudflare_B.png b/icons/Cloudflare_B.png new file mode 100644 index 0000000..9f15038 Binary files /dev/null and b/icons/Cloudflare_B.png differ diff --git a/icons/Dell_A.png b/icons/Dell_A.png new file mode 100644 index 0000000..d7c6a1e Binary files /dev/null and b/icons/Dell_A.png differ diff --git a/icons/Docker.png b/icons/Docker.png new file mode 100644 index 0000000..9f9c8ab Binary files /dev/null and b/icons/Docker.png differ diff --git a/icons/FileBrowser.png b/icons/FileBrowser.png new file mode 100644 index 0000000..45e664a Binary files /dev/null and b/icons/FileBrowser.png differ diff --git a/icons/Flood_A.png b/icons/Flood_A.png new file mode 100644 index 0000000..c08e68b Binary files /dev/null and b/icons/Flood_A.png differ diff --git a/icons/FreshRSS.png b/icons/FreshRSS.png new file mode 100644 index 0000000..d2e60d9 Binary files /dev/null and b/icons/FreshRSS.png differ diff --git a/icons/Guacamole_A.png b/icons/Guacamole_A.png new file mode 100644 index 0000000..117ea63 Binary files /dev/null and b/icons/Guacamole_A.png differ diff --git a/icons/Homebridge_A.png b/icons/Homebridge_A.png new file mode 100644 index 0000000..9b5416d Binary files /dev/null and b/icons/Homebridge_A.png differ diff --git a/icons/Homer_A.png b/icons/Homer_A.png new file mode 100644 index 0000000..f3feb24 Binary files /dev/null and b/icons/Homer_A.png differ diff --git a/icons/Ipw.cn_A.png b/icons/Ipw.cn_A.png new file mode 100644 index 0000000..a533f2c Binary files /dev/null and b/icons/Ipw.cn_A.png differ diff --git a/icons/Itdog.cn_A.png b/icons/Itdog.cn_A.png new file mode 100644 index 0000000..5333897 Binary files /dev/null and b/icons/Itdog.cn_A.png differ diff --git a/icons/Macos_A.png b/icons/Macos_A.png new file mode 100644 index 0000000..2b4cc9c Binary files /dev/null and b/icons/Macos_A.png differ diff --git a/icons/Macos_Bigsur.png b/icons/Macos_Bigsur.png new file mode 100644 index 0000000..01e99c7 Binary files /dev/null and b/icons/Macos_Bigsur.png differ diff --git a/icons/Macos_Catalina.png b/icons/Macos_Catalina.png new file mode 100644 index 0000000..510d9da Binary files /dev/null and b/icons/Macos_Catalina.png differ diff --git a/icons/Macos_EI_Capitan.png b/icons/Macos_EI_Capitan.png new file mode 100644 index 0000000..caf749d Binary files /dev/null and b/icons/Macos_EI_Capitan.png differ diff --git a/icons/Macos_High_Sierra.png b/icons/Macos_High_Sierra.png new file mode 100644 index 0000000..9e24c54 Binary files /dev/null and b/icons/Macos_High_Sierra.png differ diff --git a/icons/Macos_Mojave.png b/icons/Macos_Mojave.png new file mode 100644 index 0000000..67cacc1 Binary files /dev/null and b/icons/Macos_Mojave.png differ diff --git a/icons/Macos_Monterey.png b/icons/Macos_Monterey.png new file mode 100644 index 0000000..e682573 Binary files /dev/null and b/icons/Macos_Monterey.png differ diff --git a/icons/Macos_Sierra.png b/icons/Macos_Sierra.png new file mode 100644 index 0000000..6a6c206 Binary files /dev/null and b/icons/Macos_Sierra.png differ diff --git a/icons/Ombi.png b/icons/Ombi.png new file mode 100644 index 0000000..49b6496 Binary files /dev/null and b/icons/Ombi.png differ diff --git a/icons/Omnivore_A.png b/icons/Omnivore_A.png new file mode 100644 index 0000000..cca7922 Binary files /dev/null and b/icons/Omnivore_A.png differ diff --git a/icons/PVE_B.png b/icons/PVE_B.png new file mode 100644 index 0000000..cb54217 Binary files /dev/null and b/icons/PVE_B.png differ diff --git a/icons/Photoprism_A.png b/icons/Photoprism_A.png new file mode 100644 index 0000000..375bf7e Binary files /dev/null and b/icons/Photoprism_A.png differ diff --git a/icons/Photoprism_C.png b/icons/Photoprism_C.png new file mode 100644 index 0000000..ba23bfa Binary files /dev/null and b/icons/Photoprism_C.png differ diff --git a/icons/Photoprism_D.png b/icons/Photoprism_D.png new file mode 100644 index 0000000..07f7671 Binary files /dev/null and b/icons/Photoprism_D.png differ diff --git a/icons/Photoprism_E.png b/icons/Photoprism_E.png new file mode 100644 index 0000000..36dfbea Binary files /dev/null and b/icons/Photoprism_E.png differ diff --git a/icons/Photoprism_F.png b/icons/Photoprism_F.png new file mode 100644 index 0000000..6db2a9a Binary files /dev/null and b/icons/Photoprism_F.png differ diff --git a/icons/Photostructure_A.png b/icons/Photostructure_A.png new file mode 100644 index 0000000..21ea218 Binary files /dev/null and b/icons/Photostructure_A.png differ diff --git a/icons/Qbittorrent_B.png b/icons/Qbittorrent_B.png new file mode 100644 index 0000000..07ff264 Binary files /dev/null and b/icons/Qbittorrent_B.png differ diff --git a/icons/Readarr.png b/icons/Readarr.png new file mode 100644 index 0000000..edd7f0e Binary files /dev/null and b/icons/Readarr.png differ diff --git a/icons/Rocky_A.png b/icons/Rocky_A.png new file mode 100644 index 0000000..befc7e3 Binary files /dev/null and b/icons/Rocky_A.png differ diff --git a/icons/Shiori_A.png b/icons/Shiori_A.png new file mode 100644 index 0000000..9d40f9e Binary files /dev/null and b/icons/Shiori_A.png differ diff --git a/icons/UnlockMusic.png b/icons/UnlockMusic.png new file mode 100644 index 0000000..9e3da20 Binary files /dev/null and b/icons/UnlockMusic.png differ diff --git a/icons/Verysync.png b/icons/Verysync.png new file mode 100644 index 0000000..0498478 Binary files /dev/null and b/icons/Verysync.png differ diff --git a/icons/Youtube-dl_C.png b/icons/Youtube-dl_C.png new file mode 100644 index 0000000..debf88a Binary files /dev/null and b/icons/Youtube-dl_C.png differ diff --git a/package.json b/package.json index 59ec75d..6804a4e 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,13 @@ { "AutoSignIn": { "name": "站点自动签到", - "description": "自动模拟登录站点、签到。", - "version": "1.9", + "description": "自动模拟登录、签到站点。", + "version": "2.0", "icon": "signin.png", "author": "thsrite", "level": 2, "history": { + "v2.0": "站点签到时更新站点使用统计信息,需要主程序升级至v1.8.3+版本", "v1.9": "支持馒头新架构自动签到" } }, @@ -21,11 +22,13 @@ "SiteStatistic": { "name": "站点数据统计", "description": "自动统计和展示站点数据。", - "version": "2.6", + "version": "2.8", "icon": "statistic.png", "author": "lightolly", "level": 2, "history": { + "v2.8": "修复馒头未读消息统计", + "v2.7": "修复憨憨种子信息只统计第一页的问题,增加移除失效统计选项", "v2.6": "支持馒头新架构数据统计" } }, @@ -198,12 +201,13 @@ "CrossSeed": { "name": "青蛙辅种助手", "description": "参考ReseedPuppy和IYUU辅种插件实现自动辅种,支持站点:青蛙、AGSVPT、麒麟、UBits、聆音、憨憨等。", - "version": "2.2", + "version": "2.3", "icon": "qingwa.png", "author": "233@qingwa", "level": 2, "history": { - "v2.2": "站点停用后会同步暂停对该站点的辅种" + "v2.2": "站点停用后会同步暂停对该站点的辅种", + "v2.3": "站点辅种支持代理" } }, "VCBAnimeMonitor": { @@ -387,11 +391,13 @@ "RemoveLink": { "name": "清理硬链接", "description": "监控目录内文件被删除时,同步删除监控目录内所有和它硬链接的文件", - "version": "1.7", + "version": "1.9", "icon": "Ombi_A.png", "author": "DzAvril", "level": 1, "history": { + "v1.9": "增加清理刮削文件功能(beta)", + "v1.8": "增加清理空目录功能(beta)", "v1.7": "修复因未监测重命名事件导致的清理硬链接失败的问题", "v1.6": "提升插件性能" } @@ -506,10 +512,13 @@ "IyuuAuth": { "name": "IYUU站点绑定", "description": "为IYUU账号绑定认证站点,以便用于用户认证和辅种。", - "version": "1.0", + "version": "1.1", "icon": "Iyuu_A.png", "author": "jxxghp", - "level": 1 + "level": 1, + "history": { + "v1.1": "修复IYUU站点绑定失败问题" + } }, "NtfyMsg": { "name": "ntfy消息推送", @@ -522,7 +531,7 @@ "PluginAutoUpgrade": { "name": "插件自动升级", "description": "定时检测、升级插件。", - "version": "1.0", + "version": "1.4", "icon": "PluginAutoUpgrade.png", "author": "hotlcc", "level": 1 diff --git a/plugins/autosignin/__init__.py b/plugins/autosignin/__init__.py index 7916052..cc116df 100644 --- a/plugins/autosignin/__init__.py +++ b/plugins/autosignin/__init__.py @@ -16,6 +16,7 @@ from app.chain.site import SiteChain from app.core.config import settings from app.core.event import EventManager, eventmanager, Event from app.db.site_oper import SiteOper +from app.db.sitestatistic_oper import SiteStatisticOper from app.helper.browser import PlaywrightHelper from app.helper.cloudflare import under_challenge from app.helper.module import ModuleHelper @@ -33,11 +34,11 @@ class AutoSignIn(_PluginBase): # 插件名称 plugin_name = "站点自动签到" # 插件描述 - plugin_desc = "自动模拟登录站点、签到。" + plugin_desc = "自动模拟登录、签到站点。" # 插件图标 plugin_icon = "signin.png" # 插件版本 - plugin_version = "1.9" + plugin_version = "2.0" # 插件作者 plugin_author = "thsrite" # 作者主页 @@ -53,6 +54,7 @@ class AutoSignIn(_PluginBase): sites: SitesHelper = None siteoper: SiteOper = None sitechain: SiteChain = None + sitestatistic: SiteStatisticOper = None # 事件管理器 event: EventManager = None # 定时器 @@ -87,6 +89,7 @@ class AutoSignIn(_PluginBase): self.siteoper = SiteOper() self.event = EventManager() self.sitechain = SiteChain() + self.sitestatistic = SiteStatisticOper() # 停止现有任务 self.stop_service() @@ -871,26 +874,34 @@ class AutoSignIn(_PluginBase): 签到一个站点 """ site_module = self.__build_class(site_info.get("url")) + # 开始记时 + start_time = datetime.now() if site_module and hasattr(site_module, "signin"): try: - _, msg = site_module().signin(site_info) - # 特殊站点直接返回签到信息,防止仿真签到、模拟登录有歧义 - return site_info.get("name"), msg or "" + state, message = site_module().signin(site_info) except Exception as e: traceback.print_exc() - return site_info.get("name"), f"签到失败:{str(e)}" + state, message = False, f"签到失败:{str(e)}" else: - return site_info.get("name"), self.__signin_base(site_info) + state, message = self.__signin_base(site_info) + # 统计 + seconds = (datetime.now() - start_time).seconds + domain = StringUtils.get_url_domain(site_info.get('url')) + if state: + self.sitestatistic.success(domain=domain, seconds=seconds) + else: + self.sitestatistic.fail(domain) + return site_info.get("name"), message @staticmethod - def __signin_base(site_info: CommentedMap) -> str: + def __signin_base(site_info: CommentedMap) -> Tuple[bool, str]: """ 通用签到处理 :param site_info: 站点信息 :return: 签到结果信息 """ if not site_info: - return "" + return False, "" site = site_info.get("name") site_url = site_info.get("url") site_cookie = site_info.get("cookie") @@ -900,7 +911,7 @@ class AutoSignIn(_PluginBase): proxy_server = settings.PROXY_SERVER if site_info.get("proxy") else None if not site_url or not site_cookie: logger.warn(f"未配置 {site} 的站点地址或Cookie,无法签到") - return "" + return False, "" # 模拟登录 try: # 访问链接 @@ -916,14 +927,14 @@ class AutoSignIn(_PluginBase): proxies=proxy_server) if not SiteUtils.is_logged_in(page_source): if under_challenge(page_source): - return f"无法通过Cloudflare!" - return f"仿真登录失败,Cookie已失效!" + return False, f"无法通过Cloudflare!" + return False, f"仿真登录失败,Cookie已失效!" else: # 判断是否已签到 if re.search(r'已签|签到已得', page_source, re.IGNORECASE) \ or SiteUtils.is_checkin(page_source): - return f"签到成功" - return "仿真签到成功" + return True, f"签到成功" + return True, "仿真签到成功" else: res = RequestUtils(cookies=site_cookie, ua=ua, @@ -945,20 +956,20 @@ class AutoSignIn(_PluginBase): else: msg = f"状态码:{res.status_code}" logger.warn(f"{site} 签到失败,{msg}") - return f"签到失败,{msg}!" + return False, f"签到失败,{msg}!" else: logger.info(f"{site} 签到成功") - return f"签到成功" + return True, f"签到成功" elif res is not None: logger.warn(f"{site} 签到失败,状态码:{res.status_code}") - return f"签到失败,状态码:{res.status_code}!" + return False, f"签到失败,状态码:{res.status_code}!" else: logger.warn(f"{site} 签到失败,无法打开网站") - return f"签到失败,无法打开网站!" + return False, f"签到失败,无法打开网站!" except Exception as e: logger.warn("%s 签到失败:%s" % (site, str(e))) traceback.print_exc() - return f"签到失败:{str(e)}!" + return False, f"签到失败:{str(e)}!" def login_site(self, site_info: CommentedMap) -> Tuple[str, str]: """ diff --git a/plugins/crossseed/__init__.py b/plugins/crossseed/__init__.py index c33fb51..d05f63a 100644 --- a/plugins/crossseed/__init__.py +++ b/plugins/crossseed/__init__.py @@ -154,7 +154,11 @@ class CrossSeedHelper(object): remote_torrent_infos = [] try: response = requests.post( - site.get_api_url(), headers=headers, json=data, timeout=10 + site.get_api_url(), + headers=headers, + json=data, + timeout=10, + proxies=settings.PROXY if site.proxy else None, ) response.raise_for_status() rsp_body = response.json() @@ -177,7 +181,7 @@ class CrossSeed(_PluginBase): # 插件图标 plugin_icon = "qingwa.png" # 插件版本 - plugin_version = "2.2" + plugin_version = "2.3" # 插件作者 plugin_author = "233@qingwa" # 作者主页 diff --git a/plugins/iyuuauth/__init__.py b/plugins/iyuuauth/__init__.py index 19c54ff..0eadde9 100644 --- a/plugins/iyuuauth/__init__.py +++ b/plugins/iyuuauth/__init__.py @@ -14,7 +14,7 @@ class IyuuAuth(_PluginBase): # 插件图标 plugin_icon = "Iyuu_A.png" # 插件版本 - plugin_version = "1.0" + plugin_version = "1.1" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -85,7 +85,7 @@ class IyuuAuth(_PluginBase): for item in self.iyuu.get_auth_sites() or []: SiteOptions.append({ "title": item.get("site"), - "value": item.get("id") + "value": item.get("site") }) return [ { diff --git a/plugins/pluginautoupgrade/__init__.py b/plugins/pluginautoupgrade/__init__.py index c76cfcd..1caf489 100644 --- a/plugins/pluginautoupgrade/__init__.py +++ b/plugins/pluginautoupgrade/__init__.py @@ -1,469 +1,666 @@ -from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.triggers.cron import CronTrigger -from datetime import datetime, timedelta -from threading import Event as ThreadEvent, RLock -from typing import Any, List, Dict, Tuple, Optional -import pytz -from app import schemas -from app.api.endpoints.plugin import install -from app.core.config import settings -from app.core.plugin import PluginManager -from app.log import logger -from app.plugins import _PluginBase - - -class PluginAutoUpgrade(_PluginBase): - # 插件名称 - plugin_name = "插件自动升级" - # 插件描述 - plugin_desc = "定时检测、升级插件。" - # 插件图标 - plugin_icon = "PluginAutoUpgrade.png" - # 插件版本 - plugin_version = "1.0" - # 插件作者 - plugin_author = "hotlcc" - # 作者主页 - author_url = "https://github.com/hotlcc" - # 插件配置项ID前缀 - plugin_config_prefix = "com.hotlcc.pluginautoupgrade." - # 加载顺序 - plugin_order = 66 - # 可使用的用户级别 - auth_level = 1 - - # 私有属性 - # 调度器 - __scheduler: Optional[BackgroundScheduler] = None - # 退出事件 - __exit_event: ThreadEvent = ThreadEvent() - # 任务锁 - __task_lock: RLock = RLock() - - # 依赖组件 - # 插件管理器 - __plugin_manager: PluginManager = PluginManager() - - # 配置相关 - # 插件缺省配置 - __config_default: Dict[str, Any] = { - 'cron': '* 0/4 * * *' - } - # 插件用户配置 - __config: Dict[str, Any] = {} - - def init_plugin(self, config: dict = None): - """ - 初始化插件 - """ - # 加载插件配置 - self.__config = config - # 停止现有服务 - self.stop_service() - # 如果需要立即运行一次 - if self.__get_config_item(config_key='run_once'): - if (self.__start_scheduler()): - self.__scheduler.add_job(func=self.__try_run, - trigger='date', - run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), - name=f'{self.plugin_name}-立即运行一次') - logger.info(f"立即运行一次成功") - # 关闭一次性开关 - self.__config['run_once'] = False - self.update_config(self.__config) - - def get_state(self) -> bool: - """ - 获取插件状态 - """ - state = True if self.__get_config_item(config_key='enable') \ - and self.__get_config_item(config_key='cron') \ - else False - return state - - @staticmethod - def get_command() -> List[Dict[str, Any]]: - """ - 定义远程控制命令 - :return: 命令关键字、事件、描述、附带数据 - """ - pass - - def get_api(self) -> List[Dict[str, Any]]: - """ - 获取插件API - """ - pass - - def get_service(self) -> List[Dict[str, Any]]: - """ - 注册插件公共服务 - """ - try: - if self.get_state(): - cron = self.__get_config_item(config_key='cron') - return [{ - "id": "PluginAutoUpgradeTimerService", - "name": f"{self.plugin_name}定时服务", - "trigger": CronTrigger.from_crontab(cron), - "func": self.__try_run, - "kwargs": {} - }] - else: - return [] - except Exception as e: - logger.error(f"注册插件公共服务异常: {str(e)}", exc_info=True) - - def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: - """ - 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 - """ - # 建议的配置 - config_suggest = {} - # 合并默认配置 - config_suggest.update(self.__config_default) - # 定时周期 - cron = self.__config_default.get('cron') - # 已安装的在线插件下拉框数据 - installed_online_plugin_options = self.__get_installed_online_plugin_options() - form = [{ - 'component': 'VForm', - 'content': [{ # 业务无关总控 - 'component': 'VRow', - 'content': [{ - 'component': 'VCol', - 'props': { - 'cols': 12, - 'xxl': 4, 'xl': 4, 'lg': 4, 'md': 4, 'sm': 6, 'xs': 12 - }, - 'content': [{ - 'component': 'VSwitch', - 'props': { - 'model': 'enable', - 'label': '启用插件', - 'hint': '插件总开关' - } - }] - }, { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'xxl': 4, 'xl': 4, 'lg': 4, 'md': 4, 'sm': 6, 'xs': 12 - }, - 'content': [{ - 'component': 'VSwitch', - 'props': { - 'model': 'enable_notify', - 'label': '发送通知', - 'hint': '执行插件任务后是否发送通知' - } - }] - }, { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'xxl': 4, 'xl': 4, 'lg': 4, 'md': 4, 'sm': 6, 'xs': 12 - }, - 'content': [{ - 'component': 'VSwitch', - 'props': { - 'model': 'run_once', - 'label': '立即运行一次', - 'hint': '保存插件配置后是否立即触发一次插件任务运行' - } - }] - }] - }, { - 'component': 'VRow', - 'content': [{ - 'component': 'VCol', - 'props': { - 'cols': 12, - 'xxl': 4, 'xl': 4, 'lg': 4, 'md': 4, 'sm': 6, 'xs': 12 - }, - 'content': [{ - 'component': 'VTextField', - 'props': { - 'model': 'cron', - 'label': '定时执行周期', - 'placeholder': cron, - 'hint': f'设置插件任务执行周期。支持5位cron表达式,应避免任务执行过于频繁,缺省时为:【{cron}】' - } - }] - }, { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'xxl': 4, 'xl': 4, 'lg': 4, 'md': 4, 'sm': 6, 'xs': 12 - }, - 'content': [{ - 'component': 'VSelect', - 'props': { - 'model': 'include_plugins', - 'label': '包含的插件', - 'multiple': True, - 'chips': True, - 'items': installed_online_plugin_options, - 'hint': '选择哪些插件需要自动升级,不选时默认全部已安装插件。' - } - }] - }, { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'xxl': 4, 'xl': 4, 'lg': 4, 'md': 4, 'sm': 6, 'xs': 12 - }, - 'content': [{ - 'component': 'VSelect', - 'props': { - 'model': 'exclude_plugins', - 'label': '排除的插件', - 'multiple': True, - 'chips': True, - 'items': installed_online_plugin_options, - 'hint': '选择哪些插件需要排除升级(在【包含的插件】的基础上排除),不选时默认不排除。' - } - }] - }] - }] - }] - return form, config_suggest - - def get_page(self) -> List[dict]: - pass - - def stop_service(self): - """ - 退出插件 - """ - try: - logger.info('尝试停止插件服务...') - self.__exit_event.set() - self.__stop_scheduler() - logger.info('插件服务停止成功') - except Exception as e: - logger.error(f"插件服务停止异常: {str(e)}", exc_info=True) - finally: - self.__exit_event.clear() - - def __get_config_item(self, config_key: str, use_default: bool = True) -> Any: - """ - 获取插件配置项 - :param config_key: 配置键 - :param use_default: 是否使用缺省值 - :return: 配置值 - """ - if not config_key: - return None - config = self.__config if self.__config else {} - config_value = config.get(config_key) - if config_value is None and use_default: - config_default = self.__config_default if self.__config_default else {} - config_value = config_default.get(config_key) - return config_value - - @classmethod - def __get_local_plugins(cls) -> List[schemas.Plugin]: - """ - 获取所有本地插件信息 - """ - local_plugins = cls.__plugin_manager.get_local_plugins() - return local_plugins - - @classmethod - def __get_installed_local_plugins(cls) -> List[schemas.Plugin]: - """ - 获取所有已安装的本地插件信息 - """ - local_plugins = cls.__get_local_plugins() - installed_local_plugins = [local_plugin for local_plugin in local_plugins if local_plugin and local_plugin.installed] - return installed_local_plugins - - @classmethod - def __get_installed_local_plugin(cls, plugin_id: str) -> List[schemas.Plugin]: - """ - 获取指定的已安装的本地插件信息 - """ - if not plugin_id: - return None - # 已安装的本地插件 - installed_plugins = cls.__get_installed_local_plugins() - for installed_plugin in installed_plugins: - if installed_plugin and installed_plugin.id and installed_plugin.id == plugin_id: - return installed_plugin - return None - - @classmethod - def __get_online_plugins(cls) -> List[schemas.Plugin]: - """ - 获取所有在线插件 - """ - online_plugins = cls.__plugin_manager.get_online_plugins() - return online_plugins - - @classmethod - def __get_installed_online_plugins(cls) -> List[schemas.Plugin]: - """ - 获取所有已安装的在线插件 - """ - online_plugins = cls.__get_online_plugins() - installed_online_plugins = [online_plugin for online_plugin in online_plugins if online_plugin and online_plugin.installed] - return installed_online_plugins - - @classmethod - def __get_installed_online_plugin_options(cls) -> Dict[str, Any]: - """ - 获取所有已安装的在线插件的选项数据 - """ - installed_online_plugin_options = [] - installed_online_plugins = cls.__get_installed_online_plugins() - for installed_online_plugin in installed_online_plugins: - if not installed_online_plugin: - continue - installed_online_plugin_options.append({ - 'value': installed_online_plugin.id, - 'title': installed_online_plugin.plugin_name - }) - return installed_online_plugin_options - - @classmethod - def __get_has_update_online_plugins(cls) -> List[schemas.Plugin]: - """ - 获取所有可升级的在线插件 - """ - installed_online_plugins = cls.__get_installed_online_plugins() - if not installed_online_plugins: - return None - has_update_online_plugins = [installed_online_plugin for installed_online_plugin in installed_online_plugins if installed_online_plugin and installed_online_plugin.has_update] - return has_update_online_plugins - - def __start_scheduler(self, timezone=None) -> bool: - """ - 启动调度器 - :param timezone: 时区 - """ - try: - if not self.__scheduler: - if not timezone: - timezone = settings.TZ - self.__scheduler = BackgroundScheduler(timezone=timezone) - logger.debug(f"插件服务调度器初始化完成: timezone = {str(timezone)}") - if not self.__scheduler.running: - self.__scheduler.start() - logger.debug(f"插件服务调度器启动成功") - self.__scheduler.print_jobs() - return True - except Exception as e: - logger.error(f"插件服务调度器启动异常: {str(e)}", exc_info=True) - return False - - def __stop_scheduler(self): - """ - 停止调度器 - """ - try: - logger.info('尝试停止插件服务调度器...') - if self.__scheduler: - self.__scheduler.remove_all_jobs() - if self.__scheduler.running: - self.__scheduler.shutdown() - self.__scheduler = None - logger.info('插件服务调度器停止成功') - else: - logger.info('插件未启用服务调度器,无须停止') - except Exception as e: - logger.error(f"插件服务调度器停止异常: {str(e)}", exc_info=True) - - def __check_allow_upgrade(self, plugin_id: str) -> bool: - """ - 判断插件是否允许升级:包含、排除 - """ - if not plugin_id: - return False - exclude_plugins = self.__get_config_item('exclude_plugins') - if exclude_plugins and plugin_id in exclude_plugins: - return False - include_plugins = self.__get_config_item('include_plugins') - if not include_plugins or plugin_id in include_plugins: - return True - else: - return False - - def __try_run(self): - """ - 尝试运行插件任务 - """ - if not self.__task_lock.acquire(blocking=False): - logger.info('已有进行中的任务,本次不执行') - return - try: - self.__run() - finally: - self.__task_lock.release() - - def __run(self): - """" - 运行插件任务 - """ - self.__upgrade_batch() - - def __upgrade_batch(self): - """ - 批量升级 - """ - has_update_online_plugins = self.__get_has_update_online_plugins() - upgrade_results = [] - for has_update_online_plugin in has_update_online_plugins: - upgrade_result = self.__upgrade_single(has_update_online_plugin) - if upgrade_result: - upgrade_results.append(upgrade_result) - self.__send_notify(results=upgrade_results) - - def __upgrade_single(self, online_plugin: schemas.Plugin) -> Dict[str, Any]: - """ - 单个升级 - """ - if not online_plugin or not online_plugin.has_update or not online_plugin.id or not online_plugin.repo_url or not self.__check_allow_upgrade(plugin_id=online_plugin.id): - return None - installed_local_plugin = self.__get_installed_local_plugin(plugin_id=online_plugin.id) - if not installed_local_plugin: - return None - response = install(plugin_id=online_plugin.id, repo_url=online_plugin.repo_url, force=True) - logger.info(f"插件升级结果: plugin_name = {online_plugin.plugin_name}, plugin_version = v{installed_local_plugin.plugin_version} -> v{online_plugin.plugin_version}, success = {response.success}, message = {response.message}") - return { - 'success': response.success, - 'message': response.message, - 'plugin_id': online_plugin.id, - 'plugin_name': online_plugin.plugin_name, - 'new_plugin_version': online_plugin.plugin_version, - 'old_plugin_version': installed_local_plugin.plugin_version - } - - def __send_notify(self, results: List[Dict[str, Any]]): - """ - 发送通知 - :param results: 插件升级结果 - """ - if not results or not self.__get_config_item('enable_notify'): - return - text = self.__build_notify_message(results=results) - if not text: - return - self.post_message(title=f'{self.plugin_name}任务执行结果', text=text) - - @staticmethod - def __build_notify_message(results: List[Dict[str, Any]]) -> str: - """ - 构建通知消息内容 - """ - text = '' - if not results: - return text - for result in results: - if not result: - continue - if result.get('success'): - text += f"{result.get('plugin_name')}升级[v{result.get('old_plugin_version')} -> v{result.get('new_plugin_version')}]成功\n" - else: - text += f"{result.get('plugin_name')}升级[v{result.get('old_plugin_version')} -> v{result.get('new_plugin_version')}]失败:{result.get('message')}\n" - return text +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from datetime import datetime, timedelta +from threading import Event as ThreadEvent, RLock +from typing import Any, List, Dict, Tuple, Optional +import pytz +from app import schemas +from app.core.config import settings +from app.core.plugin import PluginManager +from app.db.systemconfig_oper import SystemConfigOper +from app.helper.plugin import PluginHelper +from app.log import logger +from app.plugins import _PluginBase +from app.scheduler import Scheduler +from app.schemas.types import SystemConfigKey + + +class PluginAutoUpgrade(_PluginBase): + # 插件名称 + plugin_name = "插件自动升级" + # 插件描述 + plugin_desc = "定时检测、升级插件。" + # 插件图标 + plugin_icon = "PluginAutoUpgrade.png" + # 插件版本 + plugin_version = "1.4" + # 插件作者 + plugin_author = "hotlcc" + # 作者主页 + author_url = "https://github.com/hotlcc" + # 插件配置项ID前缀 + plugin_config_prefix = "com.hotlcc.pluginautoupgrade." + # 加载顺序 + plugin_order = 66 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + # 调度器 + __scheduler: Optional[BackgroundScheduler] = None + # 退出事件 + __exit_event: ThreadEvent = ThreadEvent() + # 任务锁 + __task_lock: RLock = RLock() + # 插件数据key:升级记录 + __data_key_upgrade_records = "upgrade_records" + + # 依赖组件 + # 插件管理器 + __plugin_manager: PluginManager = PluginManager() + + # 配置相关 + # 插件缺省配置 + __config_default: Dict[str, Any] = { + 'cron': '* 0/4 * * *' + } + # 插件用户配置 + __config: Dict[str, Any] = {} + + def init_plugin(self, config: dict = None): + """ + 初始化插件 + """ + # 加载插件配置 + self.__config = config + # 停止现有服务 + self.stop_service() + # 如果需要立即运行一次 + if self.__get_config_item(config_key='run_once'): + if (self.__start_scheduler()): + self.__scheduler.add_job(func=self.__try_run, + trigger='date', + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name=f'{self.plugin_name}-立即运行一次') + logger.info(f"立即运行一次成功") + # 关闭一次性开关 + self.__config['run_once'] = False + self.update_config(self.__config) + + def get_state(self) -> bool: + """ + 获取插件状态 + """ + state = True if self.__get_config_item(config_key='enable') \ + and self.__get_config_item(config_key='cron') \ + else False + return state + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + """ + 定义远程控制命令 + :return: 命令关键字、事件、描述、附带数据 + """ + pass + + def get_api(self) -> List[Dict[str, Any]]: + """ + 获取插件API + """ + pass + + def get_service(self) -> List[Dict[str, Any]]: + """ + 注册插件公共服务 + """ + try: + if self.get_state(): + cron = self.__get_config_item(config_key='cron') + return [{ + "id": "PluginAutoUpgradeTimerService", + "name": f"{self.plugin_name}定时服务", + "trigger": CronTrigger.from_crontab(cron), + "func": self.__try_run, + "kwargs": {} + }] + else: + return [] + except Exception as e: + logger.error(f"注册插件公共服务异常: {str(e)}", exc_info=True) + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + # 建议的配置 + config_suggest = {} + # 合并默认配置 + config_suggest.update(self.__config_default) + # 定时周期 + cron = self.__config_default.get('cron') + # 已安装的在线插件下拉框数据 + installed_online_plugin_options = self.__get_installed_online_plugin_options() + form = [{ + 'component': 'VForm', + 'content': [{ # 业务无关总控 + 'component': 'VRow', + 'content': [{ + 'component': 'VCol', + 'props': { + 'cols': 12, + 'xxl': 4, 'xl': 4, 'lg': 4, 'md': 4, 'sm': 6, 'xs': 12 + }, + 'content': [{ + 'component': 'VSwitch', + 'props': { + 'model': 'enable', + 'label': '启用插件', + 'hint': '插件总开关' + } + }] + }, { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'xxl': 4, 'xl': 4, 'lg': 4, 'md': 4, 'sm': 6, 'xs': 12 + }, + 'content': [{ + 'component': 'VSwitch', + 'props': { + 'model': 'enable_notify', + 'label': '发送通知', + 'hint': '执行插件任务后是否发送通知' + } + }] + }, { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'xxl': 4, 'xl': 4, 'lg': 4, 'md': 4, 'sm': 6, 'xs': 12 + }, + 'content': [{ + 'component': 'VSwitch', + 'props': { + 'model': 'run_once', + 'label': '立即运行一次', + 'hint': '保存插件配置后是否立即触发一次插件任务运行' + } + }] + }] + }, { + 'component': 'VRow', + 'content': [{ + 'component': 'VCol', + 'props': { + 'cols': 12, + 'xxl': 4, 'xl': 4, 'lg': 4, 'md': 4, 'sm': 6, 'xs': 12 + }, + 'content': [{ + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '定时执行周期', + 'placeholder': cron, + 'hint': f'设置插件任务执行周期。支持5位cron表达式,应避免任务执行过于频繁,缺省时为:【{cron}】' + } + }] + }, { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'xxl': 4, 'xl': 4, 'lg': 4, 'md': 4, 'sm': 6, 'xs': 12 + }, + 'content': [{ + 'component': 'VSelect', + 'props': { + 'model': 'include_plugins', + 'label': '包含的插件', + 'multiple': True, + 'chips': True, + 'items': installed_online_plugin_options, + 'hint': '选择哪些插件需要自动升级,不选时默认全部已安装插件。' + } + }] + }, { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'xxl': 4, 'xl': 4, 'lg': 4, 'md': 4, 'sm': 6, 'xs': 12 + }, + 'content': [{ + 'component': 'VSelect', + 'props': { + 'model': 'exclude_plugins', + 'label': '排除的插件', + 'multiple': True, + 'chips': True, + 'items': installed_online_plugin_options, + 'hint': '选择哪些插件需要排除升级(在【包含的插件】的基础上排除),不选时默认不排除。' + } + }] + }] + }] + }] + return form, config_suggest + + def get_page(self) -> List[dict]: + """ + 拼装插件详情页面,需要返回页面配置,同时附带数据 + """ + page_data = self.__get_upgrade_records_to_page_data() + if page_data: + contents = [{ + 'component': 'tr', + 'props': { + 'class': 'text-sm' + }, + 'content': [{ + 'component': 'td', + 'props': { + 'class': 'whitespace-nowrap' + }, + 'text': item.get('datetime_str') + }, { + 'component': 'td', + 'props': { + 'class': 'whitespace-nowrap' + }, + 'text': item.get('plugin_name') + }, { + 'component': 'td', + 'props': { + 'class': 'whitespace-nowrap' + }, + 'text': f'v{item.get("old_plugin_version")}' + }, { + 'component': 'td', + 'props': { + 'class': 'whitespace-nowrap' + }, + 'text': f'v{item.get("new_plugin_version")}' + }, { + 'component': 'td', + 'text': item.get('info') + }, { + 'component': 'td', + 'text': item.get('upgrade_info') + }] + } for item in page_data if item] + else: + contents = [{ + 'component': 'tr', + 'props': { + 'class': 'text-sm' + }, + 'content': [{ + 'component': 'td', + 'props': { + 'colspan': '6', + 'class': 'text-center' + }, + 'text': '暂无数据' + }] + }] + return [{ + 'component': 'VTable', + 'props': { + 'hover': True + }, + 'content': [{ + 'component': 'thead', + 'content': [{ + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '时间' + }, { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '插件名称' + }, { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '旧版本' + }, { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '新版本' + }, { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '执行结果' + }, { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '升级描述' + }] + }, { + 'component': 'tbody', + 'content': contents + }] + }] + + def stop_service(self): + """ + 退出插件 + """ + try: + logger.info('尝试停止插件服务...') + self.__exit_event.set() + self.__stop_scheduler() + logger.info('插件服务停止成功') + except Exception as e: + logger.error(f"插件服务停止异常: {str(e)}", exc_info=True) + finally: + self.__exit_event.clear() + + def __get_config_item(self, config_key: str, use_default: bool = True) -> Any: + """ + 获取插件配置项 + :param config_key: 配置键 + :param use_default: 是否使用缺省值 + :return: 配置值 + """ + if not config_key: + return None + config = self.__config if self.__config else {} + config_value = config.get(config_key) + if config_value is None and use_default: + config_default = self.__config_default if self.__config_default else {} + config_value = config_default.get(config_key) + return config_value + + @classmethod + def __get_local_plugins(cls) -> List[schemas.Plugin]: + """ + 获取所有本地插件信息 + """ + local_plugins = cls.__plugin_manager.get_local_plugins() + return local_plugins + + @classmethod + def __get_installed_local_plugins(cls) -> List[schemas.Plugin]: + """ + 获取所有已安装的本地插件信息 + """ + local_plugins = cls.__get_local_plugins() + installed_local_plugins = [local_plugin for local_plugin in local_plugins if local_plugin and local_plugin.installed] + return installed_local_plugins + + @classmethod + def __get_installed_local_plugin(cls, plugin_id: str) -> List[schemas.Plugin]: + """ + 获取指定的已安装的本地插件信息 + """ + if not plugin_id: + return None + # 已安装的本地插件 + installed_plugins = cls.__get_installed_local_plugins() + for installed_plugin in installed_plugins: + if installed_plugin and installed_plugin.id and installed_plugin.id == plugin_id: + return installed_plugin + return None + + @classmethod + def __get_online_plugins(cls) -> List[schemas.Plugin]: + """ + 获取所有在线插件 + """ + online_plugins = cls.__plugin_manager.get_online_plugins() + return online_plugins + + @classmethod + def __get_installed_online_plugins(cls) -> List[schemas.Plugin]: + """ + 获取所有已安装的在线插件 + """ + online_plugins = cls.__get_online_plugins() + installed_online_plugins = [online_plugin for online_plugin in online_plugins if online_plugin and online_plugin.installed] + return installed_online_plugins + + @classmethod + def __get_installed_online_plugin_options(cls) -> Dict[str, Any]: + """ + 获取所有已安装的在线插件的选项数据 + """ + installed_online_plugin_options = [] + installed_online_plugins = cls.__get_installed_online_plugins() + for installed_online_plugin in installed_online_plugins: + if not installed_online_plugin: + continue + installed_online_plugin_options.append({ + 'value': installed_online_plugin.id, + 'title': installed_online_plugin.plugin_name + }) + return installed_online_plugin_options + + @classmethod + def __get_has_update_online_plugins(cls) -> List[schemas.Plugin]: + """ + 获取所有可升级的在线插件 + """ + installed_online_plugins = cls.__get_installed_online_plugins() + if not installed_online_plugins: + return None + has_update_online_plugins = [installed_online_plugin for installed_online_plugin in installed_online_plugins if installed_online_plugin and installed_online_plugin.has_update] + return has_update_online_plugins + + def __start_scheduler(self, timezone=None) -> bool: + """ + 启动调度器 + :param timezone: 时区 + """ + try: + if not self.__scheduler: + if not timezone: + timezone = settings.TZ + self.__scheduler = BackgroundScheduler(timezone=timezone) + logger.debug(f"插件服务调度器初始化完成: timezone = {str(timezone)}") + if not self.__scheduler.running: + self.__scheduler.start() + logger.debug(f"插件服务调度器启动成功") + self.__scheduler.print_jobs() + return True + except Exception as e: + logger.error(f"插件服务调度器启动异常: {str(e)}", exc_info=True) + return False + + def __stop_scheduler(self): + """ + 停止调度器 + """ + try: + logger.info('尝试停止插件服务调度器...') + if self.__scheduler: + self.__scheduler.remove_all_jobs() + if self.__scheduler.running: + self.__scheduler.shutdown() + self.__scheduler = None + logger.info('插件服务调度器停止成功') + else: + logger.info('插件未启用服务调度器,无须停止') + except Exception as e: + logger.error(f"插件服务调度器停止异常: {str(e)}", exc_info=True) + + def __check_allow_upgrade(self, plugin_id: str) -> bool: + """ + 判断插件是否允许升级:包含、排除 + """ + if not plugin_id: + return False + exclude_plugins = self.__get_config_item('exclude_plugins') + if exclude_plugins and plugin_id in exclude_plugins: + return False + include_plugins = self.__get_config_item('include_plugins') + if not include_plugins or plugin_id in include_plugins: + return True + else: + return False + + @staticmethod + def __install_plugin(plugin_id: str, repo_url: str = "", force: bool = False) -> Tuple[bool, str]: + """ + 安装插件,参考:app.api.endpoints.plugin.install + :param plugin_id: 插件ID + :param repo_url: 插件仓库URL + :param force: 是否强制安装 + """ + # 已安装插件 + install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] + # 如果是非本地括件,或者强制安装时,则需要下载安装 + if repo_url and (force or plugin_id not in PluginManager().get_plugin_ids()): + # 下载安装 + state, msg = PluginHelper().install(pid=plugin_id, repo_url=repo_url) + if not state: + # 安装失败 + return False, msg + # 安装插件 + if plugin_id not in install_plugins: + install_plugins.append(plugin_id) + # 保存设置 + SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins) + # 加载插件到内存 + PluginManager().reload_plugin(plugin_id) + # 注册插件服务 + Scheduler().update_plugin_job(plugin_id) + return True, None + + def __try_run(self): + """ + 尝试运行插件任务 + """ + if not self.__task_lock.acquire(blocking=False): + logger.info('已有进行中的任务,本次不执行') + return + try: + self.__run() + finally: + self.__task_lock.release() + + def __run(self): + """" + 运行插件任务 + """ + self.__upgrade_batch() + + def __upgrade_batch(self): + """ + 批量升级 + """ + has_update_online_plugins = self.__get_has_update_online_plugins() + upgrade_results = [] + for has_update_online_plugin in has_update_online_plugins: + upgrade_result = self.__upgrade_single(has_update_online_plugin) + if upgrade_result: + upgrade_results.append(upgrade_result) + # 保存升级记录 + self.__save_upgrade_records(records=upgrade_results) + # 发送通知 + self.__send_notify(results=upgrade_results) + + def __upgrade_single(self, online_plugin: schemas.Plugin) -> Dict[str, Any]: + """ + 单个升级 + """ + if not online_plugin or not online_plugin.has_update or not online_plugin.id or not online_plugin.repo_url or not self.__check_allow_upgrade(plugin_id=online_plugin.id): + return None + installed_local_plugin = self.__get_installed_local_plugin(plugin_id=online_plugin.id) + if not installed_local_plugin: + return None + success, message = self.__install_plugin(plugin_id=online_plugin.id, repo_url=online_plugin.repo_url, force=True) + logger.info(f"插件升级结果: plugin_name = {online_plugin.plugin_name}, plugin_version = v{installed_local_plugin.plugin_version} -> v{online_plugin.plugin_version}, success = {success}, message = {message}") + return { + 'success': success, + 'message': message, + 'plugin_id': online_plugin.id, + 'plugin_name': online_plugin.plugin_name, + 'new_plugin_version': online_plugin.plugin_version, + 'old_plugin_version': installed_local_plugin.plugin_version, + 'datetime_str': datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + 'upgrade_info': self.__extract_upgrade_history(online_plugin) + } + + def __send_notify(self, results: List[Dict[str, Any]]): + """ + 发送通知 + :param results: 插件升级结果 + """ + if not results or not self.__get_config_item('enable_notify'): + return + text = self.__build_notify_message(results=results) + if not text: + return + self.post_message(title=f'{self.plugin_name}任务执行结果', text=text) + + @staticmethod + def __build_notify_message(results: List[Dict[str, Any]]) -> str: + """ + 构建通知消息内容 + """ + text = '' + if not results: + return text + for result in results: + if not result: + continue + text += f"【{result.get('plugin_name')}】[v{result.get('old_plugin_version')} -> v{result.get('new_plugin_version')}]:" + if result.get('success'): + text += f"成功\n" + else: + text += f"{result.get('message')}\n" + return text + + def __save_upgrade_records(self, records: List[Dict[str, Any]]): + """ + 保存升级记录 + """ + if not records: + return + upgrade_records = self.get_data(self.__data_key_upgrade_records) + if not upgrade_records: + upgrade_records = [] + upgrade_records.extend(records) + # 最多保存100条 + upgrade_records = upgrade_records[-100:] + self.save_data(self.__data_key_upgrade_records, upgrade_records) + + @staticmethod + def __convert_upgrade_record_to_page_data(upgrade_record: Dict[str, Any]) -> Dict[str, Any]: + if not upgrade_record: + return None + info = "成功" if upgrade_record.get("success") else upgrade_record.get("message") + upgrade_record.update({"info": info}) + return upgrade_record + + def __get_upgrade_records_to_page_data(self) -> List[Dict[str, Any]]: + """ + 获取升级记录为page数据 + """ + upgrade_records = self.get_data(self.__data_key_upgrade_records) + if not upgrade_records: + return [] + # 只展示最近10条 + upgrade_records = upgrade_records[-10:] + page_data = [self.__convert_upgrade_record_to_page_data(upgrade_record) for upgrade_record in upgrade_records if upgrade_record] + # 按时间倒序 + page_data = sorted(page_data, key=lambda item: item.get("datetime_str"), reverse=True) + return page_data + + @staticmethod + def __extract_upgrade_history(plugin: schemas.Plugin, version: str = None) -> str: + """ + 提取指定版本的升级历史信息 + """ + if not plugin or not plugin.history: + return None + if not version: + version = plugin.plugin_version + if not version: + return None + version_history = plugin.history.get(f'v{version}') + if not version_history: + # 兼容处理 + version_history = plugin.history.get(version) + return version_history diff --git a/plugins/removelink/__init__.py b/plugins/removelink/__init__.py index 604cdd4..b06e49d 100644 --- a/plugins/removelink/__init__.py +++ b/plugins/removelink/__init__.py @@ -40,7 +40,7 @@ class FileMonitorHandler(FileSystemEventHandler): # 新增文件记录 with state_lock: self.sync.state_set[str(file_path)] = file_path.stat().st_ino - + def on_moved(self, event): if event.is_directory: return @@ -106,7 +106,7 @@ class RemoveLink(_PluginBase): # 插件图标 plugin_icon = "Ombi_A.png" # 插件版本 - plugin_version = "1.7" + plugin_version = "1.9" # 插件作者 plugin_author = "DzAvril" # 作者主页 @@ -124,6 +124,7 @@ class RemoveLink(_PluginBase): exclude_keywords = "" _enabled = False _notify = False + _delete_scrap_infos = False _observer = [] # 监控目录的文件列表 state_set: Dict[str, int] = {} @@ -136,6 +137,7 @@ class RemoveLink(_PluginBase): self.monitor_dirs = config.get("monitor_dirs") self.exclude_dirs = config.get("exclude_dirs") or "" self.exclude_keywords = config.get("exclude_keywords") or "" + self._delete_scrap_infos = config.get("delete_scrap_infos") # 停止现有任务 self.stop_service() @@ -224,6 +226,19 @@ class RemoveLink(_PluginBase): } ], }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "delete_scrap_infos", + "label": "清理刮削文件(beta)", + }, + } + ], + }, ], }, { @@ -289,32 +304,47 @@ class RemoveLink(_PluginBase): ], }, { - 'component': 'VRow', - 'content': [ + "component": "VRow", + "content": [ { - 'component': 'VCol', - 'props': { - 'cols': 12, + "component": "VCol", + "props": { + "cols": 12, }, - 'content': [ + "content": [ { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '监控目录如有多个需换行,源目录和硬链接目录都需要添加到监控目录中;如需实现删除硬链接时不删除源文件,可把源文件目录配置到不删除目录中。' - } + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "监控目录如有多个需换行,源目录和硬链接目录都需要添加到监控目录中;如需实现删除硬链接时不删除源文件,可把源文件目录配置到不删除目录中。", + }, } - ] - } - ] - } + ], + }, + { + "component": "VCol", + "props": { + "cols": 12, + }, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "清理刮削文件为测试功能,请谨慎开启。", + }, + } + ], + }, + ], + }, ], } ], { "enabled": False, "notify": False, - "onlyonce": False, "monitor_dirs": "", "exclude_keywords": "", } @@ -345,12 +375,91 @@ class RemoveLink(_PluginBase): return True return False + def scrape_files_left(self, path): + """ + 检查path目录是否只包含刮削文件 + """ + # 检查path下是否有目录 + for dir_path in os.listdir(path): + if os.path.isdir(os.path.join(path, dir_path)): + return False + + # 检查path下是否有非刮削文件 + for file in path.iterdir(): + if not file.suffix.lower() in [ + ".jpg", + ".nfo", + ]: + return False + return True + + def delete_scrap_infos(self, path): + """ + 清理path相关的刮削文件 + """ + if not self._delete_scrap_infos: + return + # 文件所在目录已被删除则退出 + if not os.path.exists(path.parent): + return + logger.info(f"清理刮削文件: {path}") + if not path.suffix.lower() in [ + ".jpg", + ".nfo", + ]: + # 清理与path相关的刮削文件 + name_prefix = path.stem + for file in path.parent.iterdir(): + if file.name.startswith(name_prefix): + file.unlink() + logger.info(f'删除刮削文件:{file}') + # 清理空目录 + self.delete_empty_folders(path) + def delete_empty_folders(self, path): + """ + 从指定路径开始,逐级向上层目录检测并删除空目录,直到遇到非空目录或到达指定监控目录为止 + """ + logger.info(f"清理空目录: {path}") + while True: + parent_path = path.parent + if self.__is_excluded(parent_path): + break + # parent_path如已被删除则退出检查 + if not os.path.exists(parent_path): + break + # 如果当前路径等于监控目录之一,停止向上检查 + if parent_path in self.monitor_dirs.split("\n"): + break + + # 若目录下只剩刮削文件,则清空文件夹 + if self.scrape_files_left(parent_path): + # 清除目录下所有文件 + for file in parent_path.iterdir(): + file.unlink() + logger.info(f'删除刮削文件:{file}') + + if not os.listdir(parent_path): + os.rmdir(parent_path) + logger.info(f"清理空目录:{parent_path}") + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【清理硬链接】", + text=f"清理空文件夹:[{parent_path}]\n", + ) + else: + break + # 更新路径为父目录,准备下一轮检查 + path = parent_path + def handle_deleted(self, file_path: Path): """ 处理删除事件 """ # 删除的文件对应的监控信息 with state_lock: + # 清理刮削文件 + self.delete_scrap_infos(file_path) # 删除的文件inode deleted_inode = self.state_set.get(str(file_path)) if not deleted_inode: @@ -369,12 +478,16 @@ class RemoveLink(_PluginBase): # 删除硬链接文件 logger.info(f"删除硬链接文件:{path}, inode: {inode}") file.unlink() + # 清理刮削文件 + self.delete_scrap_infos(file_path) if self._notify: self.post_message( mtype=NotificationType.SiteMessage, title=f"【清理硬链接】", text=f"监控到删除源文件:[{file_path}]\n" - f"同步删除硬链接文件:[{path}]", + f"同步删除硬链接文件:[{path}]", ) except Exception as e: - logger.error("删除硬链接文件发生错误:%s - %s" % (str(e), traceback.format_exc())) + logger.error( + "删除硬链接文件发生错误:%s - %s" % (str(e), traceback.format_exc()) + ) diff --git a/plugins/sitestatistic/__init__.py b/plugins/sitestatistic/__init__.py index 41d4c0f..b07c5d8 100644 --- a/plugins/sitestatistic/__init__.py +++ b/plugins/sitestatistic/__init__.py @@ -43,7 +43,7 @@ class SiteStatistic(_PluginBase): # 插件图标 plugin_icon = "statistic.png" # 插件版本 - plugin_version = "2.6" + plugin_version = "2.8" # 插件作者 plugin_author = "lightolly" # 作者主页 @@ -70,6 +70,7 @@ class SiteStatistic(_PluginBase): _cron: str = "" _notify: bool = False _queue_cnt: int = 5 + _remove_failed: bool = False _statistic_type: str = None _statistic_sites: list = [] @@ -87,6 +88,7 @@ class SiteStatistic(_PluginBase): self._notify = config.get("notify") self._sitemsg = config.get("sitemsg") self._queue_cnt = config.get("queue_cnt") + self._remove_failed = config.get("remove_failed") self._statistic_type = config.get("statistic_type") or "all" self._statistic_sites = config.get("statistic_sites") or [] @@ -369,7 +371,23 @@ class SiteStatistic(_PluginBase): } } ] - } + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'remove_failed', + 'label': '移除失效站点', + } + } + ] + }, ] } ] @@ -381,6 +399,7 @@ class SiteStatistic(_PluginBase): "sitemsg": True, "cron": "5 1 * * *", "queue_cnt": 5, + "remove_failed": False, "statistic_type": "all", "statistic_sites": [] } @@ -1179,7 +1198,8 @@ class SiteStatistic(_PluginBase): "bonus": site_user_info.bonus, "url": site_url, "err_msg": site_user_info.err_msg, - "message_unread": site_user_info.message_unread + "message_unread": site_user_info.message_unread, + "updated_at": datetime.now().strftime('%Y-%m-%d') } }) return site_user_info @@ -1248,62 +1268,67 @@ class SiteStatistic(_PluginBase): if not refresh_sites: return + # 将数据初始化为前一天,筛选站点 + yesterday_sites_data = {} + today_date = datetime.now().strftime('%Y-%m-%d') + if self._statistic_type == "add" or not self._remove_failed: + if last_update_time := self.get_data("last_update_time"): + yesterday_sites_data = self.get_data(last_update_time) or {} + + if not self._remove_failed and yesterday_sites_data: + site_names = [site.get("name") for site in refresh_sites] + self._sites_data = {k: v for k, v in yesterday_sites_data.items() if k in site_names} + # 并发刷新 with ThreadPool(min(len(refresh_sites), int(self._queue_cnt or 5))) as p: p.map(self.__refresh_site_data, refresh_sites) # 通知刷新完成 if self._notify: - yesterday_sites_data = {} - # 增量数据 - if self._statistic_type == "add": - last_update_time = self.get_data("last_update_time") - if last_update_time: - yesterday_sites_data = self.get_data(last_update_time) or {} - - messages = [] - # 按照上传降序排序 - sites = self._sites_data.keys() - uploads = [self._sites_data[site].get("upload") or 0 if not yesterday_sites_data.get(site) else - int(self._sites_data[site].get("upload") or 0) - int( - yesterday_sites_data[site].get("upload") or 0) for site in sites] - downloads = [self._sites_data[site].get("download") or 0 if not yesterday_sites_data.get(site) else - int(self._sites_data[site].get("download") or 0) - int( - yesterday_sites_data[site].get("download") or 0) for site in sites] - data_list = sorted(list(zip(sites, uploads, downloads)), - key=lambda x: x[1], - reverse=True) + messages = {} # 总上传 incUploads = 0 # 总下载 incDownloads = 0 - for data in data_list: - site = data[0] - upload = int(data[1]) - download = int(data[2]) + + for rand, site in enumerate(self._sites_data.keys()): + upload = int(self._sites_data[site].get("upload") or 0) + download = int(self._sites_data[site].get("download") or 0) + updated_date = self._sites_data[site].get("updated_at") + + if self._statistic_type == "add" and yesterday_sites_data.get(site): + upload -= int(yesterday_sites_data[site].get("upload") or 0) + download -= int(yesterday_sites_data[site].get("download") or 0) + + if updated_date and updated_date != today_date: + updated_date = f"({updated_date})" + else: + updated_date = "" + if upload > 0 or download > 0: - incUploads += int(upload) - incDownloads += int(download) - messages.append(f"【{site}】\n" - f"上传量:{StringUtils.str_filesize(upload)}\n" - f"下载量:{StringUtils.str_filesize(download)}\n" - f"————————————") + incUploads += upload + incDownloads += download + messages[upload + (rand / 1000)] = ( + f"【{site}】{updated_date}\n" + + f"上传量:{StringUtils.str_filesize(upload)}\n" + + f"下载量:{StringUtils.str_filesize(download)}\n" + + "————————————" + ) if incDownloads or incUploads: - messages.insert(0, f"【汇总】\n" + sorted_messages = [messages[key] for key in sorted(messages.keys(), reverse=True)] + sorted_messages.insert(0, f"【汇总】\n" f"总上传:{StringUtils.str_filesize(incUploads)}\n" f"总下载:{StringUtils.str_filesize(incDownloads)}\n" f"————————————") self.post_message(mtype=NotificationType.SiteMessage, - title="站点数据统计", text="\n".join(messages)) + title="站点数据统计", text="\n".join(sorted_messages)) - # 获取今天的日期 - key = datetime.now().strftime('%Y-%m-%d') # 保存数据 - self.save_data(key, self._sites_data) + self.save_data(today_date, self._sites_data) # 更新时间 - self.save_data("last_update_time", key) + self.save_data("last_update_time", today_date) logger.info("站点数据刷新完成") def __custom_sites(self) -> List[Any]: @@ -1321,6 +1346,7 @@ class SiteStatistic(_PluginBase): "notify": self._notify, "sitemsg": self._sitemsg, "queue_cnt": self._queue_cnt, + "remove_failed": self._remove_failed, "statistic_type": self._statistic_type, "statistic_sites": self._statistic_sites, }) diff --git a/plugins/sitestatistic/siteuserinfo/__init__.py b/plugins/sitestatistic/siteuserinfo/__init__.py index 47548da..e98a1e2 100644 --- a/plugins/sitestatistic/siteuserinfo/__init__.py +++ b/plugins/sitestatistic/siteuserinfo/__init__.py @@ -217,6 +217,9 @@ class ISiteUserInfo(metaclass=ABCMeta): msg_links ) unread_msg_links.extend(msg_links) + # 重新更新未读消息数(99999表示有消息但数量未知) + if self.message_unread == 99999: + self.message_unread = len(unread_msg_links) # 解析未读消息内容 for msg_link in unread_msg_links: logger.debug(f"{self.site_name} 信息链接 {msg_link}") diff --git a/plugins/sitestatistic/siteuserinfo/mtorrent.py b/plugins/sitestatistic/siteuserinfo/mtorrent.py index 758fc7c..4881418 100644 --- a/plugins/sitestatistic/siteuserinfo/mtorrent.py +++ b/plugins/sitestatistic/siteuserinfo/mtorrent.py @@ -102,7 +102,8 @@ class MTorrentSiteUserInfo(ISiteUserInfo): self.download = int(user_info.get("memberCount", {}).get("downloaded") or '0') self.ratio = user_info.get("memberCount", {}).get("shareRate") or 0 self.bonus = user_info.get("memberCount", {}).get("bonus") or 0 - self.message_unread = 1 + # 需要解析消息,但不确定消息条数 + self.message_unread = 99999 self._torrent_seeding_params = { "pageNumber": 1, diff --git a/plugins/sitestatistic/siteuserinfo/nexus_php.py b/plugins/sitestatistic/siteuserinfo/nexus_php.py index 783f92b..e5efd06 100644 --- a/plugins/sitestatistic/siteuserinfo/nexus_php.py +++ b/plugins/sitestatistic/siteuserinfo/nexus_php.py @@ -217,7 +217,7 @@ class NexusPhpSiteUserInfo(ISiteUserInfo): # 是否存在下页数据 next_page = None - next_page_text = html.xpath('//a[contains(.//text(), "下一页") or contains(.//text(), "下一頁")]/@href') + next_page_text = html.xpath('//a[contains(.//text(), "下一页") or contains(.//text(), "下一頁") or contains(.//text(), ">")]/@href') if next_page_text: next_page = next_page_text[-1].strip() # fix up page url