diff --git a/README.md b/README.md index 9c72cc4..f9e7cf0 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ MoviePilot官方插件市场:https://github.com/jxxghp/MoviePilot-Plugins - 插件命名请勿与官方库插件中的插件冲突,否则会在MoviePilot版本升级时被官方插件覆盖。 ### 4. 依赖 -- 可在插件目录中放置`requirement.txt`文件,用于指定插件依赖的第三方库,MoviePilot会在插件安装时自动安装依赖库。 +- 可在插件目录中放置`requirements.txt`文件,用于指定插件依赖的第三方库,MoviePilot会在插件安装时自动安装依赖库。 ### 5. 界面开发 - 插件支持`插件配置`及`详情展示`两个展示页面,通过配置化的方式组装,使用 [Vuetify](https://vuetifyjs.com/) 组件库,所有该组件库有的组件都可以通过Json配置使用。 diff --git a/icons/PluginAutoUpgrade.png b/icons/PluginAutoUpgrade.png new file mode 100644 index 0000000..104c5b6 Binary files /dev/null and b/icons/PluginAutoUpgrade.png differ diff --git a/package.json b/package.json index 822585f..b6efd5f 100644 --- a/package.json +++ b/package.json @@ -198,10 +198,13 @@ "CrossSeed": { "name": "青蛙辅种助手", "description": "参考ReseedPuppy和IYUU辅种插件实现自动辅种,支持站点:青蛙、AGSVPT、麒麟、UBits、聆音、憨憨等。", - "version": "2.1", + "version": "2.2", "icon": "qingwa.png", "author": "233@qingwa", - "level": 2 + "level": 2, + "history": { + "v2.2": "站点停用后会同步暂停对该站点的辅种" + } }, "VCBAnimeMonitor": { "name": "整理VCB动漫压制组作品", @@ -238,12 +241,13 @@ "BrushFlow": { "name": "站点刷流", "description": "自动托管刷流,将会提高对应站点的访问频率。", - "version": "2.7", + "version": "2.8", "icon": "brush.jpg", "author": "jxxghp,InfinityPacer", "level": 2, "history": { - "v2.7": "优化UI显示以及提升性能", + "v2.8": "优化UI显示以及提升性能", + "v2.7": "动态删除种子规则调整(请注意查阅插件文档),站点独立配置样式优化、日志优化,修复部分配置项无法配置小数的问题,修复部分场景可能导致重复下载的问题", "v2.6": "修复排除订阅功能", "v2.5": "增加H&R做种时间、下载器监控配置项,刷流前置条件逻辑调整,代理下载种子默认为关闭" } @@ -382,13 +386,12 @@ "RemoveLink": { "name": "清理硬链接", "description": "监控目录内文件被删除时,同步删除监控目录内所有和它硬链接的文件", - "version": "1.8", + "version": "1.6", "icon": "Ombi_A.png", "author": "DzAvril", "level": 1, "history": { - "v1.8": "修复空目录删除逻辑", - "v1.7": "增加定时清理空目录功能" + "v1.6": "提升插件性能" } }, "LinkMonitor": { @@ -513,5 +516,13 @@ "icon": "Ntfy_A.png", "author": "lethargicScribe", "level": 1 + }, + "PluginAutoUpgrade": { + "name": "插件自动升级", + "description": "定时检测、升级插件。", + "version": "1.0", + "icon": "PluginAutoUpgrade.png", + "author": "hotlcc", + "level": 1 } } diff --git a/plugins/brushflow/__init__.py b/plugins/brushflow/__init__.py index decb634..0be7424 100644 --- a/plugins/brushflow/__init__.py +++ b/plugins/brushflow/__init__.py @@ -85,7 +85,11 @@ class BrushConfig: def __initialize_site_config(self): if not self.site_config: + logger.error(f"没有设置站点配置,已关闭站点独立配置并恢复默认配置示例,请检查配置项") + self.site_config = self.__get_demo_site_config() self.group_site_configs = {} + self.enable_site_config = False + return # 定义允许覆盖的字段列表 allowed_fields = { @@ -132,6 +136,47 @@ class BrushConfig: self.enable_site_config = False self.enabled = False + @staticmethod + def __get_demo_site_config() -> str: + desc = ("//以下为配置示例,请参考 " + "https://github.com/InfinityPacer/MoviePilot-Plugins/blob/main/README.md " + "进行配置,请注意,只需要保留实际配置内容(删除这段)\n") + config = """[{ + "sitename": "站点1", + "seed_time": 96, + "hr_seed_time": 144 +}, { + "sitename": "站点2", + "hr": "yes", + "size": "10-500", + "seeder": "5-10", + "pubtime": "5-120", + "seed_time": 96, + "save_path": "/downloads/site2", + "proxy_download": true, + "hr_seed_time": 144 +}, { + "sitename": "站点3", + "freeleech": "free", + "hr": "yes", + "include": "", + "exclude": "", + "size": "10-500", + "seeder": "1", + "pubtime": "5-120", + "seed_time": 120, + "hr_seed_time": 144, + "seed_ratio": "", + "seed_size": "", + "download_time": "", + "seed_avgspeed": "", + "seed_inactivetime": "", + "save_path": "/downloads/site1", + "proxy_download": false, + "proxy_delete": false +}]""" + return desc + config + def get_site_config(self, sitename): """ 根据站点名称获取特定的BrushConfig实例。如果没有找到站点特定的配置,则返回全局的BrushConfig实例。 @@ -194,7 +239,7 @@ class BrushFlow(_PluginBase): # 插件图标 plugin_icon = "brush.jpg" # 插件版本 - plugin_version = "2.7" + plugin_version = "2.8" # 插件作者 plugin_author = "jxxghp,InfinityPacer" # 作者主页 @@ -241,10 +286,6 @@ class BrushFlow(_PluginBase): logger.info("站点刷流任务出错,无法获取插件配置") return False - # 如果没有站点配置时,增加默认的配置项 - if not config.get("site_config"): - config["site_config"] = self.__get_demo_site_config() - # 如果配置校验没有通过,那么这里修改配置文件后退出 if not self.__validate_and_fix_config(config=config): self._brush_config = BrushConfig(config=config) @@ -956,12 +997,6 @@ class BrushFlow(_PluginBase): } ] }, - { - 'component': 'VRow', - 'content': [ - - ] - }, { 'component': 'VRow', 'content': [ @@ -1163,7 +1198,7 @@ class BrushFlow(_PluginBase): "component": "VDialog", "props": { "model": "dialog_closed", - "max-width": "50rem", + "max-width": "65rem", "overlay-class": "v-dialog--scrollable v-overlay--scroll-blocked", "content-class": "v-card v-card--density-default v-card--variant-elevated rounded-t" }, @@ -1684,12 +1719,14 @@ class BrushFlow(_PluginBase): size_condition_passed, reason = self.__evaluate_size_condition_for_brush(torrents_size=torrents_size) self.__log_brush_conditions(passed=size_condition_passed, reason=reason) if not size_condition_passed: + logger.info(f"刷流任务执行完成") return # 判断能否通过刷流前置条件 pre_condition_passed, reason = self.__evaluate_pre_conditions_for_brush() self.__log_brush_conditions(passed=pre_condition_passed, reason=reason) if not pre_condition_passed: + logger.info(f"刷流任务执行完成") return statistic_info = self.__get_statistic_info() @@ -1760,7 +1797,7 @@ class BrushFlow(_PluginBase): for torrent in torrents: # 判断能否通过刷流前置条件 pre_condition_passed, reason = self.__evaluate_pre_conditions_for_brush(include_network_conditions=False) - self.__log_brush_conditions(passed=pre_condition_passed, reason=reason, torrent=torrent) + self.__log_brush_conditions(passed=pre_condition_passed, reason=reason) if not pre_condition_passed: return False @@ -1912,10 +1949,19 @@ class BrushFlow(_PluginBase): """ brush_config = self.__get_brush_config(torrent.site_name) + # 排除重复种子 + # 默认根据标题和站点名称进行排除 task_key = f"{torrent.site_name}{torrent.title}" if any(task_key == f"{task.get('site_name')}{task.get('title')}" for task in torrent_tasks.values()): return False, "重复种子" + # 部分站点标题会上新时携带后缀,这里进一步根据种子详情地址进行排除 + if torrent.page_url: + task_page_url = f"{torrent.site_name}{torrent.page_url}" + if any(task_page_url == f"{task.get('site_name')}{task.get('page_url')}" for task in + torrent_tasks.values()): + return False, "重复种子" + # 促销条件 if brush_config.freeleech and torrent.downloadvolumefactor != 0: return False, "非免费种子" @@ -1948,7 +1994,7 @@ class BrushFlow(_PluginBase): # 做种人数 if brush_config.seeder: - seeders_range = [int(n) for n in brush_config.seeder.split("-")] + seeders_range = [float(n) for n in brush_config.seeder.split("-")] # 检查是否仅指定了一个数字,即做种人数需要小于等于该数字 if len(seeders_range) == 1: # 当做种人数大于该数字时,不符合条件 @@ -1964,7 +2010,7 @@ class BrushFlow(_PluginBase): pubdate_minutes = self.__get_pubminutes(torrent.pubdate) pubdate_minutes = self.__adjust_site_pubminutes(pubdate_minutes, torrent) if brush_config.pubtime: - pubtimes = [int(n) for n in brush_config.pubtime.split("-")] + pubtimes = [float(n) for n in brush_config.pubtime.split("-")] if len(pubtimes) == 1: # 单个值:选择发布时间小于等于该值的种子 if pubdate_minutes > pubtimes[0]: @@ -1982,7 +2028,7 @@ class BrushFlow(_PluginBase): """ if not passed: if not torrent: - logger.warn(f"种子没有通过前置刷流条件校验,原因:{reason}") + logger.warn(f"没有通过前置刷流条件校验,原因:{reason}") else: brush_config = self.__get_brush_config() if brush_config.log_more: @@ -2049,7 +2095,7 @@ class BrushFlow(_PluginBase): need_delete_hashes = [] - # 如果配置了删种阈值,则根据动态删种进行分组处理 + # 如果配置了动态删除以及删种阈值,则根据动态删种进行分组处理 if brush_config.proxy_delete and brush_config.delete_size_range: logger.info("已开启动态删种,按系统默认动态删种条件开始检查任务") proxy_delete_hashs = self.__delete_torrent_for_proxy(torrents=check_torrents, @@ -2237,6 +2283,22 @@ class BrushFlow(_PluginBase): return True, reason if not hit_and_run else "H&R种子(未设置H&R条件)," + reason + def __evaluate_proxy_pre_conditions_for_delete(self, site_name: str, torrent_info: dict) -> Tuple[bool, str]: + """ + 评估动态删除前置条件并返回是否应删除种子及其原因 + """ + brush_config = self.__get_brush_config(sitename=site_name) + + reason = "未能满足动态删除设置的前置删除条件" + + if brush_config.download_time and torrent_info.get("downloaded") < torrent_info.get( + "total_size") and torrent_info.get("dltime") >= float(brush_config.download_time) * 3600: + reason = f"下载耗时 {torrent_info.get('dltime') / 3600:.1f} 小时,大于 {brush_config.download_time} 小时" + else: + return False, reason + + return True, reason + def __delete_torrent_for_evaluate_conditions(self, torrents: List[Any], torrent_tasks: Dict[str, dict], proxy_delete: bool = False) -> List: """ @@ -2273,11 +2335,54 @@ class BrushFlow(_PluginBase): return delete_hashs + def __delete_torrent_for_evaluate_proxy_pre_conditions(self, torrents: List[Any], + torrent_tasks: Dict[str, dict]) -> List: + """ + 根据动态删除前置条件排除H&R种子后删除种子并获取已删除列表 + """ + brush_config = self.__get_brush_config() + delete_hashs = [] + + for torrent in torrents: + torrent_hash = self.__get_hash(torrent) + torrent_task = torrent_tasks.get(torrent_hash, None) + # 如果找不到种子任务,说明不在管理的种子范围内,直接跳过 + if not torrent_task: + continue + + # 如果是H&R种子,前置条件中不进行处理 + if torrent_task.get('hit_and_run', False): + continue + + site_name = torrent_task.get("site_name", "") + torrent_title = torrent_task.get("title", "") + torrent_desc = torrent_task.get("description", "") + + torrent_info = self.__get_torrent_info(torrent) + + # 删除种子的具体实现可能会根据实际情况略有不同 + should_delete, reason = self.__evaluate_proxy_pre_conditions_for_delete(site_name=site_name, + torrent_info=torrent_info) + if should_delete: + delete_hashs.append(torrent_hash) + self.__send_delete_message(site_name=site_name, torrent_title=torrent_title, torrent_desc=torrent_desc, + reason=reason) + logger.info(f"站点:{site_name},{reason},删除种子:{torrent_title}|{torrent_desc}") + else: + if brush_config.log_more: + logger.info(f"站点:{site_name},{reason},不删除种子:{torrent_title}|{torrent_desc}") + + return delete_hashs + def __delete_torrent_for_proxy(self, torrents: List[Any], torrent_tasks: Dict[str, dict]) -> List: """ - 支持动态删除种子,当设置了动态删种(全局)和删除阈值时,当做种体积达到删除阈值时,优先按设置规则进行删除,若还没有达到阈值,则排除HR种子后按加入时间倒序进行删除 - 删除阈值:100,当做种体积 > 100G 时,则开始删除种子,直至降低至 100G - 删除阈值:50-100,当做种体积 > 100G 时,则开始删除种子,直至降至为 50G + 动态删除种子,删除规则如下; + - 不管做种体积是否超过设定的动态删除阈值,默认优先执行排除H&R种子后满足「下载超时时间」的种子 + - 上述规则执行完成后,当做种体积依旧超过设定的动态删除阈值时,继续执行下述种子删除规则 + - 优先删除满足用户设置删除规则的全部种子,即便在删除过程中已经低于了阈值下限,也会继续删除 + - 若删除后还没有达到阈值,则在已完成种子中排除H&R种子后按做种时间倒序进行删除 + - 动态删除阈值:100,当做种体积 > 100G 时,则开始删除种子,直至降低至 100G + - 动态删除阈值:50-100,当做种体积 > 100G 时,则开始删除种子,直至降至为 50G """ brush_config = self.__get_brush_config() @@ -2285,29 +2390,49 @@ class BrushFlow(_PluginBase): if not (brush_config.proxy_delete and brush_config.delete_size_range): return [] + # 获取种子信息Map + torrent_info_map = {self.__get_hash(torrent): self.__get_torrent_info(torrent=torrent) for torrent in torrents} + + # 计算当前总做种体积 + total_torrent_size = self.__calculate_seeding_torrents_size(torrent_tasks=torrent_tasks) + + logger.info( + f"当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB,正在准备计算满足动态前置删除条件的种子") + + # 执行排除H&R种子后满足前置删除条件的种子 + pre_delete_hashes = self.__delete_torrent_for_evaluate_proxy_pre_conditions(torrents=torrents, + torrent_tasks=torrent_tasks) or [] + + # 如果存在前置删除种子,这里进行额外判断,总做种体积排除前置删除种子的体积 + if pre_delete_hashes: + pre_delete_total_size = sum(torrent_info_map[self.__get_hash(torrent)].get("total_size", 0) + for torrent in torrents if self.__get_hash(torrent) in pre_delete_hashes) + total_torrent_size = total_torrent_size - pre_delete_total_size + torrents = [torrent for torrent in torrents if self.__get_hash(torrent) not in pre_delete_hashes] + logger.info( + f"满足动态删除前置条件的种子共 {len(pre_delete_hashes)} 个,体积 {self.__bytes_to_gb(pre_delete_total_size):.1f} GB," + f"删除种子后,当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB") + else: + logger.info(f"没有找到任何满足动态删除前置条件的种子") + # 解析删除阈值范围 sizes = [float(size) * 1024 ** 3 for size in brush_config.delete_size_range.split("-")] min_size = sizes[0] # 至少需要达到的做种体积 max_size = sizes[1] if len(sizes) > 1 else sizes[0] # 触发删除操作的做种体积上限 - torrent_info_map = {self.__get_hash(torrent): self.__get_torrent_info(torrent=torrent) for torrent in torrents} - - # 计算当前总做种体积 - # total_torrent_size = sum(info.get("total_size", 0) for info in torrent_info_map.values()) - total_torrent_size = self.__calculate_seeding_torrents_size(torrent_tasks=torrent_tasks) - # 当总体积未超过最大阈值时,不需要执行删除操作 if total_torrent_size < max_size: logger.info( f"当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB,上限 {self.__bytes_to_gb(max_size):.1f} GB," - f"下限 {self.__bytes_to_gb(min_size):.1f} GB,未触发动态删除") - return [] + f"下限 {self.__bytes_to_gb(min_size):.1f} GB,未进一步触发动态删除") + return pre_delete_hashes or [] else: logger.info( f"当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB,上限 {self.__bytes_to_gb(max_size):.1f} GB," - f"下限 {self.__bytes_to_gb(min_size):.1f} GB,触发动态删除") + f"下限 {self.__bytes_to_gb(min_size):.1f} GB,进一步触发动态删除") need_delete_hashes = [] + need_delete_hashes.extend(pre_delete_hashes) # 即使开了动态删除,但是也有可能部分站点单独设置了关闭,这里根据种子托管进行分组,先处理不需要托管的种子,按设置的规则进行删除 proxy_delete_torrents, not_proxy_delete_torrents = self.__group_torrents_by_proxy_delete(torrents=torrents, @@ -2332,7 +2457,6 @@ class BrushFlow(_PluginBase): self.__get_hash(torrent) in proxy_delete_hashes) # 在完成初始删除步骤后,如果总体积仍然超过最小阈值,则进一步找到已完成种子并排除HR种子后按做种时间正序进行删除 - sites_names = set() if total_torrent_size > min_size: # 重新计算当前的种子列表,排除已删除的种子 remaining_hashes = list( @@ -2365,14 +2489,15 @@ class BrushFlow(_PluginBase): torrent_desc = torrent_task.get("description", "") seeding_time = torrent_task.get("seeding_time", 0) if seeding_time: - sites_names.add(site_name) reason = f"触发动态删除,做种时间 {seeding_time / 3600:.1f} 小时,系统自动删除" self.__send_delete_message(site_name=site_name, torrent_title=torrent_title, torrent_desc=torrent_desc, reason=reason) logger.info(f"站点:{site_name},{reason},删除种子:{torrent_title}|{torrent_desc}") - msg = (f"站点:{','.join(sites_names)}\n内容:已完成 {len(need_delete_hashes)} 个种子删除," + delete_sites = {torrent_tasks[hash_key].get('site_name', '') for hash_key in need_delete_hashes if + hash_key in torrent_tasks} + msg = (f"站点:{','.join(delete_sites)}\n内容:已完成 {len(need_delete_hashes)} 个种子删除," f"当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB\n原因:触发动态删除") logger.info(msg) self.__send_message(title="【刷流任务状态更新】", text=msg) @@ -2754,7 +2879,7 @@ class BrushFlow(_PluginBase): if response and response.ok: torrent_content = response.content else: - logger.error('代理下载种子失败,继续尝试传递种子地址到下载器进行下载') + logger.error('尝试通过MP下载种子失败,继续尝试传递种子地址到下载器进行下载') if torrent_content: state = self.qb.add_torrent(content=torrent_content, download_dir=download_dir, @@ -2769,7 +2894,7 @@ class BrushFlow(_PluginBase): torrent_hash = self.qb.get_torrent_id_by_tag(tags=tag) if not torrent_hash: logger.error(f"{brush_config.downloader} 获取种子Hash失败" - f"{',请尝试开启代理下载种子' if not brush_config.proxy_download else ''}") + f"{',请尝试启用「代理下载种子」配置项' if not brush_config.proxy_download else ''}") return None return torrent_hash return None @@ -2785,7 +2910,7 @@ class BrushFlow(_PluginBase): if response and response.ok: torrent_content = response.content else: - logger.error('代理下载种子失败,继续尝试传递种子地址到下载器进行下载') + logger.error('尝试通过MP下载种子失败,继续尝试传递种子地址到下载器进行下载') if torrent_content: torrent = self.tr.add_torrent(content=torrent_content, download_dir=download_dir, @@ -3301,9 +3426,9 @@ class BrushFlow(_PluginBase): @staticmethod def __is_number_or_range(value): """ - 检查字符串是否表示单个数字或数字范围(如'5'或'5-10') + 检查字符串是否表示单个数字或数字范围(如'5', '5.5', '5-10' 或 '5.5-10.2') """ - return bool(re.match(r"^\d+(-\d+)?$", value)) + return bool(re.match(r"^\d+(\.\d+)?(-\d+(\.\d+)?)?$", value)) @staticmethod def __is_number(value): @@ -3379,46 +3504,6 @@ class BrushFlow(_PluginBase): } return statistic_info - @staticmethod - def __get_demo_site_config() -> str: - desc = ("// 以下为配置示例,请参考:https://github.com/InfinityPacer/MoviePilot-Plugins/blob/main/README.md 进行配置\n" - "// 无关内容请使用 // 进行注释\n") - config = """[{ - "sitename": "站点1", - "seed_time": 96, - "hr_seed_time": 144 - }, { - "sitename": "站点2", - "hr": "yes", - "size": "10-500", - "seeder": "5-10", - "pubtime": "5-120", - "seed_time": 96, - "save_path": "/downloads/site2", - "proxy_download": true, - "hr_seed_time": 144 - }, { - "sitename": "站点3", - "freeleech": "free", - "hr": "yes", - "include": "", - "exclude": "", - "size": "10-500", - "seeder": "1", - "pubtime": "5-120", - "seed_time": 120, - "hr_seed_time": 144, - "seed_ratio": "", - "seed_size": "", - "download_time": "", - "seed_avgspeed": "", - "seed_inactivetime": "", - "save_path": "/downloads/site1", - "proxy_download": false, - "proxy_delete": false, - }]""" - return desc + config - @staticmethod def __is_valid_time_range(time_range: str) -> bool: """检查时间范围字符串是否有效:格式为"HH:MM-HH:MM",且时间有效""" diff --git a/plugins/crossseed/__init__.py b/plugins/crossseed/__init__.py index b16e3e6..c33fb51 100644 --- a/plugins/crossseed/__init__.py +++ b/plugins/crossseed/__init__.py @@ -177,11 +177,11 @@ class CrossSeed(_PluginBase): # 插件图标 plugin_icon = "qingwa.png" # 插件版本 - plugin_version = "2.1" + plugin_version = "2.2" # 插件作者 plugin_author = "233@qingwa" # 作者主页 - author_url = "https://new.qingwa.pro/" + author_url = "https://qingwapt.com/" # 插件配置项ID前缀 plugin_config_prefix = "cross_seed_" # 加载顺序 @@ -955,6 +955,11 @@ class CrossSeed(_PluginBase): # 逐个站点查询可辅种数据 chunk_size = 100 for site_config in self._site_cs_infos: + # 检查站点是否已经停用 + db_site = self.siteoper.get(site_config.id) + if db_site and not db_site.is_active: + logger.info(f"站点{site_config.name}已停用,跳过辅种") + continue remote_tors: List[TorInfo] = [] total_size = len(pieces_hashes) for i in range(0, len(pieces_hashes), chunk_size): diff --git a/plugins/pluginautoupgrade/__init__.py b/plugins/pluginautoupgrade/__init__.py new file mode 100644 index 0000000..c76cfcd --- /dev/null +++ b/plugins/pluginautoupgrade/__init__.py @@ -0,0 +1,469 @@ +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 diff --git a/plugins/removelink/__init__.py b/plugins/removelink/__init__.py index f741380..2bc66f0 100644 --- a/plugins/removelink/__init__.py +++ b/plugins/removelink/__init__.py @@ -11,7 +11,6 @@ from watchdog.observers import Observer from app.log import logger from app.plugins import _PluginBase from app.schemas import Notification, NotificationType -from app.utils.timer import TimerUtils state_lock = threading.Lock() @@ -91,7 +90,7 @@ class RemoveLink(_PluginBase): # 插件图标 plugin_icon = "Ombi_A.png" # 插件版本 - plugin_version = "1.8" + plugin_version = "1.6" # 插件作者 plugin_author = "DzAvril" # 作者主页 @@ -175,40 +174,6 @@ class RemoveLink(_PluginBase): def get_api(self) -> List[Dict[str, Any]]: pass - def get_service(self) -> List[Dict[str, Any]]: - """ - 注册插件公共服务 - [{ - "id": "服务ID", - "name": "服务名称", - "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()", - "func": self.xxx, - "kwargs": {} # 定时器参数 - }] - """ - if self._enabled: - # 随机时间 - triggers = TimerUtils.random_scheduler( - num_executions=1, - begin_hour=0, - end_hour=1, - min_interval=1, - max_interval=60, - ) - ret_jobs = [] - for trigger in triggers: - ret_jobs.append( - { - "id": f"RemoveLink|{trigger.hour}:{trigger.minute}", - "name": "清理空文件夹", - "trigger": "cron", - "func": self.delete_empty_folders, - "kwargs": {"hour": trigger.hour, "minute": trigger.minute}, - } - ) - return ret_jobs - return [] - def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: return [ { @@ -308,26 +273,26 @@ 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': '监控目录如有多个需换行,源目录和硬链接目录都需要添加到监控目录中;如需实现删除硬链接时不删除源文件,可把源文件目录配置到不删除目录中。' + } } - ], + ] } - ], - }, + ] + } ], } ], { @@ -364,25 +329,6 @@ class RemoveLink(_PluginBase): return True return False - def delete_empty_folders(self): - """ - 删除空目录 - """ - for mon_path in self.monitor_dirs.split("\n"): - for subdir, dirs, files in os.walk(mon_path, topdown=False): - for dir in dirs: - dir_path = os.path.join(subdir, dir) - # 检查当前目录是否为空 - if not os.listdir(dir_path) and not self.__is_excluded(dir_path): - os.rmdir(dir_path) - logger.info(f"删除空目录:{dir_path}") - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【清理硬链接】", - text=f"清理空文件夹:[{dir_path}]\n", - ) - def handle_deleted(self, file_path: Path): """ 处理删除事件 @@ -412,10 +358,7 @@ class RemoveLink(_PluginBase): 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()))