diff --git a/README.md b/README.md index 58e1dc2..889a95f 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ MoviePilot官方插件市场:https://github.com/jxxghp/MoviePilot-Plugins - 可在插件目录中放置`requirement.txt`文件,用于指定插件依赖的第三方库,MoviePilot会在插件安装时自动安装依赖库。 ### 5. 界面开发 -- 插件支持`插件配置`及`详情展示`两个展示页面,通过配置化的方式组装,使用[Vuetify](https://vuetifyjs.com/)组件库,所有该组件库有的组件都可以通过Json配置使用。 +- 插件支持`插件配置`及`详情展示`两个展示页面,通过配置化的方式组装,使用 [Vuetify](https://vuetifyjs.com/) 组件库,所有该组件库有的组件都可以通过Json配置使用。 ## 常见问题 @@ -48,34 +48,38 @@ MoviePilot官方插件市场:https://github.com/jxxghp/MoviePilot-Plugins PS:MoviePilot中的其它事件也是同样方法实现响应: ```python class EventType(Enum): - # 插件重载 + # 插件需要重载 PluginReload = "plugin.reload" # 插件动作 PluginAction = "plugin.action" # 执行命令 CommandExcute = "command.excute" - # 站点删除 + # 站点已删除 SiteDeleted = "site.deleted" - # Webhook消息 - WebhookMessage = "webhook.message" + # 站点已更新 + SiteUpdated = "site.updated" # 转移完成 TransferComplete = "transfer.complete" - # 添加下载 + # 下载已添加 DownloadAdded = "download.added" # 删除历史记录 HistoryDeleted = "history.deleted" # 删除下载源文件 DownloadFileDeleted = "downloadfile.deleted" - # 用户外来消息 + # 收到用户外来消息 UserMessage = "user.message" - # 通知消息 + # 收到Webhook消息 + WebhookMessage = "webhook.message" + # 发送消息通知 NoticeMessage = "notice.message" # 名称识别请求 NameRecognize = "name.recognize" # 名称识别结果 NameRecognizeResult = "name.recognize.result" - # 站点信息更新 - SiteUpdated = "site.updated" + # 订阅已添加 + SubscribeAdded = "subscribe.added" + # 订阅已完成 + SubscribeComplete = "subscribe.complete" ``` ### 2. 如何在插件中实现远程命令响应? diff --git a/icons/FeiShu_A.png b/icons/FeiShu_A.png new file mode 100644 index 0000000..c0b0446 Binary files /dev/null and b/icons/FeiShu_A.png differ diff --git a/package.json b/package.json index e5329e7..b65b458 100644 --- a/package.json +++ b/package.json @@ -185,8 +185,8 @@ }, "CrossSeed": { "name": "青蛙辅种助手", - "description": "参考ReseedPuppy和IYUU辅种插件实现自动辅种,支持站点:青蛙、AGSVPT、麒麟、UBits、聆音等。", - "version": "1.8", + "description": "参考ReseedPuppy和IYUU辅种插件实现自动辅种,支持站点:青蛙、AGSVPT、麒麟、UBits、聆音、憨憨等。", + "version": "2.0", "icon": "qingwa.png", "author": "233@qingwa", "level": 2 @@ -195,7 +195,7 @@ "name": "整理VCB动漫压制组作品", "description": "提高部分VCB-Studio作品的识别准确率,将VCB-Studio的作品统一转移到指定目录同时进行刮削整理", "version": "1.6.6", - "icon": "qingwa.png", + "icon": "vcbmonitor.png", "author": "pixel@qingwa", "level": 2 }, @@ -226,7 +226,7 @@ "BrushFlow": { "name": "站点刷流", "description": "自动托管刷流,将会提高对应站点的访问频率。", - "version": "2.0", + "version": "2.2", "icon": "brush.jpg", "author": "jxxghp", "level": 2 @@ -450,7 +450,7 @@ "ContractCheck": { "name": "契约检查", "description": "定时检查保种契约达成情况。", - "version": "1.0", + "version": "1.1", "icon": "contract.png", "author": "DzAvril", "level": 1 @@ -458,9 +458,17 @@ "DownloaderHelper": { "name": "下载器助手", "description": "自动做种、站点标签、自动删种。", - "version": "1.1", + "version": "1.4", "icon": "DownloaderHelper.png", "author": "hotlcc", "level": 2 + }, + "FeiShuMsg": { + "name": "飞书机器人消息通知", + "description": "支持使用飞书群聊机器人发送消息通知。", + "version": "1.0", + "icon": "FeiShu_A.png", + "author": "InfinityPacer", + "level": 2 } } diff --git a/plugins/brushflow/__init__.py b/plugins/brushflow/__init__.py index 6b1cc12..c9d3d24 100644 --- a/plugins/brushflow/__init__.py +++ b/plugins/brushflow/__init__.py @@ -1,16 +1,14 @@ +import base64 +import json +import random import re import threading import time -import random -import json -import base64 from datetime import datetime, timedelta from threading import Event from typing import Any, List, Dict, Tuple, Optional, Union, Set import pytz -from apscheduler.schedulers.background import BackgroundScheduler - from app import schemas from app.chain.torrents import TorrentsChain from app.core.config import settings @@ -24,6 +22,7 @@ from app.plugins import _PluginBase from app.schemas import NotificationType, TorrentInfo from app.utils.http import RequestUtils from app.utils.string import StringUtils +from apscheduler.schedulers.background import BackgroundScheduler lock = threading.Lock() @@ -67,6 +66,7 @@ class BrushConfig: self.brush_sequential = config.get("brush_sequential", False) self.proxy_download = config.get("proxy_download", True) self.proxy_delete = config.get("proxy_delete", False) + self.log_more = config.get("log_more", False) self.active_time_range = config.get("active_time_range") self.enable_site_config = config.get("enable_site_config", False) self.brush_tag = "刷流" @@ -117,8 +117,10 @@ class BrushConfig: self.group_site_configs[sitename] = BrushConfig(config=full_config, process_site_config=False) except Exception as e: - logger.error(f"解析站点配置失败,请检查配置项。错误详情: {e}") + logger.error(f"解析站点配置失败,已停用插件并关闭站点独立配置,请检查配置项,错误详情: {e}") self.group_site_configs = {} + self.enable_site_config = False + self.enabled = False def get_site_config(self, sitename): """ @@ -182,7 +184,7 @@ class BrushFlow(_PluginBase): # 插件图标 plugin_icon = "brush.jpg" # 插件版本 - plugin_version = "2.0" + plugin_version = "2.2" # 插件作者 plugin_author = "jxxghp,InfinityPacer" # 作者主页 @@ -262,6 +264,12 @@ class BrushFlow(_PluginBase): brush_config.archive_task = False self.__update_config() + if brush_config.log_more: + if brush_config.enable_site_config: + logger.info(f"已开启站点独立配置,配置信息:{brush_config}") + else: + logger.info(f"没有开启站点独立配置,配置信息:{brush_config}") + # 停止现有任务 self.stop_service() @@ -282,7 +290,7 @@ class BrushFlow(_PluginBase): # 如果开启&存在站点时,才需要启用后台任务 self._task_brush_enable = brush_config.enabled and brush_config.brushsites - # brush_config.onlyonce = True + # brush_config.onlyonce = True # 检查是否启用了一次性任务 if brush_config.onlyonce: @@ -1030,6 +1038,27 @@ class BrushFlow(_PluginBase): } ] }, + { + 'component': 'VRow', + "content": [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'log_more', + 'label': '记录更多日志', + } + } + ] + } + ] + }, { 'component': 'VRow', 'content': [ @@ -1156,7 +1185,8 @@ class BrushFlow(_PluginBase): "proxy_delete": False, "freeleech": "free", "hr": "yes", - "enable_site_config": False + "enable_site_config": False, + "log_more": False } def get_page(self) -> List[dict]: @@ -1658,8 +1688,15 @@ class BrushFlow(_PluginBase): torrent_tasks: Dict[str, dict] = self.get_data("torrents") or {} torrents_size = self.__calculate_seeding_torrents_size(torrent_tasks=torrent_tasks) + # 判断能否通过保种体积前置条件 + 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: + return + # 判断能否通过刷流前置条件 - pre_condition_passed, reason = self.__evaluate_pre_conditions_for_brush(torrents_size=torrents_size) + 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: return @@ -1695,7 +1732,9 @@ class BrushFlow(_PluginBase): logger.info(f"刷流任务执行完成") def __brush_site_torrents(self, siteid, torrent_tasks, statistic_info) -> bool: - + """ + 针对站点进行刷流 + """ siteinfo = self.siteoper.get(siteid) if not siteinfo: logger.warn(f"站点不存在:{siteid}") @@ -1723,23 +1762,24 @@ class BrushFlow(_PluginBase): # 过滤种子 for torrent in torrents: # 判断能否通过刷流前置条件 - seeding_size = torrents_size + torrent.size - pre_condition_passed, reason = self.__evaluate_pre_conditions_for_brush(torrents_size=seeding_size, - include_network_conditions=False) + 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) if not pre_condition_passed: - # logger.info(f"种子没有通过刷流前置条件校验,原因:{reason} 种子:{torrent.title}|{torrent.description}") return False - # else: - # logger.info(f"种子已通过刷流前置校验,种子:{torrent.title}|{torrent.description}") + + # 判断能否通过保种体积刷流条件 + size_condition_passed, reason = self.__evaluate_size_condition_for_brush(torrents_size=torrents_size, + brush_torrent_size=torrent.size) + self.__log_brush_conditions(passed=size_condition_passed, reason=reason, torrent=torrent) + if not size_condition_passed: + continue # 判断能否通过刷流条件 condition_passed, reason = self.__evaluate_conditions_for_brush(torrent=torrent, torrent_tasks=torrent_tasks) + self.__log_brush_conditions(passed=condition_passed, reason=reason, torrent=torrent) if not condition_passed: - # logger.info(f"种子没有通过刷流条件校验,原因:{reason} 种子:{torrent.title}|{torrent.description}") continue - # else: - # logger.info(f"种子已通过刷流条件校验,种子:{torrent.title}|{torrent.description}") # 添加下载任务 hash_string = self.__download(torrent=torrent) @@ -1790,14 +1830,43 @@ class BrushFlow(_PluginBase): return True - def __evaluate_pre_conditions_for_brush(self, torrents_size: float, - include_network_conditions: bool = True) -> Tuple[bool, Optional[str]]: + def __evaluate_size_condition_for_brush(self, torrents_size: float, + brush_torrent_size: float = 0.0) -> Tuple[bool, Optional[str]]: + """ + 过滤体积不符合条件的种子 + """ + total_size = self.__bytes_to_gb(torrents_size + brush_torrent_size) # 预计总做种体积 + + def generate_message(config): + if brush_torrent_size > 0: + return (f"当前做种体积 {self.__bytes_to_gb(torrents_size):.1f} GB," + f"刷流种子 {self.__bytes_to_gb(brush_torrent_size):.1f} GB," + f"预计做种体积 {total_size:.1f} GB,已超过做种体积 {config} GB") + else: + return f"当前做种体积 {self.__bytes_to_gb(torrents_size):.1f} GB,已超过做种体积 {config} GB,暂时停止新增任务" + + reasons = [ + ("disksize", + lambda config: torrents_size + brush_torrent_size > float(config) * 1024 ** 3, generate_message) + ] + + brush_config = self.__get_brush_config() + for condition, check, message in reasons: + config_value = getattr(brush_config, condition, None) + if config_value and check(config_value): + reason = message(config_value) + return False, reason + + return True, None + + def __evaluate_pre_conditions_for_brush(self, include_network_conditions: bool = True) \ + -> Tuple[bool, Optional[str]]: + """ + 前置过滤不符合条件的种子 + """ reasons = [ ("maxdlcount", lambda config: self.__get_downloading_count() >= int(config), - lambda config: f"当前同时下载任务数已达到最大值 {config},暂时停止新增任务"), - ("disksize", lambda config: torrents_size > float(config) * 1024 ** 3, - lambda config: f"当前做种体积 {self.__bytes_to_gb(torrents_size):.1f} GB," - f"已超过保种体积 {config} GB,暂时停止新增任务"), + lambda config: f"当前同时下载任务数已达到最大值 {config},暂时停止新增任务") ] if include_network_conditions: @@ -1819,7 +1888,6 @@ class BrushFlow(_PluginBase): config_value = getattr(brush_config, condition, None) if config_value and check(config_value): reason = message(config_value) - logger.warn(reason) return False, reason return True, None @@ -1894,6 +1962,18 @@ class BrushFlow(_PluginBase): return True, None + def __log_brush_conditions(self, passed: bool, reason: str, torrent: Any = None): + """ + 记录刷流日志 + """ + if not passed: + if not torrent: + logger.warn(f"种子没有通过前置刷流条件校验,原因:{reason}") + else: + brush_config = self.__get_brush_config() + if brush_config.log_more: + logger.warn(f"种子没有通过刷流条件校验,原因:{reason} 种子:{torrent.title}|{torrent.description}") + # endregion # region Check @@ -1944,25 +2024,27 @@ class BrushFlow(_PluginBase): # 先更新刷流任务的最新状态,上下传,分享率 self.__update_torrent_tasks_state(torrents=check_torrents, torrent_tasks=torrent_tasks) - # 先通过获取的全量种子,判断已经被删除,但是任务记录中还没有被标记删除的种子 - undeleted_hashes = self.__get_undeleted_torrents_missing_in_downloader(torrent_tasks, torrent_check_hashes, - check_torrents) or [] - # 排除MoviePilot种子 if check_torrents and brush_config.except_tags: check_torrents = self.__filter_torrents_by_tag(torrents=check_torrents, exclude_tag=settings.TORRENT_TAG) - need_delete_hashes = [] - need_delete_hashes.extend(undeleted_hashes) + # 先通过获取的全量种子,判断已经被删除,但是任务记录中还没有被标记删除的种子 + undeleted_hashes = self.__get_undeleted_torrents_missing_in_downloader(torrent_tasks, torrent_check_hashes, + check_torrents) or [] + # 这里提前把已经被删除的种子进行标记,避免开启动态删除种子统计体积有时差 + if undeleted_hashes: + for torrent_hash in undeleted_hashes: + torrent_tasks[torrent_hash]["deleted"] = True + 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, torrent_tasks=torrent_tasks) or [] need_delete_hashes.extend(proxy_delete_hashs) - # 否则均认为是没有开启动态删种 + # 否则均认为是没有开启动态删种 else: logger.info("没有开启动态删种,按用户设置删种条件开始检查任务") not_proxy_delete_hashs = self.__delete_torrent_for_evaluate_conditions(torrents=check_torrents, @@ -2138,9 +2220,9 @@ class BrushFlow(_PluginBase): def __delete_torrent_for_proxy(self, torrents: List[Any], torrent_tasks: Dict[str, dict]) -> List: """ - 支持动态删除种子,当设置了动态删种(全局)和删除阈值时,当保种体积达到删除阈值时,优先按设置规则进行删除,若还没有达到阈值,则排除HR种子后按加入时间倒序进行删除 - 删除阈值:100,当保种体积 > 100G 时,则开始删除种子,直至降低至 100G - 删除阈值:50-100,当保种体积 > 100G 时,则开始删除种子,直至降至为 50G + 支持动态删除种子,当设置了动态删种(全局)和删除阈值时,当做种体积达到删除阈值时,优先按设置规则进行删除,若还没有达到阈值,则排除HR种子后按加入时间倒序进行删除 + 删除阈值:100,当做种体积 > 100G 时,则开始删除种子,直至降低至 100G + 删除阈值:50-100,当做种体积 > 100G 时,则开始删除种子,直至降至为 50G """ brush_config = self.__get_brush_config() @@ -2150,26 +2232,27 @@ class BrushFlow(_PluginBase): # 解析删除阈值范围 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] # 触发删除操作的保种体积上限 + 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 = 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,下限 {self.__bytes_to_gb(min_size):.1f} GB,未触发动态删除") + f"当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB,上限 {self.__bytes_to_gb(max_size):.1f} GB,下限 {self.__bytes_to_gb(min_size):.1f} GB,未触发动态删除") return [] else: logger.info( - f"当前保种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB,上限 {self.__bytes_to_gb(max_size):.1f} GB,下限 {self.__bytes_to_gb(min_size):.1f} GB,触发动态删除") + f"当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB,上限 {self.__bytes_to_gb(max_size):.1f} GB,下限 {self.__bytes_to_gb(min_size):.1f} GB,触发动态删除") need_delete_hashes = [] - # 即使开了动态删除,但是也有可能部分站点单独设置了关闭,这里根据种子动态进行分组,先处理不需要动态的种子,按设置的规则进行删除 + # 即使开了动态删除,但是也有可能部分站点单独设置了关闭,这里根据种子托管进行分组,先处理不需要托管的种子,按设置的规则进行删除 proxy_delete_torrents, not_proxy_delete_torrents = self.__group_torrents_by_proxy_delete(torrents=torrents, torrent_tasks=torrent_tasks) logger.info(f"托管种子数 {len(proxy_delete_torrents)},未托管种子数 {len(not_proxy_delete_torrents)}") @@ -2181,7 +2264,7 @@ class BrushFlow(_PluginBase): torrent_info_map[self.__get_hash(torrent)].get("total_size", 0) for torrent in not_proxy_delete_torrents if self.__get_hash(torrent) in not_proxy_delete_hashes) - # 如果删除非动态种子后仍未达到最小体积要求,则处理动态种子 + # 如果删除非托管种子后仍未达到最小体积要求,则处理托管种子 if total_torrent_size > min_size and proxy_delete_torrents: proxy_delete_hashes = self.__delete_torrent_for_evaluate_conditions(torrents=proxy_delete_torrents, torrent_tasks=torrent_tasks, @@ -2191,7 +2274,7 @@ class BrushFlow(_PluginBase): torrent_info_map[self.__get_hash(torrent)].get("total_size", 0) for torrent in proxy_delete_torrents if self.__get_hash(torrent) in proxy_delete_hashes) - # 在完成初始删除步骤后,如果总体积仍然超过最小阈值,则进一步找到已完成种子并排除HR种子后按加入时间倒序进行删除 + # 在完成初始删除步骤后,如果总体积仍然超过最小阈值,则进一步找到已完成种子并排除HR种子后按加入时间正序进行删除 if total_torrent_size > min_size: # 重新计算当前的种子列表,排除已删除的种子 remaining_hashes = list( @@ -2227,7 +2310,7 @@ class BrushFlow(_PluginBase): reason=reason) logger.info(f"站点:{site_name},{reason},删除种子:{torrent_title}|{torrent_desc}") - msg = f"已完成 {len(need_delete_hashes)} 个种子删除,当前保种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB" + msg = f"已完成 {len(need_delete_hashes)} 个种子删除,当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB" self.post_message(mtype=NotificationType.SiteMessage, title="【刷流任务种子删除】", text=msg) logger.info(msg) @@ -2453,6 +2536,7 @@ class BrushFlow(_PluginBase): "brush_sequential": brush_config.brush_sequential, "proxy_download": brush_config.proxy_download, "proxy_delete": brush_config.proxy_delete, + "log_more": brush_config.log_more, "active_time_range": brush_config.active_time_range, "enable_site_config": brush_config.enable_site_config, "site_config": brush_config.site_config @@ -2604,7 +2688,8 @@ class BrushFlow(_PluginBase): # 获取种子Hash torrent_hash = self.qb.get_torrent_id_by_tag(tags=tag) if not torrent_hash: - logger.error(f"{brush_config.downloader} 获取种子Hash失败") + logger.error(f"{brush_config.downloader} 获取种子Hash失败" + f"{',请尝试开启代理下载种子' if not brush_config.proxy_download else ''}") return None return torrent_hash return None @@ -2622,7 +2707,7 @@ class BrushFlow(_PluginBase): else: logger.error('代理下载种子失败,继续尝试传递种子地址到下载器进行下载') if torrent_content: - torrent = self.tr.add_torrent(content=torrent.enclosure, + torrent = self.tr.add_torrent(content=torrent_content, download_dir=download_dir, cookie=torrent.site_cookie, labels=["已整理", brush_config.brush_tag]) diff --git a/plugins/contractcheck/__init__.py b/plugins/contractcheck/__init__.py index a768ff5..b651ac0 100644 --- a/plugins/contractcheck/__init__.py +++ b/plugins/contractcheck/__init__.py @@ -39,7 +39,7 @@ class ContractCheck(_PluginBase): # 插件图标 plugin_icon = "contract.png" # 插件版本 - plugin_version = "1.0" + plugin_version = "1.1" # 插件作者 plugin_author = "DzAvril" # 作者主页 @@ -108,11 +108,11 @@ class ContractCheck(_PluginBase): ) self._site_schema.sort(key=lambda x: x.order) - # 站点数据 - self._sites_data = {} # 立即运行一次 if self._onlyonce: + # 站点数据 + self._sites_data = {} # 定时服务 self._scheduler = BackgroundScheduler(timezone=settings.TZ) logger.info(f"保种契约检查服务启动,立即运行一次") diff --git a/plugins/crossseed/__init__.py b/plugins/crossseed/__init__.py index a05945f..fc75269 100644 --- a/plugins/crossseed/__init__.py +++ b/plugins/crossseed/__init__.py @@ -1,6 +1,7 @@ import hashlib import os import re +import time from datetime import datetime, timedelta from pathlib import Path from threading import Event @@ -33,18 +34,33 @@ class CSSiteConfig(object): 站点辅种配置类 """ - def __init__(self, site_name: str, site_url: str, site_passkey: str) -> None: - self.name = site_name - self.url = site_url.removesuffix("/") - self.passkey = site_passkey + def __init__( + self, + name: str = None, + url: str = None, + passkey: str = None, + id: int = None, + cookie: str = None, + ua: str = None, + proxy: bool = None, + query_gap: int = 1, + ) -> None: + self.name = name + self.url = url + self.passkey = passkey + self.id = id + self.cookie = cookie + self.ua = ua + self.proxy = proxy + self.query_gap = query_gap def get_api_url(self): if self.name == "憨憨": - return f"{self.url}/npapi/pieces-hash" - return f"{self.url}/api/pieces-hash" + return f"{self.url}npapi/pieces-hash" + return f"{self.url}api/pieces-hash" def get_torrent_url(self, torrent_id: str): - return f"{self.url}/download.php?id={torrent_id}&passkey={self.passkey}" + return f"{self.url}download.php?id={torrent_id}&passkey={self.passkey}" class TorInfo: @@ -135,21 +151,21 @@ class CrossSeedHelper(object): "User-Agent": "CrossSeedHelper", } data = {"passkey": site.passkey, "pieces_hash": pieces_hash_set} + remote_torrent_infos = [] try: response = requests.post( site.get_api_url(), headers=headers, json=data, timeout=10 ) response.raise_for_status() + rsp_body = response.json() + if isinstance(rsp_body["data"], dict): + for pieces_hash, torrent_id in rsp_body["data"].items(): + remote_torrent_infos.append( + TorInfo.remote(site.name, pieces_hash, torrent_id) + ) + time.sleep(site.query_gap) except requests.exceptions.RequestException as e: return None, f"站点{site.name}请求失败:{e}" - rsp_body = response.json() - - remote_torrent_infos = [] - if isinstance(rsp_body["data"], dict): - for pieces_hash, torrent_id in rsp_body["data"].items(): - remote_torrent_infos.append( - TorInfo.remote(site.name, pieces_hash, torrent_id) - ) return remote_torrent_infos, None @@ -157,11 +173,11 @@ class CrossSeed(_PluginBase): # 插件名称 plugin_name = "青蛙辅种助手" # 插件描述 - plugin_desc = "参考ReseedPuppy和IYUU辅种插件实现自动辅种,支持站点:青蛙、AGSVPT、麒麟、UBits、聆音等。" + plugin_desc = "参考ReseedPuppy和IYUU辅种插件实现自动辅种,支持站点:青蛙、AGSVPT、麒麟、UBits、聆音、憨憨等。" # 插件图标 plugin_icon = "qingwa.png" # 插件版本 - plugin_version = "1.8" + plugin_version = "2.0" # 插件作者 plugin_author = "233@qingwa" # 作者主页 @@ -206,7 +222,6 @@ class CrossSeed(_PluginBase): # 辅种缓存,出错的种子不再重复辅种,且无法清除。种子被删除404等情况 _permanent_error_caches = [] _torrentpaths = [] - _name_site_map = {} _site_cs_infos = [] # 辅种计数 total = 0 @@ -229,7 +244,7 @@ class CrossSeed(_PluginBase): self._downloaders = config.get("downloaders") self._torrentpath = config.get("torrentpath") # 种子路径和下载器对应 /qb,/tr - self._torrentpaths = self._torrentpath.split(",") + self._torrentpaths = self._torrentpath.strip().split(",") self._sites = config.get("sites") or [] self._notify = config.get("notify") self._nolabels = config.get("nolabels") @@ -247,18 +262,61 @@ class CrossSeed(_PluginBase): self._sites = [site_id for site_id, site_name in all_sites if site_id in self._sites] # 拆分出选中的站点 site_names = [site_name for site_id, site_name in all_sites if site_id in self._sites] - # 拆分为映射关系 - self._name_site_map = {} - for site in self.siteoper.list_order_by_pri(): - self._name_site_map[site.name] = site - # 只给选中的站点构造站点配置 - self._site_cs_infos: List[CSSiteConfig] = [] + + # 整理所有可用内部站点信息 + all_site_cs_info_map : dict[str, CSSiteConfig] = dict() + for site in inner_site_list: + if site.is_active: + all_site_cs_info_map[site.name] = CSSiteConfig( + name=site.name, + url=site.url, + id=site.id, + cookie=site.cookie, + ua=site.ua, + proxy=site.proxy, + ) + for site in self.__custom_sites(): + all_site_cs_info_map[site.get("name")] = CSSiteConfig( + name=site.get("name"), + url=site.get("url"), + id=site.get("id"), + cookie=site.get("cookie"), + ua=site.get("ua"), + proxy=site.get("proxy"), + ) + self._sites = [site.id for site in all_site_cs_info_map.values() if site.id in self._sites] + site_names = [site.name for site in all_site_cs_info_map.values() if site.id in self._sites] + + # 整理passkey映射关系 + site_name_key_map = dict() + site_name_gap_map = dict() for site_key in self._token.strip().split("\n"): site_key_arr = re.split("[\s::]+",site_key.strip()) site_name = site_key_arr[0] - db_site = self._name_site_map[site_name] - if site_name in site_names and db_site: - self._site_cs_infos.append(CSSiteConfig(site_name, db_site.url, site_key_arr[1])) + site_name_key_map[site_name] = site_key_arr[1] + if len(site_key_arr) > 2: + if str.isdigit(site_key_arr[2]): + site_name_gap_map[site_name] = int(site_key_arr[2]) + else: + logger.warn( + f"站点{site_name}配置的查询请求间隔时间不为整数,不能生效, 请修改 {site_key_arr[2]}" + ) + + # 只给选中的站点补全站点配置 + self._site_cs_infos: List[CSSiteConfig] = [] + # 根据配置来补充passkey + for site_name in site_names: + site_key = site_name_key_map.get(site_name) + if not site_key: + logger.warning(f"未找到站点{site_name}的passkey, 请检查passkey配置是否有误,站点{site_name}将跳过辅种") + continue + site_cs_info = all_site_cs_info_map.get(site_name) + site_cs_info.passkey = site_key + # 追加设置的请求间隔时间 + site_query_gap = site_name_gap_map.get(site_name) + if site_query_gap: + site_cs_info.query_gap = site_query_gap + self._site_cs_infos.append(site_cs_info) self.__update_config() @@ -611,7 +669,7 @@ class CrossSeed(_PluginBase): 'type': 'info', 'variant': 'tonal', 'text': '1. 定时任务周期建议每次辅种间隔时间大于1天,不填写每天上午2点到7点随机辅种一次; ' - '2. 支持辅种站点列表:青蛙【已验证】,AGSVPT,麒麟,UBits,聆音 等,配置passkey时,站点名称需严格和上面选项一致,只有选中的站点会辅种,passkey可保存多个; ' + '2. 支持辅种站点列表:青蛙、AGSVPT、红豆饭、麒麟、UBits、聆音等,配置passkey时,站点名称需严格和上面选项一致,只有选中的站点会辅种,passkey可保存多个; ' '3. 请勿与IYUU辅种插件同时添加相同站点,可能会有冲突,且意义不大;' '4. 测试站点是否支持的方法:【站点域名/api/pieces-hash】接口访问返回405则大概率支持 ' } @@ -619,6 +677,29 @@ class CrossSeed(_PluginBase): ] } ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '【进阶设置】如果辅种过程中访问/api/pieces-hash接口偶尔会失败,可以设置请求间隔时间。 ' + '可以在passkey后增加 :3 来将某个站点的请求间隔调整为3秒,3可以改为其他数字,只能为整数,默认请求间隔为1秒。 ' + '示例配置 站点名称:Passkey:3' + } + } + ] + } + ] } ] } @@ -709,14 +790,14 @@ class CrossSeed(_PluginBase): if not torrent_path.exists(): if downloader == "qbittorrent": # FIXME qb从4.4.0开始,种子文件以标题+序号的方式保存,目前只能尝试导出后再解析 - # logger.info(f"正在导出种子 {torrent.get('name')}({hash_str})") + logger.warn(f"QB种子文件不存在:{torrent_path} 尝试远程导出种子") try: torrent_data = torrent.export() torrent_info, err = TorInfo.from_data(torrent_data) except Exception as e: err = str(e) if not torrent_info: - logger.error(f"尝试导出种子 {hash_str} 出错 {err}") + logger.error(f"尝试远程导出种子 {hash_str} 出错 {err}") continue else: logger.error(f"种子文件不存在:{torrent_path}") @@ -873,12 +954,12 @@ class CrossSeed(_PluginBase): # 逐个站点查询可辅种数据 chunk_size = 100 for site_config in self._site_cs_infos: - db_site_info = self._name_site_map[site_config.name] - if not db_site_info: - logger.info(f"未在支持站点中找到{site_config.name}") remote_tors: List[TorInfo] = [] total_size = len(pieces_hashes) for i in range(0, len(pieces_hashes), chunk_size): + if self._event.is_set(): + logger.info(f"辅种服务停止") + return # 切片操作 chunk = pieces_hashes[i:i + chunk_size] # 处理分组 @@ -911,6 +992,9 @@ class CrossSeed(_PluginBase): logger.info(f"站点{site_config.name}正在做种或已经辅种过的种子数为{local_cnt}") for tor_info in not_local_tors: + if self._event.is_set(): + logger.info(f"辅种服务停止") + return if not tor_info: continue if not tor_info.torrent_id or not tor_info.pieces_hash: @@ -922,7 +1006,7 @@ class CrossSeed(_PluginBase): logger.info(f"种子 {tor_info.get_name_id_tag()} 辅种失败且已缓存,跳过 ...") continue # 添加任务 - self.__download_torrent(tor=tor_info, site_config=site_config, site_info=db_site_info, + self.__download_torrent(tor=tor_info, site_config=site_config, downloader=downloader, save_path=save_paths.get(tor_info.pieces_hash)) @@ -967,7 +1051,6 @@ class CrossSeed(_PluginBase): self, tor: TorInfo, site_config: CSSiteConfig, - site_info: Site, downloader: str, save_path: str, ): @@ -984,9 +1067,9 @@ class CrossSeed(_PluginBase): # 下载种子文件 _, content, _, _, error_msg = self.torrent.download_torrent( url=torrent_url, - cookie=site_info.cookie, - ua=site_info.ua or settings.USER_AGENT, - proxy=True if site_info.proxy else False) + cookie=site_config.cookie, + ua=site_config.ua or settings.USER_AGENT, + proxy=True if site_config.proxy else False) # 兼容种子无法访问的情况 if not content or (isinstance(content, bytes) and "你没有该权限".encode(encoding="utf-8") in content): diff --git a/plugins/downloaderhelper/README.md b/plugins/downloaderhelper/README.md index 7aca8b6..c0a88d8 100644 --- a/plugins/downloaderhelper/README.md +++ b/plugins/downloaderhelper/README.md @@ -43,7 +43,7 @@ |定时执行周期|插件定时服务的cron表达式,仅支持5位的,缺省时不注册定时服务。| |排除种子标签|多个标签通过英文逗号分割,具备配置的任意标签的种子不会进行自动做种、站点标签、自动删种操作。| |站点标签前缀|站点标签的前缀,缺省时不添加前缀。| -|Tracker映射|站点标签的原理是根据tracker的域名去匹配站点,但是有的PT站的tracker域名和站点域名不一致,导致匹配不到站点,因此需要对这些特殊站点的tracker做映射;每行一个映射,格式是 `tracker域名:站点域名`,tracker域名至少需要配置二级,站点域名只能配置二级。| +|Tracker映射|站点标签的原理是根据tracker的域名去匹配站点,但是有的PT站的tracker域名和站点域名不一致,导致匹配不到站点,因此需要对这些特殊站点的tracker做映射;每行一个映射,格式是 `tracker域名:站点域名`,tracker域名可以是完整域名或者主域名。| ##### 2.1.2、下载器子任务配置项 diff --git a/plugins/downloaderhelper/__init__.py b/plugins/downloaderhelper/__init__.py index 09beb40..817c264 100644 --- a/plugins/downloaderhelper/__init__.py +++ b/plugins/downloaderhelper/__init__.py @@ -32,7 +32,7 @@ class DownloaderHelper(_PluginBase): # 插件图标 plugin_icon = "DownloaderHelper.png" # 插件版本 - plugin_version = "1.1" + plugin_version = "1.4" # 插件作者 plugin_author = "hotlcc" # 作者主页 @@ -77,6 +77,8 @@ class DownloaderHelper(_PluginBase): __tracker_mappings: Dict[str, str] = {} # 排除种子标签 __exclude_tags: Set[str] = set() + # 多级根域名,用于在打标时做特殊处理 + __multi_level_root_domain: List[str] = ['edu.cn', 'com.cn', 'net.cn', 'org.cn'] def init_plugin(self, config: dict = None): """ @@ -175,87 +177,90 @@ class DownloaderHelper(_PluginBase): 'component': 'VCol', 'props': { 'cols': 12, - 'xxl': 3, 'xl': 3, 'lg': 3, 'md': 3, 'sm': 6, 'xs': 12, - 'title': '插件总开关' + 'md': 4, + 'xl': 3 }, 'content': [{ 'component': 'VSwitch', 'props': { 'model': 'enable', - 'label': '启用插件' + 'label': '启用插件', + 'hint': '插件总开关' } }] }, { 'component': 'VCol', 'props': { 'cols': 12, - 'xxl': 3, 'xl': 3, 'lg': 3, 'md': 3, 'sm': 6, 'xs': 12, - 'title': '执行插件任务后是否发送通知' + 'md': 4, + 'xl': 3 }, 'content': [{ 'component': 'VSwitch', 'props': { 'model': 'enable_notify', - 'label': '发送通知' + 'label': '发送通知', + 'hint': '执行插件任务后是否发送通知' } }] }, { 'component': 'VCol', 'props': { 'cols': 12, - 'xxl': 3, 'xl': 3, 'lg': 3, 'md': 3, 'sm': 6, 'xs': 12, - 'title': '保存插件配置后是否立即触发一次插件任务运行' + 'md': 4, + 'xl': 3 }, 'content': [{ 'component': 'VSwitch', 'props': { 'model': 'run_once', - 'label': '立即运行一次' + 'label': '立即运行一次', + 'hint': '保存插件配置后是否立即触发一次插件任务运行' } }] - }] - }, { - 'component': 'VRow', - 'content': [{ + }, { 'component': 'VCol', 'props': { 'cols': 12, - 'xxl': 3, 'xl': 3, 'lg': 3, 'md': 3, 'sm': 6, 'xs': 12, - 'title': '监听下载添加事件。当MoviePilot添加下载任务时,会触发执行本插件进行自动做种和添加站点标签。' + 'md': 4, + 'xl': 3 }, 'content': [{ 'component': 'VSwitch', 'props': { 'model': 'listen_download_event', - 'label': '监听下载事件' + 'label': '监听下载事件', + 'hint': '监听下载添加事件。当MoviePilot添加下载任务时,会触发执行本插件进行自动做种和添加站点标签。' } }] }, { 'component': 'VCol', 'props': { 'cols': 12, - 'xxl': 3, 'xl': 3, 'lg': 3, 'md': 3, 'sm': 6, 'xs': 12, - 'title': '监听源文件删除事件。当在【历史记录】中删除源文件时,会自动触发运行本插件任务进行自动删种。' + 'md': 4, + 'xl': 3 }, 'content': [{ 'component': 'VSwitch', 'props': { 'model': 'listen_source_file_event', - 'label': '监听源文件事件' + 'label': '监听源文件事件', + 'hint': '监听源文件删除事件。当在【历史记录】中删除源文件时,会自动触发运行本插件任务进行自动删种。' } }] }, { 'component': 'VCol', 'props': { 'cols': 12, - 'xxl': 3, 'xl': 3, 'lg': 3, 'md': 3, 'sm': 6, 'xs': 12, - 'title': '给种子添加站点标签时,是否优先以站点名称作为标签内容(否则将使用域名关键字)?' + 'md': 4, + 'xl': 3 }, 'content': [{ 'component': 'VSwitch', 'props': { 'model': 'site_name_priority', - 'label': '站点名称优先' + 'label': '站点名称优先', + 'hint': '给种子添加站点标签时,是否优先以站点名称作为标签内容(否则将使用域名关键字)?' } }] }] @@ -265,44 +270,44 @@ class DownloaderHelper(_PluginBase): 'component': 'VCol', 'props': { 'cols': 12, - 'xxl': 3, 'xl': 3, 'lg': 3, 'md': 3, 'sm': 6, 'xs': 12, - 'title': '设置插件任务执行周期。支持5位cron表达式,应避免任务执行过于频繁,例如:0/30 * * * *。缺省时不执行定时任务,但不影响监听任务的执行。' + 'md': 4 }, 'content': [{ 'component': 'VTextField', 'props': { 'model': 'cron', 'label': '定时执行周期', - 'placeholder': '0/30 * * * *' + 'placeholder': '0/30 * * * *', + 'hint': '设置插件任务执行周期。支持5位cron表达式,应避免任务执行过于频繁,例如:0/30 * * * *。缺省时不执行定时任务,但不影响监听任务的执行。' } }] }, { 'component': 'VCol', 'props': { 'cols': 12, - 'xxl': 3, 'xl': 3, 'lg': 3, 'md': 3, 'sm': 6, 'xs': 12, - 'title': '下载器中的种子有这些标签时不进行任何操作,多个标签使用英文“,”分割' + 'md': 4 }, 'content': [{ 'component': 'VTextField', 'props': { 'model': 'exclude_tags', - 'label': '排除种子标签' + 'label': '排除种子标签', + 'hint': '下载器中的种子有这些标签时不进行任何操作,多个标签使用英文“,”分割' } }] }, { 'component': 'VCol', 'props': { 'cols': 12, - 'xxl': 3, 'xl': 3, 'lg': 3, 'md': 3, 'sm': 6, 'xs': 12, - 'title': '给种子添加站点标签时的标签前缀,默认值为“站点/”' + 'md': 4 }, 'content': [{ 'component': 'VTextField', 'props': { 'model': 'tag_prefix', 'label': '站点标签前缀', - 'placeholder': '站点/' + 'placeholder': '站点/', + 'hint': '给种子添加站点标签时的标签前缀,默认值为“站点/”' } }] }] @@ -311,8 +316,7 @@ class DownloaderHelper(_PluginBase): 'content': [{ 'component': 'VCol', 'props': { - 'cols': 12, - 'title': 'Tracker映射。用于在站点打标签时,指定tracker和站点域名不同的种子的域名对应关系;前面为tracker域名(二级或多级),中间是英文冒号,后面是站点域名(只能是二级)。' + 'cols': 12 }, 'content': [{ 'component': 'VTextarea', @@ -322,7 +326,8 @@ class DownloaderHelper(_PluginBase): 'placeholder': '格式:\n' ':\n' '例如:\n' - 'chdbits.xyz:ptchdbits.co' + 'chdbits.xyz:ptchdbits.co', + 'hint': 'Tracker映射。用于在站点打标签时,指定tracker和站点域名不同的种子的域名对应关系;前面为tracker域名(完整域名或者主域名皆可),中间是英文冒号,后面是站点域名。' } }] }] @@ -371,7 +376,8 @@ class DownloaderHelper(_PluginBase): 'component': 'VSwitch', 'props': { 'model': 'qb_enable', - 'label': '任务开关' + 'label': '任务开关', + 'hint': '该下载器子任务的开关' } }] }, { @@ -384,7 +390,8 @@ class DownloaderHelper(_PluginBase): 'component': 'VSwitch', 'props': { 'model': 'qb_enable_seeding', - 'label': '自动做种' + 'label': '自动做种', + 'hint': '是否开启自动做种功能' } }] }, { @@ -397,7 +404,8 @@ class DownloaderHelper(_PluginBase): 'component': 'VSwitch', 'props': { 'model': 'qb_enable_tagging', - 'label': '站点标签' + 'label': '站点标签', + 'hint': '是否开启站点标签功能' } }] }, { @@ -410,7 +418,8 @@ class DownloaderHelper(_PluginBase): 'component': 'VSwitch', 'props': { 'model': 'qb_enable_delete', - 'label': '自动删种' + 'label': '自动删种', + 'hint': '是否开启自动删种功能' } }] }] @@ -516,8 +525,7 @@ class DownloaderHelper(_PluginBase): finally: self.__exit_event.clear() - @staticmethod - def __parse_tracker_mappings(tracker_mappings: str) -> Dict[str, str]: + def __parse_tracker_mappings(self, tracker_mappings: str) -> Dict[str, str]: """ 解析配置的tracker映射 :param tracker_mappings: 配置的tracker映射 @@ -540,7 +548,7 @@ class DownloaderHelper(_PluginBase): key, value = key.strip(), value.strip() if not key or not value: continue - if len(key.split('.')) >= 2 and len(value.split('.')) == 2: + if self.__is_valid_domain(key) and self.__is_valid_domain(value): mappings[key] = value return mappings @@ -767,34 +775,60 @@ class DownloaderHelper(_PluginBase): scheme, netloc = StringUtils.get_url_netloc(url) return netloc - @staticmethod - def __get_domain_level2(domain: str) -> Optional[str]: + def __get_main_domain(self, domain: str) -> Optional[str]: """ - 获取域名的二级域名 + 获取域名的主域名 + :param domain: 原域名 + :return: 主域名 """ if not domain: return None domain_arr = domain.split('.') - domain_arr_len = len(domain_arr) - if domain_arr_len == 2: - return domain - elif domain_arr_len > 2: - return f'{domain_arr[-2]}.{domain_arr[-1]}' - else: + domain_len = len(domain_arr) + if domain_len < 2: return None + root_domain, root_domain_len = self.__match_multi_level_root_domain(domain=domain) + if root_domain: + return f'{domain_arr[-root_domain_len - 1]}.{root_domain}' + else: + return f'{domain_arr[-2]}.{domain_arr[-1]}' - @staticmethod - def __get_domain_keyword(domain: str) -> Optional[str]: + def __get_domain_keyword(self, domain: str) -> Optional[str]: """ 获取域名关键字 """ + main_domain = self.__get_main_domain(domain=domain) + if not main_domain: + return None + return main_domain.split('.')[0] + + def __match_multi_level_root_domain(self, domain: str) -> Tuple[Optional[str], int]: + """ + 匹配多级根域名 + :param domain: 被匹配的域名 + :return: 匹配的根域名, 匹配的根域名长度 + """ + if not domain or not self.__multi_level_root_domain: + return None, 0 + for root_domain in self.__multi_level_root_domain: + if domain.endswith('.' + root_domain): + root_domain_len = len(root_domain.split('.')) + return root_domain, root_domain_len + return None, 0 + + def __is_valid_domain(self, domain: str) -> bool: + """ + 判断域名是否有效 + :param domain: 被判断的域名 + :return: 是否有效 + """ if not domain: - return None - domain_arr = domain.split('.') - if len(domain_arr) >= 2: - return domain_arr[-2] - else: - return None + return False + domain_len = len(domain.split('.')) + root_domain, root_domain_len = self.__match_multi_level_root_domain(domain) + if root_domain: + return domain_len > root_domain_len + return domain_len > 1 def __generate_site_tag(self, site: str) -> Optional[str]: """ @@ -816,7 +850,7 @@ class DownloaderHelper(_PluginBase): return None, None # tracker的完整域名 - tracker_domain = self.__get_url_domain(tracker_url) + tracker_domain = self.__get_url_domain(url=tracker_url) if not tracker_domain: return None, None @@ -824,20 +858,20 @@ class DownloaderHelper(_PluginBase): delete_suggest = set() # tracker域名关键字 - tracker_domain_keyword = self.__get_domain_keyword(tracker_domain) + tracker_domain_keyword = self.__get_domain_keyword(domain=tracker_domain) if tracker_domain_keyword: # 建议移除 delete_suggest.add(tracker_domain_keyword) - delete_suggest.add(self.__generate_site_tag(tracker_domain_keyword)) + delete_suggest.add(self.__generate_site_tag(site=tracker_domain_keyword)) # 首先根据tracker的完整域名去匹配站点信息 - site_info = self.__get_site_info_by_domain(tracker_domain) + site_info = self.__get_site_info_by_domain(site_domain=tracker_domain) - # 如果没有匹配到,再根据二级域名去匹配 + # 如果没有匹配到,再根据主域名去匹配 if not site_info: - tracker_domain_level2 = self.__get_domain_level2(tracker_domain) - if tracker_domain_level2: - site_info = self.__get_site_info_by_domain(tracker_domain_level2) + tracker_main_domain = self.__get_main_domain(domain=tracker_domain) + if tracker_main_domain and tracker_main_domain != tracker_domain: + site_info = self.__get_site_info_by_domain(tracker_main_domain) # 如果还是没有匹配到,就根据tracker映射的域名匹配 matched_site_domain = None @@ -872,7 +906,7 @@ class DownloaderHelper(_PluginBase): else: site_tag = self.__generate_site_tag(self.__get_domain_keyword(tracker_domain)) - if site_tag: + if site_tag and site_tag in delete_suggest: delete_suggest.remove(site_tag) return site_tag, delete_suggest @@ -1444,7 +1478,8 @@ class DownloaderHelper(_PluginBase): # 移除建议删除的标签 if delete_suggest and len(delete_suggest) > 0: for to_delete in delete_suggest: - torrent_tags_copy.remove(to_delete) + if to_delete and to_delete in torrent_tags_copy: + torrent_tags_copy.remove(to_delete) # 如果本次需要打标签 if site_tag and site_tag not in torrent_tags_copy: torrent_tags_copy.append(site_tag) @@ -1513,12 +1548,12 @@ class DownloaderHelper(_PluginBase): # 执行 logger.info('下载添加事件监听任务执行开始...') context = TaskContext().enable_seeding(False).enable_tagging(True).enable_delete(False) - hash_str = event.event_data.get('hash') - if hash: - context.select_torrent(hash_str) + _hash = event.event_data.get('hash') + if _hash: + context.select_torrent(torrent=_hash) username = event.event_data.get('username') if username: - context.select_username(username) + context.set_username(username=username) self.__run_for_all(context=context) logger.info('下载添加事件监听任务执行结束') diff --git a/plugins/feishumsg/__init__.py b/plugins/feishumsg/__init__.py new file mode 100644 index 0000000..69988b7 --- /dev/null +++ b/plugins/feishumsg/__init__.py @@ -0,0 +1,263 @@ +import base64 +import hashlib +import hmac +import time +from typing import Any, List, Dict, Tuple + +from app.core.event import eventmanager, Event +from app.log import logger +from app.plugins import _PluginBase +from app.schemas.types import EventType, NotificationType +from app.utils.http import RequestUtils + + +class FeiShuMsg(_PluginBase): + # 插件名称 + plugin_name = "飞书机器人消息通知" + # 插件描述 + plugin_desc = "支持使用飞书群聊机器人发送消息通知。" + # 插件图标 + plugin_icon = "FeiShu_A.png" + # 插件版本 + plugin_version = "1.0" + # 插件作者 + plugin_author = "InfinityPacer" + # 作者主页 + author_url = "https://github.com/InfinityPacer" + # 插件配置项ID前缀 + plugin_config_prefix = "feishu_" + # 加载顺序 + plugin_order = 28 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled = False + _webhookurl = None + _msgtypes = [] + _secret = None + + def init_plugin(self, config: dict = None): + if config: + self._enabled = config.get("enabled") + self._webhookurl = config.get("webhookurl") + self._msgtypes = config.get("msgtypes") or [] + self._secret = config.get("secret") + + def get_state(self) -> bool: + return self._enabled and (True if self._webhookurl else False) + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + # 遍历 NotificationType 枚举,生成消息类型选项 + msg_type_options = [] + default_msg_type_values = [] + for item in NotificationType: + msg_type_options.append({ + "title": item.value, + "value": item.name + }) + default_msg_type_values.append(item.name) + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'webhookurl', + 'label': 'WebHook地址', + 'placeholder': 'https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxxxxxxxxxxx', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'secret', + 'label': '密钥', + 'placeholder': '如设置了签名校验,请输入密钥', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': True, + 'chips': True, + 'model': 'msgtypes', + 'label': '消息类型', + 'items': msg_type_options + } + } + ] + } + ] + }, + ] + } + ], { + "enabled": False, + 'webhookurl': '', + 'msgtypes': default_msg_type_values, + 'secret': '', + } + + def get_page(self) -> List[dict]: + pass + + @eventmanager.register(EventType.NoticeMessage) + def send(self, event: Event): + """ + 消息发送事件 + """ + if not self.get_state(): + return + + if not event.event_data: + return + + msg_body = event.event_data + # 渠道 + channel = msg_body.get("channel") + if channel: + logger.info(f"channel: {channel} 不进行消息推送") + return + # 类型 + msg_type: NotificationType = msg_body.get("type") + # 标题 + title = msg_body.get("title") + # 文本 + text = msg_body.get("text") + # 图像 + image = msg_body.get("image") + + if not title and not text: + logger.warn("标题和内容不能同时为空") + return + + if (msg_type and self._msgtypes + and msg_type.name not in self._msgtypes): + logger.info(f"消息类型 {msg_type.value} 未开启消息发送") + return + + try: + payload = { + "msg_type": "post", + "content": { + "post": { + "zh_cn": { + "title": title, + "content": [ + [{ + "tag": "text", + "text": text + }] + ] + } + } + } + } + + # 如果存在密钥时,还需要进行签名处理 + if self._secret: + timestamp = str(int(time.time())) + sign = self.gen_sign(timestamp, self._secret) + payload.update({ + "timestamp": timestamp, + "sign": sign + }) + + res = RequestUtils(content_type="application/json").post_res(url=self._webhookurl, json=payload) + if res and res.status_code == 200: + ret_json = res.json() + errno = ret_json.get('code') + error = ret_json.get('msg') + if errno == 0: + logger.info("飞书机器人消息发送成功") + else: + logger.warn(f"飞书机器人消息发送失败,错误码:{errno},错误原因:{error}") + elif res is not None: + logger.warn(f"飞书机器人消息发送失败,错误码:{res.status_code},错误原因:{res.reason}") + else: + logger.warn("飞书机器人消息发送失败,未获取到返回信息") + except Exception as msg_e: + logger.error(f"飞书机器人消息发送失败,{str(msg_e)}") + + def stop_service(self): + """ + 退出插件 + """ + pass + + @staticmethod + def gen_sign(timestamp, secret): + # 拼接timestamp和secret + string_to_sign = '{}\n{}'.format(timestamp, secret) + hmac_code = hmac.new(string_to_sign.encode("utf-8"), digestmod=hashlib.sha256).digest() + # 对结果进行base64处理 + sign = base64.b64encode(hmac_code).decode('utf-8') + return sign diff --git a/plugins/vcbanimemonitor/requirements.txt b/plugins/vcbanimemonitor/requirements.txt index 7001735..0eed2d7 100644 --- a/plugins/vcbanimemonitor/requirements.txt +++ b/plugins/vcbanimemonitor/requirements.txt @@ -1 +1 @@ -roman~=4.1 \ No newline at end of file +roman~=4.1