From 167723200053c2dbc2f7a7c38627f7e599f4077a Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Wed, 10 Jul 2024 20:50:58 +0800 Subject: [PATCH 001/218] =?UTF-8?q?fix=20=E7=A7=BB=E9=99=A4=E3=80=8C?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E6=9B=B4=E5=A4=9A=E6=97=A5=E5=BF=97=E3=80=8D?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E9=A1=B9=EF=BC=8C=E8=B0=83=E6=95=B4=E4=B8=BA?= =?UTF-8?q?DEBUG=E6=97=A5=E5=BF=97=EF=BC=8C=E4=BC=98=E5=8C=96=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E6=97=A5=E5=BF=97=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/brushflow/__init__.py | 78 ++++++++++------------------------- 1 file changed, 21 insertions(+), 57 deletions(-) diff --git a/plugins/brushflow/__init__.py b/plugins/brushflow/__init__.py index 0189777..b68c1a4 100644 --- a/plugins/brushflow/__init__.py +++ b/plugins/brushflow/__init__.py @@ -71,7 +71,6 @@ class BrushConfig: self.brush_sequential = config.get("brush_sequential", False) self.proxy_download = config.get("proxy_download", False) 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.downloader_monitor = config.get("downloader_monitor") self.qb_category = config.get("qb_category") @@ -340,11 +339,10 @@ 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}") + if brush_config.enable_site_config: + logger.debug(f"已开启站点独立配置,配置信息:{brush_config}") + else: + logger.debug(f"没有开启站点独立配置,配置信息:{brush_config}") # 停止现有任务 self.stop_service() @@ -366,8 +364,6 @@ class BrushFlow(_PluginBase): # 如果开启&存在站点时,才需要启用后台任务 self._task_brush_enable = brush_config.enabled and brush_config.brushsites - # brush_config.onlyonce = True - # 检查是否启用了一次性任务 if brush_config.onlyonce: self._scheduler = BackgroundScheduler(timezone=settings.TZ) @@ -974,11 +970,6 @@ class BrushFlow(_PluginBase): 'component': 'VWindow', 'props': { 'model': '_tabs' - # VWindow设置paddnig会导致切换Tab时页面高度变动,调整为修改VRow的方案 - # 'style': { - # 'padding-top': '24px', - # 'padding-bottom': '24px', - # }, }, 'content': [ { @@ -1426,7 +1417,7 @@ class BrushFlow(_PluginBase): 'component': 'VTextField', 'props': { 'model': 'seed_inactivetime', - 'label': '未活动时间(分钟) ', + 'label': '未活动时间(分钟)', 'placeholder': '超过时删除任务' } } @@ -1658,27 +1649,6 @@ class BrushFlow(_PluginBase): ] } ] - }, - { - 'component': 'VRow', - "content": [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'log_more', - 'label': '记录更多日志', - } - } - ] - } - ] } ] } @@ -1742,7 +1712,7 @@ class BrushFlow(_PluginBase): 'props': { 'type': 'error', 'variant': 'tonal', - 'text': '注意:排除H&R并不保证能完全适配所有站点(部分站点在列表页不显示H&R标志,但实际上是有H&R的),请注意核对使用!' + 'text': '注意:排除H&R并不保证能完全适配所有站点(部分站点在列表页不显示H&R标志,但实际上是有H&R的),请注意核对使用' } } ] @@ -1857,7 +1827,6 @@ class BrushFlow(_PluginBase): "freeleech": "free", "hr": "yes", "enable_site_config": False, - "log_more": False, "downloader_monitor": False, "auto_qb_category": False, "qb_first_last_piece": False, @@ -2055,9 +2024,6 @@ class BrushFlow(_PluginBase): if brush_config.site_hr_active: logger.info(f"站点 {siteinfo.name} 已开启全站H&R选项,所有种子设置为H&R种子") - # 由于缓存原因,这里不能直接改torrents,在后续加入任务中调整 - # for torrent in torrents: - # torrent.hit_and_run = True # 排除包含订阅的种子 if brush_config.except_subscribe: @@ -2068,7 +2034,7 @@ class BrushFlow(_PluginBase): torrents_size = self.__calculate_seeding_torrents_size(torrent_tasks=torrent_tasks) - logger.info(f"正在准备种子刷流,数量:{len(torrents)}") + logger.info(f"正在准备种子刷流,数量 {len(torrents)}") # 过滤种子 for torrent in torrents: @@ -2078,6 +2044,8 @@ class BrushFlow(_PluginBase): if not pre_condition_passed: return False + logger.debug(f"种子详情:{torrent}") + # 判断能否通过保种体积刷流条件 size_condition_passed, reason = self.__evaluate_size_condition_for_brush(torrents_size=torrents_size, add_torrent_size=torrent.size) @@ -2098,8 +2066,8 @@ class BrushFlow(_PluginBase): logger.warn(f"{torrent.title} 添加刷流任务失败!") continue - # 保存任务信息 - torrent_tasks[hash_string] = { + # 触发刷流下载时间并保存任务信息 + torrent_task = { "site": siteinfo.id, "site_name": siteinfo.name, "title": torrent.title, @@ -2306,7 +2274,8 @@ class BrushFlow(_PluginBase): return True, None - def __log_brush_conditions(self, passed: bool, reason: str, torrent: Any = None): + @staticmethod + def __log_brush_conditions(passed: bool, reason: str, torrent: Any = None): """ 记录刷流日志 """ @@ -2314,9 +2283,7 @@ class BrushFlow(_PluginBase): 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}") + logger.debug(f"种子没有通过刷流条件校验,原因:{reason} 种子:{torrent.title}|{torrent.description}") # endregion @@ -2618,8 +2585,7 @@ class BrushFlow(_PluginBase): 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}") + logger.debug(f"站点:{site_name},{reason},不删除种子:{torrent_title}|{torrent_desc}") return delete_hashes @@ -2657,8 +2623,7 @@ class BrushFlow(_PluginBase): 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}") + logger.debug(f"站点:{site_name},{reason},不删除种子:{torrent_title}|{torrent_desc}") return delete_hashes @@ -2914,7 +2879,7 @@ class BrushFlow(_PluginBase): "active_downloaded": active_downloaded }) - logger.info(f"刷流任务统计数据:总任务数:{total_count},活跃任务数:{active_count},已删除:{total_deleted}," + logger.info(f"刷流任务统计数据,总任务数:{total_count},活跃任务数:{active_count},已删除:{total_deleted}," f"待归档:{total_unarchived}," f"活跃上传量:{StringUtils.str_filesize(active_uploaded)}," f"活跃下载量:{StringUtils.str_filesize(active_downloaded)}," @@ -3034,7 +2999,6 @@ 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, "downloader_monitor": brush_config.downloader_monitor, "qb_category": brush_config.qb_category, @@ -3131,7 +3095,7 @@ class BrushFlow(_PluginBase): data = data.get(key) if not data: return None - logger.info(f"获取到下载地址:{data}") + logger.debug(f"获取到下载地址:{data}") return data return None @@ -3201,8 +3165,7 @@ 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失败" - f"{',请尝试启用「代理下载种子」配置项' if not brush_config.proxy_download else ''}") + logger.error(f"{brush_config.downloader} 获取种子Hash失败,详细信息请查看 README") return None return torrent_hash return None @@ -3766,7 +3729,8 @@ class BrushFlow(_PluginBase): for key in set(self._subscribe_infos) - current_keys: del self._subscribe_infos[key] - logger.info(f"订阅标题匹配完成,当前订阅的标题集合为:{self._subscribe_infos}") + logger.info("已订阅标题匹配完成") + logger.debug(f"当前订阅的标题集合为:{self._subscribe_infos}") unique_titles = {title for titles in self._subscribe_infos.values() for title in titles} return unique_titles From 73a02274adb3de424171710ca7727c59fe627df7 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Wed, 10 Jul 2024 20:51:54 +0800 Subject: [PATCH 002/218] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E3=80=8C?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=8E=92=E9=99=A4=E6=A0=87=E7=AD=BE=E3=80=8D?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/brushflow/__init__.py | 107 +++++++++++++++++++++++++--------- 1 file changed, 78 insertions(+), 29 deletions(-) diff --git a/plugins/brushflow/__init__.py b/plugins/brushflow/__init__.py index b68c1a4..8c4d419 100644 --- a/plugins/brushflow/__init__.py +++ b/plugins/brushflow/__init__.py @@ -67,6 +67,7 @@ class BrushConfig: self.clear_task = config.get("clear_task", False) self.archive_task = config.get("archive_task", False) self.except_tags = config.get("except_tags", True) + self.delete_except_tags = config.get("delete_except_tags") self.except_subscribe = config.get("except_subscribe", True) self.brush_sequential = config.get("brush_sequential", False) self.proxy_download = config.get("proxy_download", False) @@ -294,7 +295,6 @@ class BrushFlow(_PluginBase): # endregion def init_plugin(self, config: dict = None): - logger.info(f"站点刷流服务初始化") self.siteshelper = SitesHelper() self.siteoper = SiteOper() self.torrents = TorrentsChain() @@ -1422,6 +1422,23 @@ class BrushFlow(_PluginBase): } } ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'delete_except_tags', + 'label': '删除排除标签', + 'placeholder': '如:MOVIEPILOT,H&R' + } + } + ] } ] } @@ -1820,6 +1837,7 @@ class BrushFlow(_PluginBase): "clear_task": False, "archive_task": False, "except_tags": True, + "delete_except_tags": f"{settings.TORRENT_TAG},H&R" if settings.TORRENT_TAG else "H&R", "except_subscribe": True, "brush_sequential": False, "proxy_download": False, @@ -2339,34 +2357,57 @@ class BrushFlow(_PluginBase): # 更新刷流任务列表中在下载器中删除的种子为删除状态 self.__update_undeleted_torrents_missing_in_downloader(torrent_tasks, torrent_check_hashes, check_torrents) - # 排除MoviePilot种子 - if check_torrents and brush_config.except_tags: - check_torrents = self.__filter_torrents_by_tag(torrents=check_torrents, - exclude_tag=settings.TORRENT_TAG) + # 根据配置的标签进行种子排除 + if check_torrents: + logger.info(f"当前刷流任务共 {len(check_torrents)} 个有效种子,正在准备按设定的种子标签进行排除") + # 初始化一个空的列表来存储需要排除的标签 + tags_to_exclude = set() + # 如果 except_tags 配置为 True,将 settings.TORRENT_TAG 添加到排除列表中(前提是它不为空且不是纯空白) + if brush_config.except_tags and settings.TORRENT_TAG.strip(): + tags_to_exclude.add(settings.TORRENT_TAG.strip()) + # 如果 delete_except_tags 非空且不是纯空白,则添加到排除列表中 + if brush_config.delete_except_tags and brush_config.delete_except_tags.strip(): + tags_to_exclude.update(tag.strip() for tag in brush_config.delete_except_tags.split(',')) + # 将所有需要排除的标签组合成一个字符串,每个标签之间用逗号分隔 + combined_tags = ",".join(tags_to_exclude) + if combined_tags: # 确保有标签需要排除 + pre_filter_count = len(check_torrents) # 获取过滤前的任务数量 + check_torrents = self.__filter_torrents_by_tag(torrents=check_torrents, exclude_tag=combined_tags) + post_filter_count = len(check_torrents) # 获取过滤后的任务数量 + excluded_count = pre_filter_count - post_filter_count # 计算被排除的任务数量 + logger.info( + f"有效种子数 {pre_filter_count},排除标签 '{combined_tags}' 后," + f"剩余种子数 {post_filter_count},排除种子数 {excluded_count}") + else: + logger.info("没有配置有效的排除标签,所有种子均参与后续处理") - need_delete_hashes = [] - - # 如果配置了动态删除以及删种阈值,则根据动态删种进行分组处理 - if brush_config.proxy_delete and brush_config.delete_size_range: - logger.info("已开启动态删种,按系统默认动态删种条件开始检查任务") - proxy_delete_hashes = self.__delete_torrent_for_proxy(torrents=check_torrents, - torrent_tasks=torrent_tasks) or [] - need_delete_hashes.extend(proxy_delete_hashes) - # 否则均认为是没有开启动态删种 + # 种子删除检查 + if not check_torrents: + logger.info("没有需要检查的任务,跳过") else: - logger.info("没有开启动态删种,按用户设置删种条件开始检查任务") - not_proxy_delete_hashes = self.__delete_torrent_for_evaluate_conditions(torrents=check_torrents, - torrent_tasks=torrent_tasks) or [] - need_delete_hashes.extend(not_proxy_delete_hashes) + need_delete_hashes = [] - if need_delete_hashes: - # 如果是QB,则重新汇报Tracker - if brush_config.downloader == "qbittorrent": - self.__qb_torrents_reannounce(torrent_hashes=need_delete_hashes) - # 删除种子 - if downloader.delete_torrents(ids=need_delete_hashes, delete_file=True): - for torrent_hash in need_delete_hashes: - torrent_tasks[torrent_hash]["deleted"] = True + # 如果配置了动态删除以及删种阈值,则根据动态删种进行分组处理 + if brush_config.proxy_delete and brush_config.delete_size_range: + logger.info("已开启动态删种,按系统默认动态删种条件开始检查任务") + proxy_delete_hashes = self.__delete_torrent_for_proxy(torrents=check_torrents, + torrent_tasks=torrent_tasks) or [] + need_delete_hashes.extend(proxy_delete_hashes) + # 否则均认为是没有开启动态删种 + else: + logger.info("没有开启动态删种,按用户设置删种条件开始检查任务") + not_proxy_delete_hashes = self.__delete_torrent_for_evaluate_conditions(torrents=check_torrents, + torrent_tasks=torrent_tasks) or [] + need_delete_hashes.extend(not_proxy_delete_hashes) + + if need_delete_hashes: + # 如果是QB,则重新汇报Tracker + if brush_config.downloader == "qbittorrent": + self.__qb_torrents_reannounce(torrent_hashes=need_delete_hashes) + # 删除种子 + if downloader.delete_torrents(ids=need_delete_hashes, delete_file=True): + for torrent_hash in need_delete_hashes: + torrent_tasks[torrent_hash]["deleted"] = True self.__update_and_save_statistic_info(torrent_tasks) @@ -2995,6 +3036,7 @@ class BrushFlow(_PluginBase): "clear_task": brush_config.clear_task, "archive_task": brush_config.archive_task, "except_tags": brush_config.except_tags, + "delete_except_tags": brush_config.delete_except_tags, "except_subscribe": brush_config.except_subscribe, "brush_sequential": brush_config.brush_sequential, "proxy_download": brush_config.proxy_download, @@ -3668,14 +3710,21 @@ class BrushFlow(_PluginBase): def __filter_torrents_by_tag(self, torrents: List[Any], exclude_tag: str) -> List[Any]: """ - 根据标签过滤torrents + 根据标签过滤torrents,排除标签格式为逗号分隔的字符串,例如 "MOVIEPILOT, H&R" """ + # 如果排除标签字符串为空,则返回原始列表 + if not exclude_tag: + return torrents + + # 将 exclude_tag 字符串分割成一个集合,并去除每个标签两端的空白,忽略空白标签并自动去重 + exclude_tags = set(tag.strip() for tag in exclude_tag.split(',') if tag.strip()) + filter_torrents = [] for torrent in torrents: # 使用 __get_label 方法获取每个 torrent 的标签列表 labels = self.__get_label(torrent) - # 如果排除的标签不在这个列表中,则添加到过滤后的列表 - if exclude_tag not in labels: + # 检查是否有任何一个排除标签存在于标签列表中 + if not any(exclude in labels for exclude in exclude_tags): filter_torrents.append(torrent) return filter_torrents From 326bef0652373f3b51d72a0c7bef4740eae59760 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Wed, 10 Jul 2024 20:52:15 +0800 Subject: [PATCH 003/218] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=88=B7?= =?UTF-8?q?=E6=B5=81=E4=BB=BB=E5=8A=A1=E6=97=B6=E6=94=AF=E6=8C=81=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=E6=8F=92=E4=BB=B6=E4=BA=8B=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/brushflow/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/plugins/brushflow/__init__.py b/plugins/brushflow/__init__.py index 8c4d419..a92d1d4 100644 --- a/plugins/brushflow/__init__.py +++ b/plugins/brushflow/__init__.py @@ -25,6 +25,7 @@ from app.modules.qbittorrent import Qbittorrent from app.modules.transmission import Transmission from app.plugins import _PluginBase from app.schemas import NotificationType, TorrentInfo, MediaType +from app.schemas.types import EventType from app.utils.http import RequestUtils from app.utils.string import StringUtils @@ -2120,6 +2121,12 @@ class BrushFlow(_PluginBase): "time": time.time() } + self.eventmanager.send_event(etype=EventType.PluginAction, data={ + "action": "brushflow_download_added", + "data": torrent_task + }) + torrent_tasks[hash_string] = torrent_task + # 统计数据 torrents_size += torrent.size statistic_info["count"] += 1 From 61dc98ff7c29fe4b8ec7997bb975ea5d387e68da Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Wed, 10 Jul 2024 20:53:39 +0800 Subject: [PATCH 004/218] feat: BrushFlow v3.4 --- package.json | 3 ++- plugins/brushflow/__init__.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 348f06a..a76e7c3 100644 --- a/package.json +++ b/package.json @@ -355,11 +355,12 @@ "name": "站点刷流", "description": "自动托管刷流,将会提高对应站点的访问频率。", "labels": "刷流,仪表板", - "version": "3.3", + "version": "3.4", "icon": "brush.jpg", "author": "jxxghp,InfinityPacer", "level": 2, "history": { + "v3.4": "移除「记录更多日志」配置项并调整为DEBUG日志,支持「删除排除标签」配置项,增加刷流任务时支持触发插件事件", "v3.3": "支持QB删除种子时强制汇报Tracker,站点独立配置增加「站点全局H&R」配置项", "v3.2": "支持推送QB种子时启用「先下载首尾文件块」选项", "v3.1": "支持仪表板显示站点刷流数据,需要主程序升级v1.8.7+版本", diff --git a/plugins/brushflow/__init__.py b/plugins/brushflow/__init__.py index a92d1d4..bdc0b9a 100644 --- a/plugins/brushflow/__init__.py +++ b/plugins/brushflow/__init__.py @@ -258,7 +258,7 @@ class BrushFlow(_PluginBase): # 插件图标 plugin_icon = "brush.jpg" # 插件版本 - plugin_version = "3.3" + plugin_version = "3.4" # 插件作者 plugin_author = "jxxghp,InfinityPacer" # 作者主页 From e4b95eb0a1a6601e0b4fe61a06f00365f2fa99b5 Mon Sep 17 00:00:00 2001 From: xuzhi Date: Fri, 12 Jul 2024 06:38:59 +0000 Subject: [PATCH 005/218] Update speedlimiter to v1.2 --- package.json | 7 +++-- plugins/speedlimiter/__init__.py | 44 +++++++++++++++++++++++++++++--- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index a76e7c3..28dc65b 100644 --- a/package.json +++ b/package.json @@ -169,10 +169,13 @@ "name": "播放限速", "description": "外网播放媒体库视频时,自动对下载器进行限速。", "labels": "网络", - "version": "1.1", + "version": "1.2", "icon": "Librespeed_A.png", "author": "Shurelol", - "level": 1 + "level": 1, + "history": { + "v1.2": "增加不限速路径配置,以应对网盘直链播放的情况" + } }, "CloudflareSpeedTest": { "name": "Cloudflare IP优选", diff --git a/plugins/speedlimiter/__init__.py b/plugins/speedlimiter/__init__.py index 4700c6c..517f683 100644 --- a/plugins/speedlimiter/__init__.py +++ b/plugins/speedlimiter/__init__.py @@ -23,7 +23,7 @@ class SpeedLimiter(_PluginBase): # 插件图标 plugin_icon = "Librespeed_A.png" # 插件版本 - plugin_version = "1.1" + plugin_version = "1.2" # 插件作者 plugin_author = "Shurelol" # 作者主页 @@ -55,6 +55,7 @@ class SpeedLimiter(_PluginBase): _unlimited_ips = {} # 当前限速状态 _current_state = "" + _exclude_path = "" def init_plugin(self, config: dict = None): # 读取配置 @@ -66,6 +67,8 @@ class SpeedLimiter(_PluginBase): self._noplay_up_speed = float(config.get("noplay_up_speed")) if config.get("noplay_up_speed") else 0 self._noplay_down_speed = float(config.get("noplay_down_speed")) if config.get("noplay_down_speed") else 0 self._current_state = f"U:{self._noplay_up_speed},D:{self._noplay_down_speed}" + self._exclude_path = config.get("exclude_path") + try: # 总带宽 self._bandwidth = int(float(config.get("bandwidth") or 0)) * 1000000 @@ -355,6 +358,23 @@ class SpeedLimiter(_PluginBase): } } ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'exclude_path', + 'label': '不限速路径', + 'placeholder': '包含该路径的媒体不限速,多个请换行' + } + } + ] } ] } @@ -371,7 +391,8 @@ class SpeedLimiter(_PluginBase): "bandwidth": None, "allocation_ratio": "", "ipv4": "", - "ipv6": "" + "ipv6": "", + "exclude_path": "" } def get_page(self) -> List[dict]: @@ -415,7 +436,9 @@ class SpeedLimiter(_PluginBase): sessions = res.json() for session in sessions: if session.get("NowPlayingItem") and not session.get("PlayState", {}).get("IsPaused"): - playing_sessions.append(session) + if not self.__path_execluded(session.get("NowPlayingItem").get("Path")): + playing_sessions.append(session) + except Exception as e: logger.error(f"获取Emby播放会话失败:{str(e)}") continue @@ -438,7 +461,8 @@ class SpeedLimiter(_PluginBase): sessions = res.json() for session in sessions: if session.get("NowPlayingItem") and not session.get("PlayState", {}).get("IsPaused"): - playing_sessions.append(session) + if not self.__path_execluded(session.get("NowPlayingItem").get("Path")): + playing_sessions.append(session) except Exception as e: logger.error(f"获取Jellyfin播放会话失败:{str(e)}") continue @@ -495,6 +519,18 @@ class SpeedLimiter(_PluginBase): self.__set_limiter(limit_type="未播放", upload_limit=self._noplay_up_speed, download_limit=self._noplay_down_speed) + def __path_execluded(self, path: str) -> bool: + """ + 判断是否在不限速路径内 + """ + if self._exclude_path: + exclude_paths = self._exclude_path.split("\n") + for exclude_path in exclude_paths: + if exclude_path in path: + logger.info(f"{path} 在不限速路径:{exclude_path} 内,跳过限速") + return True + return False + def __calc_limit(self, total_bit_rate: float) -> float: """ 计算智能上传限速 From f3a0cdba2ef6e5efa041f7c749d38fb6fd0da49b Mon Sep 17 00:00:00 2001 From: Doubly <1286398734@qq.com> Date: Wed, 24 Jul 2024 12:51:21 +0800 Subject: [PATCH 006/218] =?UTF-8?q?fix=EF=BC=9A=E4=BF=AE=E5=A4=8D=E6=B5=B7?= =?UTF-8?q?=E8=83=86=E7=AD=BE=E5=88=B0=E5=A4=B1=E8=B4=A5=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 ++- plugins/autosignin/__init__.py | 2 +- plugins/autosignin/sites/haidan.py | 10 +++++++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 28dc65b..f7a5138 100644 --- a/package.json +++ b/package.json @@ -3,11 +3,12 @@ "name": "站点自动签到", "description": "自动模拟登录、签到站点。", "labels": "站点", - "version": "2.4", + "version": "2.4.1", "icon": "signin.png", "author": "thsrite", "level": 2, "history": { + "v2.4.1": "修复海胆签到失败问题", "v2.4": "适配m-team Api地址变化", "v2.3.2": "修复YemaPT登录失败,支持YemaPT自动签到", "v2.3.1": "修复签到报错问题", diff --git a/plugins/autosignin/__init__.py b/plugins/autosignin/__init__.py index 1c9a5a3..a3d21d0 100644 --- a/plugins/autosignin/__init__.py +++ b/plugins/autosignin/__init__.py @@ -38,7 +38,7 @@ class AutoSignIn(_PluginBase): # 插件图标 plugin_icon = "signin.png" # 插件版本 - plugin_version = "2.4" + plugin_version = "2.4.1" # 插件作者 plugin_author = "thsrite" # 作者主页 diff --git a/plugins/autosignin/sites/haidan.py b/plugins/autosignin/sites/haidan.py index 38a4af3..23f6b03 100644 --- a/plugins/autosignin/sites/haidan.py +++ b/plugins/autosignin/sites/haidan.py @@ -39,7 +39,15 @@ class HaiDan(_ISiteSigninHandler): render = site_info.get("render") # 签到 - html_text = self.get_page_source(url='https://www.haidan.video/signin.php', + # 签到页会重定向到index.php,由于302重定向特性,导致index.php没有携带cookie + self.get_page_source(url='https://www.haidan.video/signin.php', + cookie=site_cookie, + ua=ua, + proxy=proxy, + render=render) + + # 重新携带cookie获取index.php查看签到结果 + html_text = self.get_page_source(url='https://www.haidan.video/index.php', cookie=site_cookie, ua=ua, proxy=proxy, From 5a482b31bf8ab388644346c4d5820823ada64f24 Mon Sep 17 00:00:00 2001 From: Doubly <1286398734@qq.com> Date: Wed, 24 Jul 2024 14:58:25 +0800 Subject: [PATCH 007/218] =?UTF-8?q?fix=EF=BC=9A=E4=BF=AE=E5=A4=8D=E9=87=8E?= =?UTF-8?q?=E9=A9=AC=E6=95=B0=E6=8D=AE=E7=BB=9F=E8=AE=A1=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 ++- plugins/sitestatistic/__init__.py | 2 +- plugins/sitestatistic/siteuserinfo/yema.py | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index f7a5138..0af7ba9 100644 --- a/package.json +++ b/package.json @@ -32,11 +32,12 @@ "name": "站点数据统计", "description": "自动统计和展示站点数据。", "labels": "站点,仪表板", - "version": "3.9.1", + "version": "3.9.2", "icon": "statistic.png", "author": "lightolly", "level": 2, "history": { + "v3.9.2": "修复YemaPT的上传下载统计错误", "v3.9.1": "修复mteam域名地址", "v3.9": "修复YemaPT站点数据统计", "v3.8": "适配m-team Api地址变化", diff --git a/plugins/sitestatistic/__init__.py b/plugins/sitestatistic/__init__.py index 752ee13..2002db8 100644 --- a/plugins/sitestatistic/__init__.py +++ b/plugins/sitestatistic/__init__.py @@ -43,7 +43,7 @@ class SiteStatistic(_PluginBase): # 插件图标 plugin_icon = "statistic.png" # 插件版本 - plugin_version = "3.9.1" + plugin_version = "3.9.2" # 插件作者 plugin_author = "lightolly" # 作者主页 diff --git a/plugins/sitestatistic/siteuserinfo/yema.py b/plugins/sitestatistic/siteuserinfo/yema.py index 636d55e..44a23d7 100644 --- a/plugins/sitestatistic/siteuserinfo/yema.py +++ b/plugins/sitestatistic/siteuserinfo/yema.py @@ -62,8 +62,8 @@ class TYemaSiteUserInfo(ISiteUserInfo): self.user_level = user_info.get("level") self.join_at = StringUtils.unify_datetime_str(user_info.get("registerTime")) - self.upload = user_info.get('uploadSize') - self.download = user_info.get('downloadSize') + self.upload = user_info.get('promotionUploadSize') + self.download = user_info.get('promotionDownloadSize') self.ratio = round(self.upload / (self.download or 1), 2) self.bonus = user_info.get("bonus") self.message_unread = 0 From bb84d573dcf901813489256fd471f4eeec44dc9b Mon Sep 17 00:00:00 2001 From: Doubly <1286398734@qq.com> Date: Wed, 24 Jul 2024 15:49:23 +0800 Subject: [PATCH 008/218] =?UTF-8?q?fix=EF=BC=9A=E4=BF=AE=E5=A4=8DPTT?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=AD=89=E7=BA=A7=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 ++- plugins/sitestatistic/__init__.py | 2 +- plugins/sitestatistic/siteuserinfo/nexus_php.py | 6 ++++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 0af7ba9..3841555 100644 --- a/package.json +++ b/package.json @@ -32,11 +32,12 @@ "name": "站点数据统计", "description": "自动统计和展示站点数据。", "labels": "站点,仪表板", - "version": "3.9.2", + "version": "3.9.3", "icon": "statistic.png", "author": "lightolly", "level": 2, "history": { + "v3.9.3": "修复PTT的用户等级统计", "v3.9.2": "修复YemaPT的上传下载统计错误", "v3.9.1": "修复mteam域名地址", "v3.9": "修复YemaPT站点数据统计", diff --git a/plugins/sitestatistic/__init__.py b/plugins/sitestatistic/__init__.py index 2002db8..90b2218 100644 --- a/plugins/sitestatistic/__init__.py +++ b/plugins/sitestatistic/__init__.py @@ -43,7 +43,7 @@ class SiteStatistic(_PluginBase): # 插件图标 plugin_icon = "statistic.png" # 插件版本 - plugin_version = "3.9.2" + plugin_version = "3.9.3" # 插件作者 plugin_author = "lightolly" # 作者主页 diff --git a/plugins/sitestatistic/siteuserinfo/nexus_php.py b/plugins/sitestatistic/siteuserinfo/nexus_php.py index e5efd06..c1deced 100644 --- a/plugins/sitestatistic/siteuserinfo/nexus_php.py +++ b/plugins/sitestatistic/siteuserinfo/nexus_php.py @@ -340,6 +340,12 @@ class NexusPhpSiteUserInfo(ISiteUserInfo): self.user_level = user_levels_text[0].xpath("string(.)").strip() return + # 适配PTT用户等级 + user_levels_text = html.xpath('//tr/td[text()="用户等级"]/following-sibling::td[1]/b/@title') + if user_levels_text: + self.user_level = user_levels_text[0].strip() + return + user_levels_text = html.xpath('//a[contains(@href, "userdetails")]/text()') if not self.user_level and user_levels_text: for user_level_text in user_levels_text: From c4b9221ffd2aed7f380c2414408c676e46fdc27a Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sun, 28 Jul 2024 05:09:40 +0800 Subject: [PATCH 009/218] =?UTF-8?q?refactor(brushflow):=20=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=E3=80=8C=E5=88=A0=E7=A7=8D=E6=8E=92=E9=99=A4MoviePilo?= =?UTF-8?q?t=E4=BB=BB=E5=8A=A1=E3=80=8D=E9=85=8D=E7=BD=AE=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/brushflow/__init__.py | 30 ++++-------------------------- 1 file changed, 4 insertions(+), 26 deletions(-) diff --git a/plugins/brushflow/__init__.py b/plugins/brushflow/__init__.py index bdc0b9a..e61c052 100644 --- a/plugins/brushflow/__init__.py +++ b/plugins/brushflow/__init__.py @@ -67,7 +67,6 @@ class BrushConfig: self.save_path = config.get("save_path") self.clear_task = config.get("clear_task", False) self.archive_task = config.get("archive_task", False) - self.except_tags = config.get("except_tags", True) self.delete_except_tags = config.get("delete_except_tags") self.except_subscribe = config.get("except_subscribe", True) self.brush_sequential = config.get("brush_sequential", False) @@ -1485,8 +1484,8 @@ class BrushFlow(_PluginBase): { 'component': 'VSwitch', 'props': { - 'model': 'except_tags', - 'label': '删种排除MoviePilot任务', + 'model': 'except_subscribe', + 'label': '排除订阅(实验性功能)', } } ] @@ -1501,8 +1500,8 @@ class BrushFlow(_PluginBase): { 'component': 'VSwitch', 'props': { - 'model': 'except_subscribe', - 'label': '排除订阅(实验性功能)', + 'model': 'qb_first_last_piece', + 'label': '优先下载首尾文件块', } } ] @@ -1649,22 +1648,6 @@ class BrushFlow(_PluginBase): } } ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'qb_first_last_piece', - 'label': '优先下载首尾文件块', - } - } - ] } ] } @@ -1837,7 +1820,6 @@ class BrushFlow(_PluginBase): "onlyonce": False, "clear_task": False, "archive_task": False, - "except_tags": True, "delete_except_tags": f"{settings.TORRENT_TAG},H&R" if settings.TORRENT_TAG else "H&R", "except_subscribe": True, "brush_sequential": False, @@ -2369,9 +2351,6 @@ class BrushFlow(_PluginBase): logger.info(f"当前刷流任务共 {len(check_torrents)} 个有效种子,正在准备按设定的种子标签进行排除") # 初始化一个空的列表来存储需要排除的标签 tags_to_exclude = set() - # 如果 except_tags 配置为 True,将 settings.TORRENT_TAG 添加到排除列表中(前提是它不为空且不是纯空白) - if brush_config.except_tags and settings.TORRENT_TAG.strip(): - tags_to_exclude.add(settings.TORRENT_TAG.strip()) # 如果 delete_except_tags 非空且不是纯空白,则添加到排除列表中 if brush_config.delete_except_tags and brush_config.delete_except_tags.strip(): tags_to_exclude.update(tag.strip() for tag in brush_config.delete_except_tags.split(',')) @@ -3042,7 +3021,6 @@ class BrushFlow(_PluginBase): "save_path": brush_config.save_path, "clear_task": brush_config.clear_task, "archive_task": brush_config.archive_task, - "except_tags": brush_config.except_tags, "delete_except_tags": brush_config.delete_except_tags, "except_subscribe": brush_config.except_subscribe, "brush_sequential": brush_config.brush_sequential, From 42336827ecf178568d5a9eb262fc9e6ce461f15c Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sun, 28 Jul 2024 05:10:20 +0800 Subject: [PATCH 010/218] =?UTF-8?q?fix(brushflow):=20event=5Fdata=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=A7=8D=E5=AD=90hash=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/brushflow/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/brushflow/__init__.py b/plugins/brushflow/__init__.py index e61c052..6b37e10 100644 --- a/plugins/brushflow/__init__.py +++ b/plugins/brushflow/__init__.py @@ -2105,6 +2105,7 @@ class BrushFlow(_PluginBase): self.eventmanager.send_event(etype=EventType.PluginAction, data={ "action": "brushflow_download_added", + "hash": hash_string, "data": torrent_task }) torrent_tasks[hash_string] = torrent_task From 493375ecbb1c1daffc759caf87bd2c461d461dde Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sun, 28 Jul 2024 05:11:28 +0800 Subject: [PATCH 011/218] fix BrushFlow v3.5 --- package.json | 5 +++-- plugins/brushflow/__init__.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index a76e7c3..548c3c9 100644 --- a/package.json +++ b/package.json @@ -355,12 +355,13 @@ "name": "站点刷流", "description": "自动托管刷流,将会提高对应站点的访问频率。", "labels": "刷流,仪表板", - "version": "3.4", + "version": "3.5", "icon": "brush.jpg", "author": "jxxghp,InfinityPacer", "level": 2, "history": { - "v3.4": "移除「记录更多日志」配置项并调整为DEBUG日志,支持「删除排除标签」配置项,增加刷流任务时支持触发插件事件", + "v3.5": "移除「删种排除MoviePilot任务」配置项(请使用「删除排除标签」替代),完善刷流任务触发插件事件相关逻辑(联动H&R助手)", + "v3.4": "移除「记录更多日志」配置项并调整为DEBUG日志,支持「删除排除标签」配置项,增加刷流任务时支持触发插件事件(联动H&R助手)", "v3.3": "支持QB删除种子时强制汇报Tracker,站点独立配置增加「站点全局H&R」配置项", "v3.2": "支持推送QB种子时启用「先下载首尾文件块」选项", "v3.1": "支持仪表板显示站点刷流数据,需要主程序升级v1.8.7+版本", diff --git a/plugins/brushflow/__init__.py b/plugins/brushflow/__init__.py index 6b37e10..b9025a1 100644 --- a/plugins/brushflow/__init__.py +++ b/plugins/brushflow/__init__.py @@ -257,7 +257,7 @@ class BrushFlow(_PluginBase): # 插件图标 plugin_icon = "brush.jpg" # 插件版本 - plugin_version = "3.4" + plugin_version = "3.5" # 插件作者 plugin_author = "jxxghp,InfinityPacer" # 作者主页 From c073adcdbdac8a3c622c5a22ed36713ea1b029fc Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Thu, 1 Aug 2024 16:38:35 +0800 Subject: [PATCH 012/218] =?UTF-8?q?fix(brushflow):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E6=9C=8D=E5=8A=A1=E4=B8=AD=E7=9A=84=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E7=AE=A1=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/brushflow/__init__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/plugins/brushflow/__init__.py b/plugins/brushflow/__init__.py index b9025a1..9d31773 100644 --- a/plugins/brushflow/__init__.py +++ b/plugins/brushflow/__init__.py @@ -2306,10 +2306,6 @@ class BrushFlow(_PluginBase): if not brush_config.downloader: return - if not self.__is_current_time_in_range(): - logger.info(f"当前不在指定的刷流时间区间内,检查操作将暂时暂停") - return - with lock: logger.info("开始检查刷流下载任务 ...") torrent_tasks: Dict[str, dict] = self.get_data("torrents") or {} From 6f6dd71fe9dc8b5b4315edefb5b3b11a5551595d Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Thu, 1 Aug 2024 16:39:53 +0800 Subject: [PATCH 013/218] fix BrushFlow v3.6 --- package.json | 3 ++- plugins/brushflow/__init__.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index b08f9b0..e118836 100644 --- a/package.json +++ b/package.json @@ -361,11 +361,12 @@ "name": "站点刷流", "description": "自动托管刷流,将会提高对应站点的访问频率。", "labels": "刷流,仪表板", - "version": "3.5", + "version": "3.6", "icon": "brush.jpg", "author": "jxxghp,InfinityPacer", "level": 2, "history": { + "v3.6": "优化检查服务中的时间管控", "v3.5": "移除「删种排除MoviePilot任务」配置项(请使用「删除排除标签」替代),完善刷流任务触发插件事件相关逻辑(联动H&R助手)", "v3.4": "移除「记录更多日志」配置项并调整为DEBUG日志,支持「删除排除标签」配置项,增加刷流任务时支持触发插件事件(联动H&R助手)", "v3.3": "支持QB删除种子时强制汇报Tracker,站点独立配置增加「站点全局H&R」配置项", diff --git a/plugins/brushflow/__init__.py b/plugins/brushflow/__init__.py index 9d31773..656a013 100644 --- a/plugins/brushflow/__init__.py +++ b/plugins/brushflow/__init__.py @@ -257,7 +257,7 @@ class BrushFlow(_PluginBase): # 插件图标 plugin_icon = "brush.jpg" # 插件版本 - plugin_version = "3.5" + plugin_version = "3.6" # 插件作者 plugin_author = "jxxghp,InfinityPacer" # 作者主页 From 74015b9b59220acd8011b95355ef23fc08167ec6 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sat, 3 Aug 2024 21:24:16 +0800 Subject: [PATCH 014/218] =?UTF-8?q?fix(SiteStatistic):=20=E9=AD=94?= =?UTF-8?q?=E5=8A=9B=E5=80=BC=E5=AE=B9=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/sitestatistic/__init__.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/plugins/sitestatistic/__init__.py b/plugins/sitestatistic/__init__.py index 90b2218..c1fb3d0 100644 --- a/plugins/sitestatistic/__init__.py +++ b/plugins/sitestatistic/__init__.py @@ -14,8 +14,7 @@ from ruamel.yaml import CommentedMap from app import schemas from app.core.config import settings -from app.core.event import Event -from app.core.event import eventmanager +from app.core.event import Event, eventmanager from app.db.models import PluginData from app.db.site_oper import SiteOper from app.helper.browser import PlaywrightHelper @@ -931,6 +930,12 @@ class SiteStatistic(_PluginBase): 拼装插件详情页面,需要返回页面配置,同时附带数据 """ + def format_bonus(bonus): + try: + return f'{float(bonus):,.1f}' + except ValueError: + return '0.0' + # 获取数据 today, stattistic_data, yesterday_sites_data = self.__get_data() if not stattistic_data: @@ -995,7 +1000,7 @@ class SiteStatistic(_PluginBase): }, { 'component': 'td', - 'text': '{:,.1f}'.format(data.get('bonus') or 0) + 'text': format_bonus(data.get('bonus') or 0) }, { 'component': 'td', From 4ef08748e4fadd3ca46d15a2b85df24dadf5534a Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sun, 4 Aug 2024 10:39:33 +0800 Subject: [PATCH 015/218] fix SiteStatistic v4.0 --- package.json | 5 +++-- plugins/sitestatistic/__init__.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index e118836..f3ecb75 100644 --- a/package.json +++ b/package.json @@ -32,11 +32,12 @@ "name": "站点数据统计", "description": "自动统计和展示站点数据。", "labels": "站点,仪表板", - "version": "3.9.3", + "version": "4.0", "icon": "statistic.png", "author": "lightolly", "level": 2, "history": { + "v4.0": "修复插件数据页异常", "v3.9.3": "修复PTT的用户等级统计", "v3.9.2": "修复YemaPT的上传下载统计错误", "v3.9.1": "修复mteam域名地址", @@ -271,7 +272,7 @@ "author": "thsrite", "level": 1, "history": { - "v1.3":"去除已废弃的环境变量引用", + "v1.3": "去除已废弃的环境变量引用", "v1.2": "增强API安全性" } }, diff --git a/plugins/sitestatistic/__init__.py b/plugins/sitestatistic/__init__.py index c1fb3d0..2469e00 100644 --- a/plugins/sitestatistic/__init__.py +++ b/plugins/sitestatistic/__init__.py @@ -42,7 +42,7 @@ class SiteStatistic(_PluginBase): # 插件图标 plugin_icon = "statistic.png" # 插件版本 - plugin_version = "3.9.3" + plugin_version = "4.0" # 插件作者 plugin_author = "lightolly" # 作者主页 From 7c9fb487fe4460ebd9ba7fb118a92914b1256e66 Mon Sep 17 00:00:00 2001 From: DDSRem <73049927+DDSRem@users.noreply.github.com> Date: Sat, 10 Aug 2024 13:07:50 +0800 Subject: [PATCH 016/218] =?UTF-8?q?fix(sites):=20PT=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E7=AD=BE=E5=88=B0=E5=A4=B1=E8=B4=A5=20&=20PT=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E9=AD=94=E5=8A=9B=E5=80=BC=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 6 +- plugins/autosignin/__init__.py | 2 +- plugins/autosignin/sites/pttime.py | 64 +++++++++++++++++++ plugins/sitestatistic/__init__.py | 2 +- .../sitestatistic/siteuserinfo/nexus_php.py | 2 +- 5 files changed, 71 insertions(+), 5 deletions(-) create mode 100644 plugins/autosignin/sites/pttime.py diff --git a/package.json b/package.json index f3ecb75..9bdf16a 100644 --- a/package.json +++ b/package.json @@ -3,11 +3,12 @@ "name": "站点自动签到", "description": "自动模拟登录、签到站点。", "labels": "站点", - "version": "2.4.1", + "version": "2.4.2", "icon": "signin.png", "author": "thsrite", "level": 2, "history": { + "v2.4.2": "修复PT时间签到失败问题", "v2.4.1": "修复海胆签到失败问题", "v2.4": "适配m-team Api地址变化", "v2.3.2": "修复YemaPT登录失败,支持YemaPT自动签到", @@ -32,11 +33,12 @@ "name": "站点数据统计", "description": "自动统计和展示站点数据。", "labels": "站点,仪表板", - "version": "4.0", + "version": "4.0.1", "icon": "statistic.png", "author": "lightolly", "level": 2, "history": { + "v4.0.1": "修复PTT的魔力值统计", "v4.0": "修复插件数据页异常", "v3.9.3": "修复PTT的用户等级统计", "v3.9.2": "修复YemaPT的上传下载统计错误", diff --git a/plugins/autosignin/__init__.py b/plugins/autosignin/__init__.py index a3d21d0..d81ee4d 100644 --- a/plugins/autosignin/__init__.py +++ b/plugins/autosignin/__init__.py @@ -38,7 +38,7 @@ class AutoSignIn(_PluginBase): # 插件图标 plugin_icon = "signin.png" # 插件版本 - plugin_version = "2.4.1" + plugin_version = "2.4.2" # 插件作者 plugin_author = "thsrite" # 作者主页 diff --git a/plugins/autosignin/sites/pttime.py b/plugins/autosignin/sites/pttime.py new file mode 100644 index 0000000..6c766d2 --- /dev/null +++ b/plugins/autosignin/sites/pttime.py @@ -0,0 +1,64 @@ +from typing import Tuple + +from ruamel.yaml import CommentedMap + +from app.log import logger +from app.plugins.autosignin.sites import _ISiteSigninHandler +from app.utils.string import StringUtils + + +class PTTime(_ISiteSigninHandler): + """ + PT时间签到 + """ + # 匹配的站点Url,每一个实现类都需要设置为自己的站点Url + site_url = "pttime.org" + + # 签到成功 + _succeed_regex = ['签到成功'] + + @classmethod + def match(cls, url: str) -> bool: + """ + 根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可 + :param url: 站点Url + :return: 是否匹配,如匹配则会调用该类的signin方法 + """ + return True if StringUtils.url_equal(url, cls.site_url) else False + + def signin(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行签到操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 签到结果信息 + """ + site = site_info.get("name") + site_cookie = site_info.get("cookie") + ua = site_info.get("ua") + proxy = site_info.get("proxy") + render = site_info.get("render") + + # 签到 + # 签到返回:签到成功 + html_text = self.get_page_source(url='https://www.pttime.org/attendance.php', + cookie=site_cookie, + ua=ua, + proxy=proxy, + render=render) + + if not html_text: + logger.error(f"{site} 签到失败,请检查站点连通性") + return False, '签到失败,请检查站点连通性' + + if "login.php" in html_text: + logger.error(f"{site} 签到失败,Cookie已失效") + return False, '签到失败,Cookie已失效' + + sign_status = self.sign_in_result(html_res=html_text, + regexs=self._succeed_regex) + if sign_status: + logger.info(f"{site} 签到成功") + return True, '签到成功' + + logger.error(f"{site} 签到失败,签到接口返回 {html_text}") + return False, '签到失败' diff --git a/plugins/sitestatistic/__init__.py b/plugins/sitestatistic/__init__.py index 2469e00..9760734 100644 --- a/plugins/sitestatistic/__init__.py +++ b/plugins/sitestatistic/__init__.py @@ -42,7 +42,7 @@ class SiteStatistic(_PluginBase): # 插件图标 plugin_icon = "statistic.png" # 插件版本 - plugin_version = "4.0" + plugin_version = "4.0.1" # 插件作者 plugin_author = "lightolly" # 作者主页 diff --git a/plugins/sitestatistic/siteuserinfo/nexus_php.py b/plugins/sitestatistic/siteuserinfo/nexus_php.py index c1deced..13b357b 100644 --- a/plugins/sitestatistic/siteuserinfo/nexus_php.py +++ b/plugins/sitestatistic/siteuserinfo/nexus_php.py @@ -118,7 +118,7 @@ class NexusPhpSiteUserInfo(ISiteUserInfo): if bonus_match and bonus_match.group(1).strip(): self.bonus = StringUtils.str_float(bonus_match.group(1)) return - bonus_match = re.search(r"mybonus.[\[\]::<>/a-zA-Z_\-=\"'\s#;.(使用魔力值豆]+\s*([\d,.]+)[<()&\s]", html_text) + bonus_match = re.search(r"mybonus.[\[\]::<>/a-zA-Z_\-=\"'\s#;.(使用&说明魔力值豆]+\s*([\d,.]+)[\[<()&\s]", html_text) try: if bonus_match and bonus_match.group(1).strip(): self.bonus = StringUtils.str_float(bonus_match.group(1)) From c49e2106971ca839260bcd96b4f4c6cdd9c10be6 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Tue, 13 Aug 2024 19:02:58 +0800 Subject: [PATCH 017/218] =?UTF-8?q?feat(CustomHosts):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=86=99=E5=85=A5Hosts=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/customhosts/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/plugins/customhosts/__init__.py b/plugins/customhosts/__init__.py index 849159f..d776995 100644 --- a/plugins/customhosts/__init__.py +++ b/plugins/customhosts/__init__.py @@ -235,6 +235,12 @@ class CustomHosts(_PluginBase): for host in hosts: if not host: continue + host = host.strip() + if host.startswith('#'): # 检查是否为注释行 + host_entry = HostsEntry(entry_type='comment', comment=host) + new_entrys.append(host_entry) + continue + host_arr = str(host).split() try: host_entry = HostsEntry(entry_type='ipv4' if IpUtils.is_ipv4(str(host_arr[0])) else 'ipv6', From bf2a1ea6f0bf7439298c3dd850d3fc223beea197 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Tue, 13 Aug 2024 19:04:24 +0800 Subject: [PATCH 018/218] feat: CustomHosts v1.2 --- package.json | 3 ++- plugins/customhosts/__init__.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index f3ecb75..9c8ba5c 100644 --- a/package.json +++ b/package.json @@ -161,11 +161,12 @@ "name": "自定义Hosts", "description": "修改系统hosts文件,加速网络访问。", "labels": "网络", - "version": "1.1", + "version": "1.2", "icon": "hosts.png", "author": "thsrite", "level": 1, "history": { + "v1.2": "支持写入注释", "v1.1": "关闭插件时自动恢复系统hosts" } }, diff --git a/plugins/customhosts/__init__.py b/plugins/customhosts/__init__.py index d776995..ae0fe6a 100644 --- a/plugins/customhosts/__init__.py +++ b/plugins/customhosts/__init__.py @@ -18,7 +18,7 @@ class CustomHosts(_PluginBase): # 插件图标 plugin_icon = "hosts.png" # 插件版本 - plugin_version = "1.1" + plugin_version = "1.2" # 插件作者 plugin_author = "thsrite" # 作者主页 From b7d084148d9e0b1436f660ef4fc4cf6e1598d67b Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Thu, 15 Aug 2024 17:49:00 +0800 Subject: [PATCH 019/218] =?UTF-8?q?fix(BrushFlow):=20=E8=8E=B7=E5=8F=96?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E6=95=B0=E9=87=8F=E8=B0=83=E6=95=B4=E4=B8=BA?= =?UTF-8?q?=E4=BB=85=E8=8E=B7=E5=8F=96=E5=88=B7=E6=B5=81=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E7=A7=8D=E5=AD=90=EF=BC=8C=E5=B9=B6=E5=AE=B9=E9=94=99=E5=A4=84?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/brushflow/__init__.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/plugins/brushflow/__init__.py b/plugins/brushflow/__init__.py index 656a013..74151ab 100644 --- a/plugins/brushflow/__init__.py +++ b/plugins/brushflow/__init__.py @@ -3641,12 +3641,21 @@ class BrushFlow(_PluginBase): """ 获取正在下载的任务数量 """ - brush_config = self.__get_brush_config() - downloader = self.__get_downloader(brush_config.downloader) - if not downloader: + try: + brush_config = self.__get_brush_config() + downloader = self.__get_downloader(brush_config.downloader) + if not downloader: + return 0 + + torrents = downloader.get_downloading_torrents(tags=brush_config.brush_tag) + if torrents is None: + logger.warn("获取下载数量失败,可能是下载器连接发生异常") + return 0 + + return len(torrents) + except Exception as e: + logger.error(f"获取下载数量发生异常: {e}") return 0 - torrents = downloader.get_downloading_torrents() - return len(torrents) or 0 @staticmethod def __get_pubminutes(pubdate: str) -> float: From f67b0ae460bcfbf0d781f0452d8ea62b18db2054 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Thu, 15 Aug 2024 17:50:45 +0800 Subject: [PATCH 020/218] fix BrushFlow v3.7 --- package.json | 3 ++- plugins/brushflow/__init__.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 9c8ba5c..7830905 100644 --- a/package.json +++ b/package.json @@ -363,11 +363,12 @@ "name": "站点刷流", "description": "自动托管刷流,将会提高对应站点的访问频率。", "labels": "刷流,仪表板", - "version": "3.6", + "version": "3.7", "icon": "brush.jpg", "author": "jxxghp,InfinityPacer", "level": 2, "history": { + "v3.7": "下载数量调整为仅获取刷流标签种子并修复了一些细节问题", "v3.6": "优化检查服务中的时间管控", "v3.5": "移除「删种排除MoviePilot任务」配置项(请使用「删除排除标签」替代),完善刷流任务触发插件事件相关逻辑(联动H&R助手)", "v3.4": "移除「记录更多日志」配置项并调整为DEBUG日志,支持「删除排除标签」配置项,增加刷流任务时支持触发插件事件(联动H&R助手)", diff --git a/plugins/brushflow/__init__.py b/plugins/brushflow/__init__.py index 74151ab..e3d45b6 100644 --- a/plugins/brushflow/__init__.py +++ b/plugins/brushflow/__init__.py @@ -257,7 +257,7 @@ class BrushFlow(_PluginBase): # 插件图标 plugin_icon = "brush.jpg" # 插件版本 - plugin_version = "3.6" + plugin_version = "3.7" # 插件作者 plugin_author = "jxxghp,InfinityPacer" # 作者主页 From 50e70edd6e91076e5be2785adea0b3b9e719df54 Mon Sep 17 00:00:00 2001 From: Lunatic Date: Sat, 17 Aug 2024 09:11:52 +0800 Subject: [PATCH 021/218] =?UTF-8?q?fix(torrenttransfer):=20=E8=BD=AC?= =?UTF-8?q?=E7=A7=BB=E6=97=B6=E7=9A=84=E4=BF=9D=E7=95=99=E5=85=A8=E9=83=A8?= =?UTF-8?q?tracker=E4=BB=A5=E6=8F=90=E9=AB=98=E5=8F=AF=E8=BE=BE=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复了在转移torrent时仅保留第一个tracker的问题,此更新确保所有tracker都被保留,从而提高在不同网络条件下的可达性。 --- package.json | 1 + plugins/torrenttransfer/__init__.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 38ba44a..d381d18 100644 --- a/package.json +++ b/package.json @@ -335,6 +335,7 @@ "author": "jxxghp", "level": 2, "history": { + "v1.5":"修复在转移时只保留了第一个tracker,导致红种问题。此修复确保保留所有的tracker,以提高在不同网络条件下的可达性。", "v1.4": "支持自动删除源下载器在目的下载器中存在的种子" } }, diff --git a/plugins/torrenttransfer/__init__.py b/plugins/torrenttransfer/__init__.py index c304a59..fa22714 100644 --- a/plugins/torrenttransfer/__init__.py +++ b/plugins/torrenttransfer/__init__.py @@ -27,7 +27,7 @@ class TorrentTransfer(_PluginBase): # 插件图标 plugin_icon = "seed.png" # 插件版本 - plugin_version = "1.4" + plugin_version = "1.5" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -724,6 +724,9 @@ class TorrentTransfer(_PluginBase): and fastresume_trackers[0]: # 重新赋值 torrent_main['announce'] = fastresume_trackers[0][0] + # 保留其他tracker,避免单一tracker无法连接 + if len(fastresume_trackers) > 1 or len(fastresume_trackers[0]) > 1: + torrent_main['announce-list'] = fastresume_trackers # 替换种子文件路径 torrent_file = settings.TEMP_PATH / f"{torrent_item.get('hash')}.torrent" # 编码并保存到临时文件 From 1af48f8203b11adc4b7b50c4d1e08780e063caa4 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 17 Aug 2024 10:00:30 +0800 Subject: [PATCH 022/218] fix #449 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d381d18..aef43ad 100644 --- a/package.json +++ b/package.json @@ -330,7 +330,7 @@ "name": "自动转移做种", "description": "定期转移下载器中的做种任务到另一个下载器。", "labels": "做种", - "version": "1.4", + "version": "1.5", "icon": "seed.png", "author": "jxxghp", "level": 2, From 31d0f4c3b67336b871c4a4596ebfc6bf84a89478 Mon Sep 17 00:00:00 2001 From: HankunYu Date: Thu, 22 Aug 2024 13:20:18 +0100 Subject: [PATCH 023/218] =?UTF-8?q?Fix=20iyuu=E6=B7=BB=E5=8A=A0=E5=90=8E?= =?UTF-8?q?=E4=B8=8D=E4=BC=9A=E8=87=AA=E5=8A=A8=E5=81=9A=E7=A7=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 ++- plugins/iyuuautoseed/__init__.py | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index aef43ad..63eabcd 100644 --- a/package.json +++ b/package.json @@ -283,11 +283,12 @@ "name": "IYUU自动辅种", "description": "基于IYUU官方Api实现自动辅种。", "labels": "做种,IYUU", - "version": "1.9.3", + "version": "1.9.4", "icon": "IYUU.png", "author": "jxxghp", "level": 2, "history": { + "v1.9.4": "修复qBittorrent辅种后不会自动开始做种", "v1.9.3": "修复Monika因缺少rsskey,种子下载失败的问题", "v1.9.2": "适配馒头使用API下载种子", "v1.9.1": "支持自定义辅种的种子分类", diff --git a/plugins/iyuuautoseed/__init__.py b/plugins/iyuuautoseed/__init__.py index 187a8c2..43682cf 100644 --- a/plugins/iyuuautoseed/__init__.py +++ b/plugins/iyuuautoseed/__init__.py @@ -34,7 +34,7 @@ class IYUUAutoSeed(_PluginBase): # 插件图标 plugin_icon = "IYUU.png" # 插件版本 - plugin_version = "1.9.3" + plugin_version = "1.9.4" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -967,6 +967,9 @@ class IYUUAutoSeed(_PluginBase): if downloader == "qbittorrent": # 开始校验种子 downloader_obj.recheck_torrents(ids=[download_id]) + # qbittorrent 添加种子后会标记为完成不会自动做种,需要手动开始 + if downloader == "qbittorrent": + downloader_obj.start_torrents(ids=[download_id]) # 下载成功 logger.info(f"成功添加辅种下载,站点:{site_info.get('name')},种子链接:{torrent_url}") # 成功也加入缓存,有一些改了路径校验不通过的,手动删除后,下一次又会辅上 From 7fef1f007a65ecf3cd84a8773d3e12f60f07d0e1 Mon Sep 17 00:00:00 2001 From: HankunYu Date: Thu, 22 Aug 2024 15:45:19 +0100 Subject: [PATCH 024/218] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=9D=A1=E4=BB=B6?= =?UTF-8?q?=E9=99=90=E5=88=B6=E4=B8=BA=E8=B7=B3=E8=BF=87=E6=A3=80=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/iyuuautoseed/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/plugins/iyuuautoseed/__init__.py b/plugins/iyuuautoseed/__init__.py index 43682cf..e0551a0 100644 --- a/plugins/iyuuautoseed/__init__.py +++ b/plugins/iyuuautoseed/__init__.py @@ -957,6 +957,9 @@ class IYUUAutoSeed(_PluginBase): if self._skipverify: # 跳过校验 logger.info(f"{download_id} 跳过校验,请自行检查...") + # qbittorrent 添加种子并跳过检验后会标记为完成不会自动做种,需要手动开始 + if downloader == "qbittorrent": + downloader_obj.start_torrents(ids=[download_id]) else: # 追加校验任务 logger.info(f"添加校验检查任务:{download_id} ...") @@ -967,9 +970,7 @@ class IYUUAutoSeed(_PluginBase): if downloader == "qbittorrent": # 开始校验种子 downloader_obj.recheck_torrents(ids=[download_id]) - # qbittorrent 添加种子后会标记为完成不会自动做种,需要手动开始 - if downloader == "qbittorrent": - downloader_obj.start_torrents(ids=[download_id]) + # 下载成功 logger.info(f"成功添加辅种下载,站点:{site_info.get('name')},种子链接:{torrent_url}") # 成功也加入缓存,有一些改了路径校验不通过的,手动删除后,下一次又会辅上 From bfae36dce8889c0ad0be26ef33a94f8a79e87b13 Mon Sep 17 00:00:00 2001 From: HankunYu Date: Thu, 22 Aug 2024 16:06:10 +0100 Subject: [PATCH 025/218] Update Correct words --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 63eabcd..ad8993f 100644 --- a/package.json +++ b/package.json @@ -288,7 +288,7 @@ "author": "jxxghp", "level": 2, "history": { - "v1.9.4": "修复qBittorrent辅种后不会自动开始做种", + "v1.9.4": "修复qbittorrent 添加种子并跳过检验后会标记为完成不会自动做种", "v1.9.3": "修复Monika因缺少rsskey,种子下载失败的问题", "v1.9.2": "适配馒头使用API下载种子", "v1.9.1": "支持自定义辅种的种子分类", From 7b6937a8b6fdb650cc7f83f2d7011a22e0efebd0 Mon Sep 17 00:00:00 2001 From: Doubly <1286398734@qq.com> Date: Fri, 23 Aug 2024 01:41:40 +0800 Subject: [PATCH 026/218] =?UTF-8?q?fix=EF=BC=9A=E4=BF=AE=E5=A4=8D=E6=97=B6?= =?UTF-8?q?=E5=8C=BA=E9=97=AE=E9=A2=98=E5=AF=BC=E8=87=B4=E7=9A=84=E4=B8=8A?= =?UTF-8?q?=E6=AC=A1=E5=90=8C=E6=AD=A5=E5=90=8E8h=E5=86=85=E7=9A=84?= =?UTF-8?q?=E7=A7=8D=E5=AD=90=E4=B8=8D=E5=90=8C=E6=AD=A5=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 7 +++++-- plugins/syncdownloadfiles/__init__.py | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index aef43ad..ab7eb9e 100644 --- a/package.json +++ b/package.json @@ -357,10 +357,13 @@ "name": "下载器文件同步", "description": "同步下载器的文件信息到数据库,删除文件时联动删除下载任务。", "labels": "下载管理", - "version": "1.1", + "version": "1.1.1", "icon": "Youtube-dl_A.png", "author": "thsrite", - "level": 1 + "level": 1, + "history": { + "v1.1.1": "修复时区问题导致的上次同步后8h内的种子不同步的问题" + } }, "BrushFlow": { "name": "站点刷流", diff --git a/plugins/syncdownloadfiles/__init__.py b/plugins/syncdownloadfiles/__init__.py index 32be61e..15c8a42 100644 --- a/plugins/syncdownloadfiles/__init__.py +++ b/plugins/syncdownloadfiles/__init__.py @@ -22,7 +22,7 @@ class SyncDownloadFiles(_PluginBase): # 插件图标 plugin_icon = "Youtube-dl_A.png" # 插件版本 - plugin_version = "1.1" + plugin_version = "1.1.1" # 插件作者 plugin_author = "thsrite" # 作者主页 @@ -265,7 +265,7 @@ class SyncDownloadFiles(_PluginBase): if last_sync_time: # 获取种子时间 if dl_tpe == "qbittorrent": - torrent_date = time.gmtime(torrent.get("added_on")) # 将时间戳转换为时间元组 + torrent_date = time.localtime(torrent.get("added_on")) # 将时间戳转换为时间元组 torrent_date = time.strftime("%Y-%m-%d %H:%M:%S", torrent_date) # 格式化时间 else: torrent_date = torrent.added_date From a90b49a6a1d7a668328d5d80b13e182f92595995 Mon Sep 17 00:00:00 2001 From: HankunYu Date: Sat, 24 Aug 2024 20:52:25 +0100 Subject: [PATCH 027/218] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E8=BE=85=E7=A7=8D=E4=B8=AD=E8=B7=B3=E8=BF=87=E6=A3=80=E9=AA=8C?= =?UTF-8?q?=E5=90=8E=E4=B8=8D=E4=BC=9A=E6=A3=80=E6=9F=A5=E7=A7=8D=E5=AD=90?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E5=BA=A6=E7=9B=B4=E6=8E=A5=E5=81=9A=E7=A7=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + plugins/iyuuautoseed/__init__.py | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 7048cc3..18a2837 100644 --- a/package.json +++ b/package.json @@ -288,6 +288,7 @@ "author": "jxxghp", "level": 2, "history": { + "v1.9.5": "修复跳过检验后不会检查种子完整度直接做种", "v1.9.4": "修复qbittorrent 添加种子并跳过检验后会标记为完成不会自动做种", "v1.9.3": "修复Monika因缺少rsskey,种子下载失败的问题", "v1.9.2": "适配馒头使用API下载种子", diff --git a/plugins/iyuuautoseed/__init__.py b/plugins/iyuuautoseed/__init__.py index e0551a0..128fad6 100644 --- a/plugins/iyuuautoseed/__init__.py +++ b/plugins/iyuuautoseed/__init__.py @@ -957,9 +957,12 @@ class IYUUAutoSeed(_PluginBase): if self._skipverify: # 跳过校验 logger.info(f"{download_id} 跳过校验,请自行检查...") - # qbittorrent 添加种子并跳过检验后会标记为完成不会自动做种,需要手动开始 + # qbittorrent 添加种子并跳过检验后会标记为完成不会自动做种 + # 加入校验种子列表,但是跳过校验 if downloader == "qbittorrent": - downloader_obj.start_torrents(ids=[download_id]) + if not self._recheck_torrents.get(downloader): + self._recheck_torrents[downloader] = [] + self._recheck_torrents[downloader].append(download_id) else: # 追加校验任务 logger.info(f"添加校验检查任务:{download_id} ...") From d1ac940e4d6d6c2b0d31e5753dd6ad7d4fc13668 Mon Sep 17 00:00:00 2001 From: nelson Date: Mon, 26 Aug 2024 14:37:22 +0800 Subject: [PATCH 028/218] =?UTF-8?q?=E7=94=B1=E4=BA=8Epushplus=E8=A7=84?= =?UTF-8?q?=E5=88=99=E6=9B=B4=E6=96=B0=EF=BC=8C=E6=89=80=E4=BB=A5=E5=9C=A8?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E9=A1=B5=E9=9D=A2=E6=B7=BB=E5=8A=A0=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E8=AF=B4=E6=98=8E=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/pushplusmsg/__init__.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/plugins/pushplusmsg/__init__.py b/plugins/pushplusmsg/__init__.py index 3d4485e..b5e287b 100644 --- a/plugins/pushplusmsg/__init__.py +++ b/plugins/pushplusmsg/__init__.py @@ -11,11 +11,11 @@ class PushPlusMsg(_PluginBase): # 插件名称 plugin_name = "PushPlus消息推送" # 插件描述 - plugin_desc = "支持使用PushPlus发送消息通知。" + plugin_desc = "支持使用PushPlus发送消息通知(需实名认证)。" # 插件图标 plugin_icon = "Pushplus_A.png" # 插件版本 - plugin_version = "1.0" + plugin_version = "1.1" # 插件作者 plugin_author = "cheng" # 作者主页 @@ -128,6 +128,27 @@ class PushPlusMsg(_PluginBase): } ] }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '由于pushplus规则更新,没有实名认证的用户无法发送消息,所以需要用户自己去官网进行认证。官网地址:https://www.pushplus.plus' + } + } + ] + } + ] + } ] } ], { From b8085690da320730f47b5ecfb67f30c6f8e7ee9c Mon Sep 17 00:00:00 2001 From: jxxghp Date: Mon, 26 Aug 2024 14:44:29 +0800 Subject: [PATCH 029/218] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20package.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 18a2837..82cbc86 100644 --- a/package.json +++ b/package.json @@ -536,7 +536,7 @@ "name": "PushPlus消息推送", "description": "支持使用PushPlus发送消息通知。", "labels": "消息通知", - "version": "1.0", + "version": "1.1", "icon": "Pushplus_A.png", "author": "cheng", "level": 1 From ed74a5be7745ba20ea3d33a9f06e13387b8fe04b Mon Sep 17 00:00:00 2001 From: Alex Liu Date: Tue, 27 Aug 2024 10:57:58 +0800 Subject: [PATCH 030/218] =?UTF-8?q?Revert=20"=E4=BF=AE=E5=A4=8D=20?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E8=BE=85=E7=A7=8D=E4=B8=AD=E8=B7=B3=E8=BF=87?= =?UTF-8?q?=E6=A3=80=E9=AA=8C=E5=90=8E=E4=B8=8D=E4=BC=9A=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E7=A7=8D=E5=AD=90=E5=AE=8C=E6=95=B4=E5=BA=A6=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E5=81=9A=E7=A7=8D"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit a90b49a6a1d7a668328d5d80b13e182f92595995. --- package.json | 1 - plugins/iyuuautoseed/__init__.py | 7 ++----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 82cbc86..d0c051a 100644 --- a/package.json +++ b/package.json @@ -288,7 +288,6 @@ "author": "jxxghp", "level": 2, "history": { - "v1.9.5": "修复跳过检验后不会检查种子完整度直接做种", "v1.9.4": "修复qbittorrent 添加种子并跳过检验后会标记为完成不会自动做种", "v1.9.3": "修复Monika因缺少rsskey,种子下载失败的问题", "v1.9.2": "适配馒头使用API下载种子", diff --git a/plugins/iyuuautoseed/__init__.py b/plugins/iyuuautoseed/__init__.py index 128fad6..e0551a0 100644 --- a/plugins/iyuuautoseed/__init__.py +++ b/plugins/iyuuautoseed/__init__.py @@ -957,12 +957,9 @@ class IYUUAutoSeed(_PluginBase): if self._skipverify: # 跳过校验 logger.info(f"{download_id} 跳过校验,请自行检查...") - # qbittorrent 添加种子并跳过检验后会标记为完成不会自动做种 - # 加入校验种子列表,但是跳过校验 + # qbittorrent 添加种子并跳过检验后会标记为完成不会自动做种,需要手动开始 if downloader == "qbittorrent": - if not self._recheck_torrents.get(downloader): - self._recheck_torrents[downloader] = [] - self._recheck_torrents[downloader].append(download_id) + downloader_obj.start_torrents(ids=[download_id]) else: # 追加校验任务 logger.info(f"添加校验检查任务:{download_id} ...") From fbd2844f9bbf6eabb29df3662c75beb808e87a28 Mon Sep 17 00:00:00 2001 From: Alex Liu Date: Tue, 27 Aug 2024 10:58:05 +0800 Subject: [PATCH 031/218] Revert "Update Correct words" This reverts commit bfae36dce8889c0ad0be26ef33a94f8a79e87b13. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d0c051a..a32c29b 100644 --- a/package.json +++ b/package.json @@ -288,7 +288,7 @@ "author": "jxxghp", "level": 2, "history": { - "v1.9.4": "修复qbittorrent 添加种子并跳过检验后会标记为完成不会自动做种", + "v1.9.4": "修复qBittorrent辅种后不会自动开始做种", "v1.9.3": "修复Monika因缺少rsskey,种子下载失败的问题", "v1.9.2": "适配馒头使用API下载种子", "v1.9.1": "支持自定义辅种的种子分类", From 9b2eec8ca909626f7a9fdf2d27889ed9a5300bc2 Mon Sep 17 00:00:00 2001 From: Alex Liu Date: Tue, 27 Aug 2024 10:58:09 +0800 Subject: [PATCH 032/218] =?UTF-8?q?Revert=20"=E5=A2=9E=E5=8A=A0=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E9=99=90=E5=88=B6=E4=B8=BA=E8=B7=B3=E8=BF=87=E6=A3=80?= =?UTF-8?q?=E9=AA=8C"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 7fef1f007a65ecf3cd84a8773d3e12f60f07d0e1. --- plugins/iyuuautoseed/__init__.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/plugins/iyuuautoseed/__init__.py b/plugins/iyuuautoseed/__init__.py index e0551a0..43682cf 100644 --- a/plugins/iyuuautoseed/__init__.py +++ b/plugins/iyuuautoseed/__init__.py @@ -957,9 +957,6 @@ class IYUUAutoSeed(_PluginBase): if self._skipverify: # 跳过校验 logger.info(f"{download_id} 跳过校验,请自行检查...") - # qbittorrent 添加种子并跳过检验后会标记为完成不会自动做种,需要手动开始 - if downloader == "qbittorrent": - downloader_obj.start_torrents(ids=[download_id]) else: # 追加校验任务 logger.info(f"添加校验检查任务:{download_id} ...") @@ -970,7 +967,9 @@ class IYUUAutoSeed(_PluginBase): if downloader == "qbittorrent": # 开始校验种子 downloader_obj.recheck_torrents(ids=[download_id]) - + # qbittorrent 添加种子后会标记为完成不会自动做种,需要手动开始 + if downloader == "qbittorrent": + downloader_obj.start_torrents(ids=[download_id]) # 下载成功 logger.info(f"成功添加辅种下载,站点:{site_info.get('name')},种子链接:{torrent_url}") # 成功也加入缓存,有一些改了路径校验不通过的,手动删除后,下一次又会辅上 From 5dbafa0fe842a0f23ba29f45af227cc89ae81831 Mon Sep 17 00:00:00 2001 From: Alex Liu Date: Tue, 27 Aug 2024 10:58:13 +0800 Subject: [PATCH 033/218] =?UTF-8?q?Revert=20"Fix=20iyuu=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=90=8E=E4=B8=8D=E4=BC=9A=E8=87=AA=E5=8A=A8=E5=81=9A=E7=A7=8D?= =?UTF-8?q?"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 31d0f4c3b67336b871c4a4596ebfc6bf84a89478. --- package.json | 3 +-- plugins/iyuuautoseed/__init__.py | 5 +---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index a32c29b..8f3cc0f 100644 --- a/package.json +++ b/package.json @@ -283,12 +283,11 @@ "name": "IYUU自动辅种", "description": "基于IYUU官方Api实现自动辅种。", "labels": "做种,IYUU", - "version": "1.9.4", + "version": "1.9.3", "icon": "IYUU.png", "author": "jxxghp", "level": 2, "history": { - "v1.9.4": "修复qBittorrent辅种后不会自动开始做种", "v1.9.3": "修复Monika因缺少rsskey,种子下载失败的问题", "v1.9.2": "适配馒头使用API下载种子", "v1.9.1": "支持自定义辅种的种子分类", diff --git a/plugins/iyuuautoseed/__init__.py b/plugins/iyuuautoseed/__init__.py index 43682cf..187a8c2 100644 --- a/plugins/iyuuautoseed/__init__.py +++ b/plugins/iyuuautoseed/__init__.py @@ -34,7 +34,7 @@ class IYUUAutoSeed(_PluginBase): # 插件图标 plugin_icon = "IYUU.png" # 插件版本 - plugin_version = "1.9.4" + plugin_version = "1.9.3" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -967,9 +967,6 @@ class IYUUAutoSeed(_PluginBase): if downloader == "qbittorrent": # 开始校验种子 downloader_obj.recheck_torrents(ids=[download_id]) - # qbittorrent 添加种子后会标记为完成不会自动做种,需要手动开始 - if downloader == "qbittorrent": - downloader_obj.start_torrents(ids=[download_id]) # 下载成功 logger.info(f"成功添加辅种下载,站点:{site_info.get('name')},种子链接:{torrent_url}") # 成功也加入缓存,有一些改了路径校验不通过的,手动删除后,下一次又会辅上 From 66b0fd3823ab73030dd8aaead23610a78d3ec77d Mon Sep 17 00:00:00 2001 From: Alex Liu Date: Tue, 27 Aug 2024 11:03:02 +0800 Subject: [PATCH 034/218] =?UTF-8?q?fix(iyuuautoseed):=20Revert=20qBittorre?= =?UTF-8?q?nt=20=E8=B7=B3=E6=A3=80=E4=B9=8B=E5=90=8E=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=BC=80=E5=A7=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 4 +++- plugins/iyuuautoseed/__init__.py | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 8f3cc0f..e719740 100644 --- a/package.json +++ b/package.json @@ -283,11 +283,13 @@ "name": "IYUU自动辅种", "description": "基于IYUU官方Api实现自动辅种。", "labels": "做种,IYUU", - "version": "1.9.3", + "version": "1.9.5", "icon": "IYUU.png", "author": "jxxghp", "level": 2, "history": { + "v1.9.5": "Revert qBittorrent跳检之后自动开始", + "v1.9.4": "修复qBittorrent辅种后不会自动开始做种", "v1.9.3": "修复Monika因缺少rsskey,种子下载失败的问题", "v1.9.2": "适配馒头使用API下载种子", "v1.9.1": "支持自定义辅种的种子分类", diff --git a/plugins/iyuuautoseed/__init__.py b/plugins/iyuuautoseed/__init__.py index 187a8c2..64e4f2f 100644 --- a/plugins/iyuuautoseed/__init__.py +++ b/plugins/iyuuautoseed/__init__.py @@ -34,7 +34,7 @@ class IYUUAutoSeed(_PluginBase): # 插件图标 plugin_icon = "IYUU.png" # 插件版本 - plugin_version = "1.9.3" + plugin_version = "1.9.5" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -957,6 +957,10 @@ class IYUUAutoSeed(_PluginBase): if self._skipverify: # 跳过校验 logger.info(f"{download_id} 跳过校验,请自行检查...") + # 请注意这里是故意不自动开始的 + # 跳过校验存在直接失败、种子目录相同文件不同等异常情况 + # 必须要用户自行二次确认之后才能开始做种 + # 否则会出现反复下载刷掉分享率、做假种的情况 else: # 追加校验任务 logger.info(f"添加校验检查任务:{download_id} ...") From fa142615993ce019b52accef3089172ac68602b7 Mon Sep 17 00:00:00 2001 From: Dean <36684698+BrettDean@users.noreply.github.com> Date: Fri, 30 Aug 2024 17:55:17 +0800 Subject: [PATCH 035/218] =?UTF-8?q?=E5=B0=86=E6=96=87=E4=BB=B6=E5=90=8E?= =?UTF-8?q?=E7=BC=80=E5=90=8D=E6=A3=80=E6=9F=A5=E6=94=B9=E4=B8=BA=E4=B8=8D?= =?UTF-8?q?=E5=8C=BA=E5=88=86=E5=A4=A7=E5=B0=8F=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/dirmonitor/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/dirmonitor/__init__.py b/plugins/dirmonitor/__init__.py index 2159523..0f0c957 100644 --- a/plugins/dirmonitor/__init__.py +++ b/plugins/dirmonitor/__init__.py @@ -330,7 +330,7 @@ class DirMonitor(_PluginBase): return # 不是媒体文件不处理 - if file_path.suffix not in settings.RMT_MEDIAEXT: + if file_path.suffix.casefold() not in map(str.casefold, settings.RMT_MEDIAEXT): logger.debug(f"{event_path} 不是媒体文件") return From f72f92ab37d9ee893efdbdab8932ff2534b099c4 Mon Sep 17 00:00:00 2001 From: Pixel-LH <2569646547@qq.com> Date: Sun, 1 Sep 2024 06:04:09 +0800 Subject: [PATCH 036/218] =?UTF-8?q?Update:VCB=E8=BE=85=E5=8A=A9=E6=95=B4?= =?UTF-8?q?=E7=90=86=E6=8F=92=E4=BB=B6=E5=8A=9F=E8=83=BD=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + plugins/vcbanimemonitor/__init__.py | 54 ++-- plugins/vcbanimemonitor/remeta.py | 413 ++++++++++++++++------------ 3 files changed, 278 insertions(+), 190 deletions(-) diff --git a/package.json b/package.json index e719740..f2db2d8 100644 --- a/package.json +++ b/package.json @@ -324,6 +324,7 @@ "author": "pixel@qingwa", "level": 2, "history": { + "v1.8.1": "重构插件,测试版", "v1.8": "增加了元数据刮削开关,升级后需要手动打开,否则默认不刮削", "v1.7.1": "修复偶尔安装失败问题" } diff --git a/plugins/vcbanimemonitor/__init__.py b/plugins/vcbanimemonitor/__init__.py index de3edd1..84c5f86 100644 --- a/plugins/vcbanimemonitor/__init__.py +++ b/plugins/vcbanimemonitor/__init__.py @@ -5,7 +5,6 @@ import threading import time import traceback from pathlib import Path -from time import sleep from typing import List, Tuple, Dict, Any, Optional import pytz import qbittorrentapi @@ -19,8 +18,6 @@ from app.chain.tmdb import TmdbChain from app.chain.transfer import TransferChain from app.core.config import settings from app.core.context import MediaInfo -from app.core.event import eventmanager, Event -from app.core.metainfo import MetaInfoPath from app.db.downloadhistory_oper import DownloadHistoryOper from app.db.transferhistory_oper import TransferHistoryOper from app.log import logger @@ -77,7 +74,7 @@ class VCBAnimeMonitor(_PluginBase): # 插件图标 plugin_icon = "vcbmonitor.png" # 插件版本 - plugin_version = "1.8" + plugin_version = "1.8.1" # 插件作者 plugin_author = "pixel@qingwa" # 作者主页 @@ -224,7 +221,8 @@ class VCBAnimeMonitor(_PluginBase): try: if target_path and target_path.is_relative_to(Path(mon_path)): logger.warn(f"{target_path} 是监控目录 {mon_path} 的子目录,无法监控") - self.systemmessage.put(f"{target_path} 是下载目录 {mon_path} 的子目录,无法监控", title="整理VCB动漫压制组作品") + self.systemmessage.put(f"{target_path} 是下载目录 {mon_path} 的子目录,无法监控", + title="整理VCB动漫压制组作品") continue except Exception as e: logger.debug(str(e)) @@ -382,27 +380,49 @@ class VCBAnimeMonitor(_PluginBase): return # 元数据 - if file_path.parent.name == "SPs": - logger.warn("位于SPs目录下,跳过处理") + if file_path.parent.name in ["SPs", "Scans", "CDs"]: + logger.warn("位于特典等其他特殊目录下,跳过处理") return - remeta = ReMeta(ova_switch=self._switch_ova, high_performance=self._high_mode) + + if 'VCB-Studio' not in file_path.stem.strip(): + logger.warn("不属于VCB的作品,不处理!") + return + + remeta = ReMeta(ova_switch=self._switch_ova, ) file_meta = remeta.handel_file(file_path=file_path) if file_meta: if not file_meta.name: logger.error(f"{file_path.name} 无法识别有效信息") return - if remeta.is_special and not self._switch_ova: + if remeta.is_ova and not self._switch_ova: logger.warn(f"{file_path.name} 为OVA资源,未开启OVA开关,不处理") return - if remeta.is_special and self._switch_ova: - logger.info(f"{file_path.name} 为OVA资源,开始处理") - if self.get_data(key=f"OVA_{file_meta.title}") is not None: - ova_history_ep = int(self.get_data(key=f"OVA_{file_meta.title}")) + 1 - file_meta.begin_episode = ova_history_ep - self.save_data(key=f"OVA_{file_meta.title}", value=ova_history_ep) + # if remeta.is_ova and self._switch_ova: + # logger.info(f"{file_path.name} 为OVA资源,开始处理") + # if self.get_data(key=f"OVA_{file_meta.title}") is not None: + # ova_history_ep = int(self.get_data(key=f"OVA_{file_meta.title}")) + 1 + # file_meta.begin_episode = ova_history_ep + # self.save_data(key=f"OVA_{file_meta.title}", value=ova_history_ep) + # else: + # file_meta.begin_episode = 1 + # self.save_data(key=f"OVA_{file_meta.title}", value=1) + if remeta.is_ova and self._switch_ova: + logger.info(f"{file_path.name} 为OVA资源,开始历史记录处理") + ova_history_ep_list = self.plugindata.get(file_meta.title, []) + if ova_history_ep_list: + ep = file_meta.begin_episode + if ep in ova_history_ep_list: + for i in range(1, 100): + if ep + i not in ova_history_ep_list: + ova_history_ep_list.append(ep + i) + file_meta.begin_episode = ep + i + break + else: + ova_history_ep_list.append(ep) + self.plugindata.put(file_meta.title, ova_history_ep_list) else: - file_meta.begin_episode = 1 - self.save_data(key=f"OVA_{file_meta.title}", value=1) + self.plugindata.put(file_meta.title, [file_meta.begin_episode]) + else: return diff --git a/plugins/vcbanimemonitor/remeta.py b/plugins/vcbanimemonitor/remeta.py index 4624cc7..5b8a659 100644 --- a/plugins/vcbanimemonitor/remeta.py +++ b/plugins/vcbanimemonitor/remeta.py @@ -1,203 +1,270 @@ import concurrent import re +from dataclasses import dataclass from pathlib import Path from typing import List from app.chain.media import MediaChain -from app.chain.tmdb import TmdbChain from app.core.metainfo import MetaInfoPath from app.log import logger from app.schemas import MediaType +season_patterns = [ + {"pattern": re.compile(r"S(\d+)$", re.IGNORECASE), "group": 1}, + {"pattern": re.compile(r"(\d+)$", re.IGNORECASE), "group": 1}, + {"pattern": re.compile(r"(\d+)(st|nd|rd|th)?\s*season", re.IGNORECASE), "group": 1}, + {"pattern": re.compile(r"(.*) ?\s*season (\d+)", re.IGNORECASE), "group": 2}, + {"pattern": re.compile(r"\s(II|III|IV|V|VI|VII|VIII|IX|X)$", re.IGNORECASE), "group": "1"} +] +episode_patterns = [ + {"pattern": re.compile(r"(\d+)\((\d+)\)", re.IGNORECASE), "group": 2}, + {"pattern": re.compile(r"(\d+)", re.IGNORECASE), "group": 1}, + {"pattern": re.compile(r'(\d+)v\d+', re.IGNORECASE), "group": 1}, +] -def roman_to_int(s) -> int: - """ - :param s: 罗马数字字符串 - 罗马数字转整数 - """ - roman_dict = {'I': 1, 'V': 5, 'X': 10, 'L': 50, 'C': 100, 'D': 500, 'M': 1000} - total = 0 - prev_value = 0 +ova_patterns = [ + re.compile(r".*?(OVA|OAD).*?", re.IGNORECASE), + re.compile(r"\d+\.5"), + re.compile(r"00") +] - for char in reversed(s): # 反向遍历罗马数字字符串 - current_value = roman_dict[char] - if current_value >= prev_value: - total += current_value # 如果当前值大于等于前一个值,加上当前值 - else: - total -= current_value # 如果当前值小于前一个值,减去当前值 - prev_value = current_value +final_season_patterns = [ + re.compile('final season', re.IGNORECASE), + re.compile('The Final', re.IGNORECASE), + re.compile(r'\sFinal') +] - return total + +@dataclass +class VCBMetaBase: + # 转化为小写后的原始文件名称 (不含后缀) + original_title: str = "" + # 解析后不包含季度和集数的标题 + title: str = "" + # 类型:TV / Movie (默认TV) + type: str = "TV" + # 可能含有季度的标题,一级解析后的标题 + season_title: str = "" + # 可能含有集数的字符串列表 + ep_title: List[str] = None + # 识别出来的季度 + season: int = None + # 识别出来的集数 + ep: int = None + # 是否是OVA/OAD + is_ova: bool = False + + +blocked_words = ["vcb-studio", "360p", "480p", "720p", "1080p", "2160p", "hdr", "x265", "x264", "aac", "flac"] class ReMeta: - # 解析之后的标题: - title: str = None - # 识别出来的集数 - ep: int = None - # 识别出来的季度 - season: int = None - # 特殊季识别开关 - is_special = False - # OVA/OAD识别开关 - ova_switch: bool = False - # 高性能处理开关 - high_performance = False - season_patterns = [ - {"pattern": re.compile(r"S(\d+)$"), "group": 1}, - {"pattern": re.compile(r"(\d+)$"), "group": 1}, - {"pattern": re.compile(r"(\d+)(st|nd|rd|th)?\s*[Ss][Ee][Aa][Ss][Oo][Nn]"), "group": 1}, - {"pattern": re.compile(r"(.*) ?\s*[Ss][Ee][Aa][Ss][Oo][Nn] (\d+)"), "group": 2}, - {"pattern": re.compile(r"\s(II|III|IV|V|VI|VII|VIII|IX|X)$"), "group": "1"} - ] - episode_patterns = [ - {"pattern": re.compile(r"\[(\d+)\((\d+)\)]"), "group": 2}, - {"pattern": re.compile(r"\[(\d+)]"), "group": 1}, - {"pattern": re.compile(r'\[(\d+)v\d+]'), "group": 1}, - - ] - _ova_patterns = [re.compile(r"\[.*?(OVA|OAD).*?]"), - re.compile(r"\[\d+\.5]"), - re.compile(r"\[00\]")] - - final_season_patterns = [re.compile('final season', re.IGNORECASE), - re.compile('The Final', re.IGNORECASE), - re.compile(r'\sFinal') - ] - # 自定义添加的季度正则表达式 - _custom_season_patterns = [] - - def __init__(self, ova_switch: bool = False, high_performance: bool = False): + def __init__(self, ova_switch: bool = False, custom_season_patterns: list[dict] = None): + self.meta = None + # TODO:自定义季度匹配规则 + self.custom_season_patterns = custom_season_patterns + self.season_patterns = season_patterns self.ova_switch = ova_switch - self.high_performance = high_performance + self.vcb_meta = VCBMetaBase() + self.is_ova = False + + def is_tv(self, title: str) -> bool: + """ + 判断是否是TV + """ + if title.count("[") != 4 and title.count("]") != 4: + self.vcb_meta.type = "Movie" + self.vcb_meta.title = re.sub(r'\[.*?\]', '', title).strip() + return False + return True def handel_file(self, file_path: Path): + file_name = file_path.stem.strip().lower() + self.vcb_meta.original_title = file_name + if not self.is_tv(file_name): + logger.warn( + "不符合VCB-Studio的剧集命名规范,归类为电影,跳过剧集模块处理。注意:年份较为久远的作品可能会判断错误") + else: + self.tv_mode() + self.is_ova = self.vcb_meta.is_ova meta = MetaInfoPath(file_path) - self.title = meta.title - self.title = Path(self.title).stem.strip() - if 'VCB-Studio' not in meta.title: - logger.warn("不属于VCB的作品,不处理!") - return None - if meta.title.count("[") != 4 and meta.title.count("]") != 4: - # 可能是电影,电影只有三组[],因此去除所有[]后只剩下电影名 - logger.warn("不符合VCB-Studio的剧集命名规范,跳过剧集模块处理!交给默认处理逻辑") - meta.title = re.sub(r'\[.*?\]', '', meta.title).strip() - meta.en_name = meta.title - return meta - split_title: List[str] | None = self.split_season_ep(self.title) - if split_title: - self.handle_season_ep(split_title) - if self.season is not None: - meta.begin_season = self.season - else: - logger.warn("未识别出季度,默认处理逻辑返回第一季") - if self.ep is not None: - meta.begin_episode = self.ep - else: - logger.warn("未识别出集数,默认处理逻辑返回第一集") - meta.title = self.title - meta.en_name = self.title - logger.info(f"识别出季度为{self.season},集数为{self.ep},标题为:{self.title}") - + meta.title = self.vcb_meta.title + meta.en_name = self.vcb_meta.title + meta.begin_season = self.vcb_meta.season + if self.vcb_meta.ep: + meta.begin_episode = self.vcb_meta.ep + if self.vcb_meta.type == "Movie": + meta.type = MediaType.MOVIE return meta - # 分离季度部分和集数部分 - def split_season_ep(self, pre_title: str): - split_ep = re.findall(r"(\[.*?])", pre_title)[1] - if not split_ep: - logger.warn("未识别出集数位置信息,结束识别!") - return None - split_title = re.sub(r"\[.*?\]", "", pre_title).strip() - logger.info(f"分离出包含季度的部分:{split_title} \n 分离出包含集数的部分: {split_ep}") - return [split_title, split_ep] + def split_season_ep(self): + # 把所有的[] 里面的内容获取出来,不需要[]本身 + self.vcb_meta.ep_title = re.findall(r'\[(.*?)\]', self.vcb_meta.original_title) + # 去除所有[]后只剩下剧名 + self.vcb_meta.season_title = re.sub(r"\[.*?\]", "", self.vcb_meta.original_title).strip() + if self.vcb_meta.ep_title: + self.culling_blocked_words() + logger.info( + f"分离出包含可能季度的内容部分:{self.vcb_meta.season_title} | 可能包含集数的内容部分: {self.vcb_meta.ep_title}") + self.vcb_meta.title = self.vcb_meta.season_title + if not self.vcb_meta.ep_title: + self.vcb_meta.title = self.vcb_meta.season_title + logger.warn("未识别出可能存在集数位置的信息,跳过剩余识别步骤!") - def handle_season_ep(self, title: List[str]): - if self.high_performance: - with concurrent.futures.ProcessPoolExecutor(max_workers=2) as executor: - title_season_result = executor.submit(self.handle_season, title[0]) - ep_result = executor.submit(self.re_ep, title[1], ) - try: - title_season_result = title_season_result.result() # Blocks until the task is complete. - ep_result = ep_result.result() # Blocks until the task is complete. - except Exception as exc: - print('Generated an exception: %s' % exc) - else: - title_season_result = self.handle_season(title[0]) - ep_result = self.re_ep(title[1]) - self.title = title_season_result["title"] - is_ova = ep_result["is_ova"] - if ep_result["ep"] is not None: - self.ep = ep_result["ep"] - if title_season_result["season"]: - self.season = title_season_result["season"] - if is_ova: - self.season = 0 - self.is_special = True + def tv_mode(self): + logger.info("开始分离季度和集数部分") + self.split_season_ep() + if not self.vcb_meta.ep_title: + return + self.parse_season() + self.parse_episode() - # 处理季度 - def handle_season(self, pre_title: str) -> dict: - title_season = {"title": pre_title, "season": 1} - for season_pattern in self.season_patterns: - pattern = season_pattern["pattern"] - group = season_pattern["group"] - match = pattern.search(pre_title) + def parse_season(self): + """ + 从标题中解析季度 + """ + flag = False + for pattern in season_patterns: + match = pattern["pattern"].search(self.vcb_meta.season_title) if match: - if type(group) == str: - title_season["season"] = roman_to_int(match.group(int(group))) - title_season["title"] = re.sub(pattern, "", pre_title).strip() + if isinstance(pattern["group"], int): + self.vcb_meta.season = int(match.group(pattern["group"])) else: - title_season["season"] = int(match.group(group)) - title_season["title"] = re.sub(pattern, "", pre_title).strip() - return title_season - for final_season_pattern in self.final_season_patterns: - match = final_season_pattern.search(pre_title) - if match: - logger.info("识别出最终季度,开始处理!") - title_season["title"] = re.sub(final_season_pattern, "", pre_title).strip() - title_season["season"] = self.handle_final_season(title=pre_title) - break - return title_season + self.vcb_meta.season = self.roman_to_int(match.group(pattern["group"])) + # 匹配成功后,标题中去除季度信息 + self.vcb_meta.title = pattern["pattern"].sub("", self.vcb_meta.season_title).strip + logger.info(f"识别出季度为{self.vcb_meta.season}") + return + logger.info(f"正常匹配季度失败,开始匹配ova/oad/最终季度") + if not flag: + # 匹配是否为最终季 + for pattern in final_season_patterns: + if pattern.search(self.vcb_meta.season_title): + logger.info("命中到最终季匹配规则") + self.vcb_meta.title = pattern.sub("", self.vcb_meta.season_title).strip() + self.handle_final_season() + return + logger.info("未识别出最终季度,开始匹配OVA/OAD") + # 匹配是否为OVA/OAD + if "ova" in self.vcb_meta.season_title or "oad" in self.vcb_meta.season_title: + logger.info("季度部分命中到OVA/OAD匹配规则") + if self.ova_switch: + logger.info("开启OVA/OAD处理逻辑") + self.vcb_meta.is_ova = True + for pattern in ova_patterns: + if pattern.search(self.vcb_meta.season_title): + self.vcb_meta.title = pattern.sub("", self.vcb_meta.season_title).strip() + self.vcb_meta.title = re.sub("ova|oad", "", self.vcb_meta.season_title).strip() + self.vcb_meta.season = 0 + return + logger.warn("未识别出季度,默认处理逻辑返回第一季") + self.vcb_meta.title = self.vcb_meta.season_title + self.vcb_meta.season = 1 - # 处理存在“Final”字样命名的季度 - def handle_final_season(self, title: str) -> int | None: - medias = MediaChain().search(title=title)[1] - if not medias: - logger.warn("没有找到对应的媒体信息!") - return - # 根据类型进行过滤,只取类型是电视剧和动漫的media - medias = [media for media in medias if media.type == MediaType.TV] - if not medias: - logger.warn("没有找到动漫或电视剧的媒体信息!") - return - media = sorted(medias, key=lambda x: x.popularity, reverse=True)[0] - media_tmdb_id = media.tmdb_id - seasons_info = TmdbChain().tmdb_seasons(tmdbid=media_tmdb_id) - if seasons_info is None: - logger.warn("无法获取最终季") - else: - logger.info(f"获取到最终季,季度为{len(seasons_info)}") - return len(seasons_info) + def parse_episode(self): + """ + 从标题中解析集数 + """ + # 从ep_title中剔除不相关的内容之后只剩下存在集数的字符串 + ep = self.vcb_meta.ep_title[0] + for pattern in episode_patterns: + match = pattern["pattern"].search(ep) + if match: + self.vcb_meta.ep = int(match.group(pattern["group"])) + logger.info(f"识别出集数为{self.vcb_meta.ep}") + return + # 直接进入判断是否为OVA/OAD + for pattern in ova_patterns: + if pattern.search(ep): + self.vcb_meta.is_ova = True + # 直接获取数字 + self.vcb_meta.ep = int(re.search(r"\d+", ep).group()) or 1 + logger.info(f"识别出集数为{self.vcb_meta.ep}") + return - def re_ep(self, ep_title: str, ) -> dict: + def culling_blocked_words(self): """ - # 集数匹配处理模块 - :param ep_title: 从title解析出的集数,ep_title固定格式[集数] - 1.先判断是否存在OVA/OAD,形如:[OVA],[12(OVA)],[12.5]这种形式都是属于OVA/OAD,交给处理OVA模块处理 - 2.集数通常有两种情况一种:[12]直接性,另一种:[12(24)],这一种应该去括号内的为集数 - :return: 集数(int) + 从ep_title中剔除不相关的内容 """ - ep_ova = {"ep": None, "is_ova": False} - for ova_pattern in self._ova_patterns: - match = ova_pattern.search(ep_title) - if match: - ep_ova["is_ova"] = True - ep_ova["ep"] = 1 - return ep_ova - for ep_pattern in self.episode_patterns: - pattern = ep_pattern["pattern"] - group = ep_pattern["group"] - match = pattern.search(ep_title) - if match: - ep_ova["ep"] = int(match.group(group)) - return ep_ova - return ep_ova + blocked_set = set(blocked_words) # 将阻止词列表转换为集合 + result = [ep for ep in self.vcb_meta.ep_title if not any(word in ep for word in blocked_set)] + self.vcb_meta.ep_title = result + + def handle_final_season(self): + + meta, medias = MediaChain().search(title=self.vcb_meta.title) + if not medias: + logger.warning("匹配到最终季时无法找到对应的媒体信息!季度返回默认值:1") + self.vcb_meta.season = 1 + return + + max_season_number = 1 + # 当没有季度参考时用评分来决定 + vote_average = 0 + season_info = False + for media in medias: + if media.type != MediaType.TV: + logger.info(f"搜索到的: {media.title}, 媒体类型为 {media.type},跳过") + continue + if media.season_info: + season_info = True + last_season_number = int(media.season_info[-1].get("season_number", 1)) + if last_season_number > max_season_number: + max_season_number = last_season_number + else: + logger.info(f"媒体: {media.title} 没有季信息,跳过") + if not season_info: + # 备用方案 + for media in medias: + if media.seasons: + seasons: dict + # 获取最大的键,即最大季度 + last_season_number = max(media.seasons.keys()) + if last_season_number > max_season_number: + max_season_number = last_season_number + logger.info(f"获取到最终季,季度为 {max_season_number},标题为 {media.title},年份为 {media.year}") + else: + logger.info(f"媒体: {media.title} 没有季信息,跳过") + + self.vcb_meta.season = max_season_number + logger.info(f"获取到最终季,季度为 {self.vcb_meta.season}") + + @staticmethod + def roman_to_int(s) -> int: + """ + :param s: 罗马数字字符串 + 罗马数字转整数 + """ + roman_dict = {'I': 1, 'V': 5, 'X': 10, 'L': 50, 'C': 100, 'D': 500, 'M': 1000} + total = 0 + prev_value = 0 + + for char in reversed(s): # 反向遍历罗马数字字符串 + current_value = roman_dict[char] + if current_value >= prev_value: + total += current_value # 如果当前值大于等于前一个值,加上当前值 + else: + total -= current_value # 如果当前值小于前一个值,减去当前值 + prev_value = current_value + + return total + + +def test(title: str): + # 示例文件名 + pre_title = title + + # 提取方括号内的内容,不包括方括号 + content = re.findall(r'\[(.*?)\]', pre_title) + + print(content) + + +if __name__ == '__main__': + # title = "[BeanSub&VCB-Studio] Jujutsu Kaisen [26][Ma10p_1080p][x265_flac].mkv " + # test(title) + + ReMeta( + ova_switch=True, + ).handel_file(Path( + r"[Nekomoe kissaten&VCB-Studio] Fruits Basket The Final [08][Ma10p_1080p][x265_flac].mkv")) From bd9d1c413fae34fb8a60a0ee42c0e6b38cef6803 Mon Sep 17 00:00:00 2001 From: Pixel-LH <2569646547@qq.com> Date: Mon, 2 Sep 2024 00:00:45 +0800 Subject: [PATCH 037/218] =?UTF-8?q?Update:VCB=E8=BE=85=E5=8A=A9=E6=95=B4?= =?UTF-8?q?=E7=90=86=E6=8F=92=E4=BB=B6=E6=8F=90=E9=AB=98=E8=AF=86=E5=88=AB?= =?UTF-8?q?=E7=8E=87&Fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 +- plugins/vcbanimemonitor/__init__.py | 65 ++++++----------- plugins/vcbanimemonitor/remeta.py | 109 +++++++++++++++------------- 3 files changed, 83 insertions(+), 94 deletions(-) diff --git a/package.json b/package.json index f2db2d8..de920a7 100644 --- a/package.json +++ b/package.json @@ -317,13 +317,14 @@ }, "VCBAnimeMonitor": { "name": "整理VCB动漫压制组作品", - "description": "提高部分VCB-Studio作品的识别准确率,将VCB-Studio的作品统一转移到指定目录同时进行刮削整理", + "description": "一款辅助整理&提高识别VCB-Stuido动漫压制组作品的插件", "labels": "文件整理,识别", "version": "1.8", "icon": "vcbmonitor.png", "author": "pixel@qingwa", "level": 2, "history": { + "v1.8.2": "提高识别率", "v1.8.1": "重构插件,测试版", "v1.8": "增加了元数据刮削开关,升级后需要手动打开,否则默认不刮削", "v1.7.1": "修复偶尔安装失败问题" diff --git a/plugins/vcbanimemonitor/__init__.py b/plugins/vcbanimemonitor/__init__.py index 84c5f86..97ae16c 100644 --- a/plugins/vcbanimemonitor/__init__.py +++ b/plugins/vcbanimemonitor/__init__.py @@ -5,6 +5,7 @@ import threading import time import traceback from pathlib import Path +from time import sleep from typing import List, Tuple, Dict, Any, Optional import pytz import qbittorrentapi @@ -70,11 +71,11 @@ class VCBAnimeMonitor(_PluginBase): # 插件名称 plugin_name = "整理VCB动漫压制组作品" # 插件描述 - plugin_desc = "提高部分VCB-Studio作品的识别准确率,将VCB-Studio的作品统一转移到指定目录同时进行刮削整理" + plugin_desc = "一款辅助整理&提高识别VCB-Stuido动漫压制组作品的插件" # 插件图标 plugin_icon = "vcbmonitor.png" # 插件版本 - plugin_version = "1.8.1" + plugin_version = "1.8.2" # 插件作者 plugin_author = "pixel@qingwa" # 作者主页 @@ -88,7 +89,6 @@ class VCBAnimeMonitor(_PluginBase): # 私有属性 _switch_ova = False - _high_mode = False _torrents_path = None new_save_path = None qb = None @@ -142,7 +142,6 @@ class VCBAnimeMonitor(_PluginBase): self._size = config.get("size") or 0 self._scrape = config.get("scrape") self._switch_ova = config.get("ova") - self._high_mode = config.get("high_mode") self._torrents_path = config.get("torrents_path") or "" # 停止现有任务 @@ -161,13 +160,16 @@ class VCBAnimeMonitor(_PluginBase): return # 启用种子目录监控 - if self._torrents_path is not None and Path(self._torrents_path).exists() and self._enabled: + if self._torrents_path and Path(self._torrents_path).exists() and self._enabled: # 只取第一个目录作为新的保存 - first_path = monitor_dirs[0] - if SystemUtils.is_windows(): - self.new_save_path = first_path.split(':')[0] + ":" + first_path.split(':')[1] - else: - self.new_save_path = first_path.split(':')[0] + try: + first_path = monitor_dirs[0] + if SystemUtils.is_windows(): + self.new_save_path = first_path.split(':')[0] + ":" + first_path.split(':')[1] + else: + self.new_save_path = first_path.split(':')[0] + except Exception: + logger.error(f"目录保存失败,请检查输入目录是否合法") # print(self.new_save_path) try: observer = Observer() @@ -288,7 +290,6 @@ class VCBAnimeMonitor(_PluginBase): "size": self._size, "scrape": self._scrape, "ova": self._switch_ova, - "high_mode": self._high_mode, "torrents_path": self._torrents_path }) @@ -388,7 +389,7 @@ class VCBAnimeMonitor(_PluginBase): logger.warn("不属于VCB的作品,不处理!") return - remeta = ReMeta(ova_switch=self._switch_ova, ) + remeta = ReMeta(ova_switch=self._switch_ova,) file_meta = remeta.handel_file(file_path=file_path) if file_meta: if not file_meta.name: @@ -397,15 +398,6 @@ class VCBAnimeMonitor(_PluginBase): if remeta.is_ova and not self._switch_ova: logger.warn(f"{file_path.name} 为OVA资源,未开启OVA开关,不处理") return - # if remeta.is_ova and self._switch_ova: - # logger.info(f"{file_path.name} 为OVA资源,开始处理") - # if self.get_data(key=f"OVA_{file_meta.title}") is not None: - # ova_history_ep = int(self.get_data(key=f"OVA_{file_meta.title}")) + 1 - # file_meta.begin_episode = ova_history_ep - # self.save_data(key=f"OVA_{file_meta.title}", value=ova_history_ep) - # else: - # file_meta.begin_episode = 1 - # self.save_data(key=f"OVA_{file_meta.title}", value=1) if remeta.is_ova and self._switch_ova: logger.info(f"{file_path.name} 为OVA资源,开始历史记录处理") ova_history_ep_list = self.plugindata.get(file_meta.title, []) @@ -833,22 +825,6 @@ class VCBAnimeMonitor(_PluginBase): } ] }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'high_mode', - 'label': '高性能处理模式', - } - } - ] - }, { 'component': 'VCol', 'props': { @@ -1003,7 +979,7 @@ class VCBAnimeMonitor(_PluginBase): 'props': { 'model': 'monitor_dirs', 'label': '监控目录', - 'rows': 5, + 'rows': 4, 'placeholder': '每一行一个目录,支持以下几种配置方式,转移方式支持 move、copy、link、softlink、rclone_copy、rclone_move:\n' '监控目录\n' '监控目录#转移方式\n' @@ -1051,8 +1027,10 @@ class VCBAnimeMonitor(_PluginBase): 'props': { 'type': 'info', 'variant': 'tonal', - 'text': '核心用法与目录同步插件相同,不同点在于只识别处理VCB-Studio资源,\n' - '不处理SPs目录下的文件,OVA/OAD集数根据入库顺序累加命名,不保证与TMDB集数匹配' + 'text': '核心用法与目录同步插件相同,不同点在于只识别处理VCB-Studio资源。' + '默认不处理SPs、CDs、SCans目录下的文件,OVA/OAD集数暂时根据入库顺序累加命名,' + '因此不保证与TMDB集数匹配。部分季度以罗马音音译为名的作品暂时无法识别出准确季度。' + '有想法,有问题欢迎点击插件作者主页提issue!' } } ] @@ -1073,9 +1051,9 @@ class VCBAnimeMonitor(_PluginBase): 'props': { 'type': 'info', 'variant': 'tonal', - 'text': '最佳使用方式:监控目录单独设置一个作为保存VCB-Studio资源的目录,\n' - '填入监控种子目录,开启后会将正在QB(仅支持QB)下载器内的VCB-Studio资源转移到监控目录实现自动整理(' - '仅支持第一个监控目录),\n' + 'text': '最佳使用方式:监控目录单独设置一个作为保存VCB-Studio资源的目录,' + '填入监控种子目录,开启后会将正在QB(仅支持QB)下载器内正在下载的VCB-Studio资源转移到监控目录实现自动整理(' + '仅支持第一个监控目录),' '监控种子目录为空则不转移文件' } } @@ -1097,7 +1075,6 @@ class VCBAnimeMonitor(_PluginBase): "cron": "", "size": 0, "ova": False, - "high_mode": False, "torrents_path": "", } diff --git a/plugins/vcbanimemonitor/remeta.py b/plugins/vcbanimemonitor/remeta.py index 5b8a659..260e17f 100644 --- a/plugins/vcbanimemonitor/remeta.py +++ b/plugins/vcbanimemonitor/remeta.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from pathlib import Path from typing import List from app.chain.media import MediaChain +from app.chain.tmdb import TmdbChain from app.core.metainfo import MetaInfoPath from app.log import logger from app.schemas import MediaType @@ -33,6 +34,11 @@ final_season_patterns = [ re.compile(r'\sFinal') ] +movie_patterns = [ + re.compile("Movie", re.IGNORECASE), + re.compile("the Movie", re.IGNORECASE), +] + @dataclass class VCBMetaBase: @@ -52,6 +58,8 @@ class VCBMetaBase: ep: int = None # 是否是OVA/OAD is_ova: bool = False + # TMDB ID + tmdb_id: int = None blocked_words = ["vcb-studio", "360p", "480p", "720p", "1080p", "2160p", "hdr", "x265", "x264", "aac", "flac"] @@ -83,7 +91,8 @@ class ReMeta: self.vcb_meta.original_title = file_name if not self.is_tv(file_name): logger.warn( - "不符合VCB-Studio的剧集命名规范,归类为电影,跳过剧集模块处理。注意:年份较为久远的作品可能会判断错误") + "不符合VCB-Studio的剧集命名规范,归类为电影,跳过剧集模块处理。注意:年份较为久远的作品可能在此会判断错误") + self.parse_movie() else: self.tv_mode() self.is_ova = self.vcb_meta.is_ova @@ -95,6 +104,8 @@ class ReMeta: meta.begin_episode = self.vcb_meta.ep if self.vcb_meta.type == "Movie": meta.type = MediaType.MOVIE + else: + meta.type = MediaType.TV return meta def split_season_ep(self): @@ -179,7 +190,8 @@ class ReMeta: self.vcb_meta.is_ova = True # 直接获取数字 self.vcb_meta.ep = int(re.search(r"\d+", ep).group()) or 1 - logger.info(f"识别出集数为{self.vcb_meta.ep}") + logger.info(f"OVA模式下识别出集数为{self.vcb_meta.ep}") + self.vcb_meta.season = 0 return def culling_blocked_words(self): @@ -192,42 +204,53 @@ class ReMeta: def handle_final_season(self): - meta, medias = MediaChain().search(title=self.vcb_meta.title) + _, medias = MediaChain().search(title=self.vcb_meta.title) if not medias: logger.warning("匹配到最终季时无法找到对应的媒体信息!季度返回默认值:1") self.vcb_meta.season = 1 return - max_season_number = 1 - # 当没有季度参考时用评分来决定 - vote_average = 0 - season_info = False - for media in medias: - if media.type != MediaType.TV: - logger.info(f"搜索到的: {media.title}, 媒体类型为 {media.type},跳过") - continue - if media.season_info: - season_info = True - last_season_number = int(media.season_info[-1].get("season_number", 1)) - if last_season_number > max_season_number: - max_season_number = last_season_number - else: - logger.info(f"媒体: {media.title} 没有季信息,跳过") - if not season_info: - # 备用方案 - for media in medias: - if media.seasons: - seasons: dict - # 获取最大的键,即最大季度 - last_season_number = max(media.seasons.keys()) - if last_season_number > max_season_number: - max_season_number = last_season_number - logger.info(f"获取到最终季,季度为 {max_season_number},标题为 {media.title},年份为 {media.year}") - else: - logger.info(f"媒体: {media.title} 没有季信息,跳过") + filter_medias = [media for media in medias if media.type == MediaType.TV] + if not filter_medias: + logger.warning("匹配到最终季时无法找到对应的媒体信息!季度返回默认值:1") + self.vcb_meta.season = 1 + return + medias = [media for media in filter_medias if media.popularity or media.vote_average] + if not medias: + logger.warning("匹配到最终季时无法找到对应的媒体信息!季度返回默认值:1") + self.vcb_meta.season = 1 + return + # 获取欢迎度最高或者评分最高的媒体 + medias_sorted = sorted(medias, key=lambda x: x.popularity or x.vote_average, reverse=True)[0] + self.vcb_meta.tmdb_id = medias_sorted.tmdb_id + if medias_sorted.tmdb_id: + seasons_info = TmdbChain().tmdb_seasons(tmdbid=medias_sorted.tmdb_id) + if seasons_info: + self.vcb_meta.season = len(seasons_info) + logger.info(f"获取到最终季度,季度为{self.vcb_meta.season}") + return + logger.warning("无法获取到最终季度信息,季度返回默认值:1") + self.vcb_meta.season = 1 + + + + def parse_movie(self): + logger.info("开始尝试剧场版模式解析") + for pattern in movie_patterns: + if pattern.search(self.vcb_meta.title): + logger.info("命中剧场版匹配规则,加上剧场版标识辅助识别") + self.vcb_meta.type = "Movie" + self.vcb_meta.title = pattern.sub("", self.vcb_meta.title).strip() + self.vcb_meta.title = self.vcb_meta.title + return + + def find_ova_episode(self): + """ + 搜索OVA的集数 + TODO:模糊匹配OVA的集数 + """ + pass - self.vcb_meta.season = max_season_number - logger.info(f"获取到最终季,季度为 {self.vcb_meta.season}") @staticmethod def roman_to_int(s) -> int: @@ -250,21 +273,9 @@ class ReMeta: return total -def test(title: str): - # 示例文件名 - pre_title = title - # 提取方括号内的内容,不包括方括号 - content = re.findall(r'\[(.*?)\]', pre_title) - - print(content) - - -if __name__ == '__main__': - # title = "[BeanSub&VCB-Studio] Jujutsu Kaisen [26][Ma10p_1080p][x265_flac].mkv " - # test(title) - - ReMeta( - ova_switch=True, - ).handel_file(Path( - r"[Nekomoe kissaten&VCB-Studio] Fruits Basket The Final [08][Ma10p_1080p][x265_flac].mkv")) +# if __name__ == '__main__': +# ReMeta( +# ova_switch=True, +# ).handel_file(Path( +# r"[Airota&Nekomoe kissaten&VCB-Studio] Yuru Camp [Heya Camp EP00][Ma10p_1080p][x265_flac].mkv")) From 0265a2dc122eac0fbbe356ace88e632c31d1b569 Mon Sep 17 00:00:00 2001 From: Pixel-LH <2569646547@qq.com> Date: Mon, 2 Sep 2024 00:16:14 +0800 Subject: [PATCH 038/218] =?UTF-8?q?update:=E6=9B=B4=E6=96=B0=E6=95=B4?= =?UTF-8?q?=E7=90=86VCB=E5=8A=A8=E6=BC=AB=E5=8E=8B=E5=88=B6=E7=BB=84?= =?UTF-8?q?=E4=BD=9C=E5=93=81=E6=8F=92=E4=BB=B6=E7=89=88=E6=9C=AC=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index de920a7..06d08c8 100644 --- a/package.json +++ b/package.json @@ -319,7 +319,7 @@ "name": "整理VCB动漫压制组作品", "description": "一款辅助整理&提高识别VCB-Stuido动漫压制组作品的插件", "labels": "文件整理,识别", - "version": "1.8", + "version": "1.8.2", "icon": "vcbmonitor.png", "author": "pixel@qingwa", "level": 2, From 6112d2989f5350705300c64565ce60c3ab628a62 Mon Sep 17 00:00:00 2001 From: Pixel-LH <2569646547@qq.com> Date: Mon, 2 Sep 2024 01:00:30 +0800 Subject: [PATCH 039/218] =?UTF-8?q?fix:ova/oad=E9=9B=86=E6=95=B0=E7=B4=AF?= =?UTF-8?q?=E5=8A=A0=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/vcbanimemonitor/__init__.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/plugins/vcbanimemonitor/__init__.py b/plugins/vcbanimemonitor/__init__.py index 97ae16c..be92c38 100644 --- a/plugins/vcbanimemonitor/__init__.py +++ b/plugins/vcbanimemonitor/__init__.py @@ -400,20 +400,21 @@ class VCBAnimeMonitor(_PluginBase): return if remeta.is_ova and self._switch_ova: logger.info(f"{file_path.name} 为OVA资源,开始历史记录处理") - ova_history_ep_list = self.plugindata.get(file_meta.title, []) - if ova_history_ep_list: + ova_history_ep_list = self.get_data(file_meta.title) + if ova_history_ep_list and isinstance(ova_history_ep_list, list): ep = file_meta.begin_episode if ep in ova_history_ep_list: for i in range(1, 100): if ep + i not in ova_history_ep_list: ova_history_ep_list.append(ep + i) file_meta.begin_episode = ep + i + logger.info(f"{file_path.name} 为OVA资源,历史记录中已存在,自动识别为第{ep + i}集") break else: ova_history_ep_list.append(ep) - self.plugindata.put(file_meta.title, ova_history_ep_list) + self.save_data(file_meta.title, ova_history_ep_list) else: - self.plugindata.put(file_meta.title, [file_meta.begin_episode]) + self.save_data(file_meta.title, [file_meta.begin_episode]) else: return From 6d68d3c3b03702e647cc522eeefef8520403f17f Mon Sep 17 00:00:00 2001 From: Pixel-LH <2569646547@qq.com> Date: Mon, 2 Sep 2024 22:40:29 +0800 Subject: [PATCH 040/218] =?UTF-8?q?update:=E4=BF=AE=E5=A4=8D=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E8=BE=93=E5=87=BA&=E5=90=8C=E6=AD=A5=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E7=9B=91=E6=8E=A7=E6=8F=92=E4=BB=B6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 +- plugins/vcbanimemonitor/__init__.py | 49 ++++++++++++++++++++--------- plugins/vcbanimemonitor/remeta.py | 11 +++++-- 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index 06d08c8..b9bac71 100644 --- a/package.json +++ b/package.json @@ -319,11 +319,12 @@ "name": "整理VCB动漫压制组作品", "description": "一款辅助整理&提高识别VCB-Stuido动漫压制组作品的插件", "labels": "文件整理,识别", - "version": "1.8.2", + "version": "1.8.2.1", "icon": "vcbmonitor.png", "author": "pixel@qingwa", "level": 2, "history": { + "v1.8.2.1": "修复日志输出&同步目录监控插件功能", "v1.8.2": "提高识别率", "v1.8.1": "重构插件,测试版", "v1.8": "增加了元数据刮削开关,升级后需要手动打开,否则默认不刮削", diff --git a/plugins/vcbanimemonitor/__init__.py b/plugins/vcbanimemonitor/__init__.py index be92c38..5558f60 100644 --- a/plugins/vcbanimemonitor/__init__.py +++ b/plugins/vcbanimemonitor/__init__.py @@ -15,6 +15,7 @@ from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer from watchdog.observers.polling import PollingObserver from app import schemas +from app.chain.media import MediaChain from app.chain.tmdb import TmdbChain from app.chain.transfer import TransferChain from app.core.config import settings @@ -75,7 +76,7 @@ class VCBAnimeMonitor(_PluginBase): # 插件图标 plugin_icon = "vcbmonitor.png" # 插件版本 - plugin_version = "1.8.2" + plugin_version = "1.8.2.1" # 插件作者 plugin_author = "pixel@qingwa" # 作者主页 @@ -97,6 +98,7 @@ class VCBAnimeMonitor(_PluginBase): downloadhis = None transferchian = None tmdbchain = None + mediaChain = None _observer = [] _enabled = False _notify = False @@ -123,6 +125,7 @@ class VCBAnimeMonitor(_PluginBase): self.transferhis = TransferHistoryOper() self.downloadhis = DownloadHistoryOper() self.transferchian = TransferChain() + self.mediaChain = MediaChain() self.tmdbchain = TmdbChain() # 清空配置 self._dirconf = {} @@ -180,7 +183,7 @@ class VCBAnimeMonitor(_PluginBase): observer.start() logger.info(f"{self._torrents_path} 的种子目录监控服务启动,开启监控新增的VCB-Studio种子文件") except Exception as e: - logger.error(f"{self._torrents_path} 启动种子目录监控失败:{str(e)}") + logger.debug(f"{self._torrents_path} 启动种子目录监控失败:{str(e)}") else: logger.info("种子目录为空,不转移qb中正在下载的VCB-Studio文件") @@ -375,21 +378,30 @@ class VCBAnimeMonitor(_PluginBase): logger.debug(f"{event_path} 不是媒体文件") return + # 判断是不是蓝光目录 + bluray_flag = False + if re.search(r"BDMV[/\\]STREAM", event_path, re.IGNORECASE): + bluray_flag = True + # 截取BDMV前面的路径 + blurray_dir = event_path[:event_path.find("BDMV")] + file_path = Path(blurray_dir) + logger.info(f"{event_path} 是蓝光目录,更正文件路径为:{str(file_path)}") + # 查询历史记录,已转移的不处理 if self.transferhis.get_by_src(str(file_path)): logger.info(f"{file_path} 已整理过") return # 元数据 - if file_path.parent.name in ["SPs", "Scans", "CDs"]: - logger.warn("位于特典等其他特殊目录下,跳过处理") + if file_path.parent.name.lower() in ["sps", "scans", "cds", "previews", "extras"]: + logger.warn("位于特典或其他特殊目录下,跳过处理") return if 'VCB-Studio' not in file_path.stem.strip(): logger.warn("不属于VCB的作品,不处理!") return - remeta = ReMeta(ova_switch=self._switch_ova,) + remeta = ReMeta(ova_switch=self._switch_ova) file_meta = remeta.handel_file(file_path=file_path) if file_meta: if not file_meta.name: @@ -408,14 +420,14 @@ class VCBAnimeMonitor(_PluginBase): if ep + i not in ova_history_ep_list: ova_history_ep_list.append(ep + i) file_meta.begin_episode = ep + i - logger.info(f"{file_path.name} 为OVA资源,历史记录中已存在,自动识别为第{ep + i}集") + logger.info( + f"{file_path.name} 为OVA资源,历史记录中已存在,自动识别为第{ep + i}集") break else: ova_history_ep_list.append(ep) self.save_data(file_meta.title, ova_history_ep_list) else: self.save_data(file_meta.title, [file_meta.begin_episode]) - else: return @@ -431,14 +443,23 @@ class VCBAnimeMonitor(_PluginBase): # 根据父路径获取下载历史 download_history = None - # 按文件全路径查询 - download_file = self.downloadhis.get_file_by_fullpath(str(file_path)) - if download_file: - download_history = self.downloadhis.get_by_hash(download_file.download_hash) + if bluray_flag: + # 蓝光原盘,按目录名查询 + # FIXME 理论上DownloadHistory表中的path应该是全路径,但实际表中登记的数据只有目录名,暂按目录名查询 + download_history = self.downloadhis.get_by_path(file_path.name) + else: + # 按文件全路径查询 + download_file = self.downloadhis.get_file_by_fullpath(str(file_path)) + if download_file: + download_history = self.downloadhis.get_by_hash(download_file.download_hash) # 识别媒体信息 - mediainfo: MediaInfo = self.chain.recognize_media(meta=file_meta, - tmdbid=download_history.tmdbid if download_history else None) + if download_history and download_history.tmdbid: + mediainfo: MediaInfo = self.mediaChain.recognize_media(mtype=MediaType(download_history.type), + tmdbid=download_history.tmdbid, + doubanid=download_history.doubanid) + else: + mediainfo: MediaInfo = self.mediaChain.recognize_by_meta(file_meta) if not mediainfo: logger.warn(f'未识别到媒体信息,标题:{file_meta.name}') @@ -628,13 +649,13 @@ class VCBAnimeMonitor(_PluginBase): if not torrent_path.exists(): return # 只处理刚刚添加的种子也就是获取正在下载的种子 - logger.info(f"开始转移qb中正在下载的VCB资源,转移目录为:{self.new_save_path}") # 等待种子文件下载完成 time.sleep(5) with lock: torrents = self.qb.get_downloading_torrents() for torrent in torrents: if "VCB-Studio" in torrent.name: + logger.info(f"开始转移qb中正在下载的VCB资源,转移目录为:{self.new_save_path}") # 原本存在的暂停的种子不处理 if torrent.state_enum == qbittorrentapi.TorrentState.PAUSED_DOWNLOAD: continue diff --git a/plugins/vcbanimemonitor/remeta.py b/plugins/vcbanimemonitor/remeta.py index 260e17f..add6e32 100644 --- a/plugins/vcbanimemonitor/remeta.py +++ b/plugins/vcbanimemonitor/remeta.py @@ -98,14 +98,19 @@ class ReMeta: self.is_ova = self.vcb_meta.is_ova meta = MetaInfoPath(file_path) meta.title = self.vcb_meta.title + meta.name = self.vcb_meta.title meta.en_name = self.vcb_meta.title - meta.begin_season = self.vcb_meta.season - if self.vcb_meta.ep: - meta.begin_episode = self.vcb_meta.ep + meta.cn_name = self.vcb_meta.title if self.vcb_meta.type == "Movie": meta.type = MediaType.MOVIE else: meta.type = MediaType.TV + if self.vcb_meta.ep is not None: + meta.begin_episode = self.vcb_meta.ep + if self.vcb_meta.season is not None: + meta.begin_season = self.vcb_meta.season + if self.vcb_meta.tmdb_id is not None: + meta.tmdbid = self.vcb_meta.tmdb_id return meta def split_season_ep(self): From a5b1e33cebb744b86e596f4f410f29d298e57950 Mon Sep 17 00:00:00 2001 From: thsrite Date: Tue, 3 Sep 2024 12:38:56 +0800 Subject: [PATCH 041/218] =?UTF-8?q?fix=20=E5=AA=92=E4=BD=93=E5=BA=93?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=E9=80=9A=E7=9F=A5v1.3\n=20=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E5=A4=84=E7=90=86Emby=E9=83=A8=E5=88=86=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E6=9A=82=E5=81=9C=E9=87=8D=E5=A4=8D=E6=8E=A8?= =?UTF-8?q?=E9=80=81=E5=81=9C=E6=AD=A2=E6=92=AD=E6=94=BEwebhook=E7=9A=84?= =?UTF-8?q?=E5=9C=BA=E6=99=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1607 ++++++++++++++-------------- plugins/mediaservermsg/__init__.py | 31 +- 2 files changed, 834 insertions(+), 804 deletions(-) diff --git a/package.json b/package.json index b9bac71..e1207bc 100644 --- a/package.json +++ b/package.json @@ -1,806 +1,807 @@ { - "AutoSignIn": { - "name": "站点自动签到", - "description": "自动模拟登录、签到站点。", - "labels": "站点", - "version": "2.4.2", - "icon": "signin.png", - "author": "thsrite", - "level": 2, - "history": { - "v2.4.2": "修复PT时间签到失败问题", - "v2.4.1": "修复海胆签到失败问题", - "v2.4": "适配m-team Api地址变化", - "v2.3.2": "修复YemaPT登录失败,支持YemaPT自动签到", - "v2.3.1": "修复签到报错问题", - "v2.3": "优化模拟登录逻辑,支持YemaPT模拟登录", - "v2.2": "适配馒头最新变化,需要升级至v1.8.5+版本且维护好Authorization", - "v2.1": "增强API安全性", - "v2.0": "站点签到时更新站点使用统计信息,需要主程序升级至v1.8.3+版本", - "v1.9": "支持馒头新架构自动签到" - } - }, - "CustomSites": { - "name": "自定义站点", - "description": "增加自定义站点为签到和统计使用。", - "labels": "站点", - "version": "1.0", - "icon": "world.png", - "author": "lightolly", - "level": 2 - }, - "SiteStatistic": { - "name": "站点数据统计", - "description": "自动统计和展示站点数据。", - "labels": "站点,仪表板", - "version": "4.0.1", - "icon": "statistic.png", - "author": "lightolly", - "level": 2, - "history": { - "v4.0.1": "修复PTT的魔力值统计", - "v4.0": "修复插件数据页异常", - "v3.9.3": "修复PTT的用户等级统计", - "v3.9.2": "修复YemaPT的上传下载统计错误", - "v3.9.1": "修复mteam域名地址", - "v3.9": "修复YemaPT站点数据统计", - "v3.8": "适配m-team Api地址变化", - "v3.7": "修复观众做种数据统计", - "v3.6": "支持站点数据统计刷新后触发插件事件", - "v3.5": "站点数据统计支持YemaPT", - "v3.4": "修复馒头站点数据统计", - "v3.3": "支持选择仪表板组件规格", - "v3.2": "支持在仪表板中显示站点统计信息,需要主程序升级至v1.8.7+版本", - "v3.1": "修复观众无法统计做总数和做种体积的bug", - "v3.0": "适配馒头数据统计,需要升级至v1.8.5+版本,且在站点信息中维护好API Key", - "v2.9": "增强API安全性", - "v2.8": "修复馒头未读消息统计", - "v2.7": "修复憨憨种子信息只统计第一页的问题,增加移除失效统计选项", - "v2.6": "支持馒头新架构数据统计" - } - }, - "SiteRefresh": { - "name": "站点自动更新", - "description": "使用浏览器模拟登录站点获取Cookie和UA。", - "labels": "站点", - "version": "1.2", - "icon": "Chrome_A.png", - "author": "thsrite", - "level": 2 - }, - "DoubanSync": { - "name": "豆瓣想看", - "description": "同步豆瓣想看数据,自动添加订阅。", - "labels": "订阅", - "version": "1.8", - "icon": "douban.png", - "author": "jxxghp", - "level": 2, - "history": { - "v1.8": "不同步在看条目", - "v1.7": "增强API安全性", - "v1.6": "同步历史记录支持手动删除,需要主程序升级至v1.8.4+版本", - "v1.5": "豆瓣信息识别后直接添加订阅,不进行搜索下载" - } - }, - "DirMonitor": { - "name": "目录监控", - "description": "监控目录文件发生变化时实时整理到媒体库。", - "labels": "文件整理", - "version": "2.4", - "icon": "directory.png", - "author": "jxxghp", - "level": 1, - "history": { - "v2.4": "修复目录监控不使用ChatGPT辅助识别问题", - "v2.3": "特殊场景下补充转移成功历史记录", - "v2.2": "更新目录设置说明", - "v2.1": "增加了元数据刮削开关,升级后需要手动打开,否则默认不刮削", - "v2.0": "增强API安全性", - "v1.9": "修复目录监控不能正确获取下载历史记录进行识别的问题" - } - }, - "ChineseSubFinder": { - "name": "ChineseSubFinder", - "description": "整理入库时通知ChineseSubFinder下载字幕。", - "labels": "字幕", - "version": "1.1", - "icon": "chinesesubfinder.png", - "author": "jxxghp", - "level": 1 - }, - "DoubanRank": { - "name": "豆瓣榜单订阅", - "description": "监控豆瓣热门榜单,自动添加订阅。", - "labels": "订阅", - "version": "1.9.1", - "icon": "movie.jpg", - "author": "jxxghp", - "level": 2, - "history": { - "v1.9.1": "优化媒体类型的判断处理", - "v1.9": "增强API安全性", - "v1.8": "订阅历史记录支持手动删除,需要主程序升级至v1.8.4+版本" - } - }, - "LibraryScraper": { - "name": "媒体库刮削", - "description": "定时对媒体库进行刮削,补齐缺失元数据和图片。", - "labels": "刮削", - "version": "1.5", - "icon": "scraper.png", - "author": "jxxghp", - "level": 1, - "history": { - "v1.5": "修复未获取fanart图片的问题", - "v1.4.1": "修复nfo文件读取失败时任务中断问题" - } - }, - "TorrentRemover": { - "name": "自动删种", - "description": "自动删除下载器中的下载任务。", - "labels": "做种", - "version": "1.2.2", - "icon": "delete.jpg", - "author": "jxxghp", - "level": 2 - }, - "MediaSyncDel": { - "name": "媒体文件同步删除", - "description": "同步删除历史记录、源文件和下载任务。", - "labels": "文件整理", - "version": "1.7", - "icon": "mediasyncdel.png", - "author": "thsrite", - "level": 1, - "history": { - "v1.7": "修复重新整理被一并删除问题", - "v1.6": "修复删除辅种", - "v1.5": "支持手动删除订阅历史记录(本次更新之后的历史)" - } - }, - "CustomHosts": { - "name": "自定义Hosts", - "description": "修改系统hosts文件,加速网络访问。", - "labels": "网络", - "version": "1.2", - "icon": "hosts.png", - "author": "thsrite", - "level": 1, - "history": { - "v1.2": "支持写入注释", - "v1.1": "关闭插件时自动恢复系统hosts" - } - }, - "SpeedLimiter": { - "name": "播放限速", - "description": "外网播放媒体库视频时,自动对下载器进行限速。", - "labels": "网络", - "version": "1.2", - "icon": "Librespeed_A.png", - "author": "Shurelol", - "level": 1, - "history": { - "v1.2": "增加不限速路径配置,以应对网盘直链播放的情况" - } - }, - "CloudflareSpeedTest": { - "name": "Cloudflare IP优选", - "description": "🌩 测试 Cloudflare CDN 延迟和速度,自动优选IP。", - "labels": "网络,站点", - "version": "1.4", - "icon": "cloudflare.jpg", - "author": "thsrite", - "level": 1, - "history": { - "v1.4": "修复立即运行一次", - "v1.3": "调整插件开启状态判断条件", - "v1.2": "增强API安全性" - } - }, - "BestFilmVersion": { - "name": "收藏洗版", - "description": "Jellyfin/Emby/Plex点击收藏电影后,自动订阅洗版。", - "labels": "订阅", - "version": "2.2", - "icon": "like.jpg", - "author": "wlj", - "level": 2, - "history": { - "v2.3": "修复定时任务运行问题,Jellyfin的Webhook需要主程序大于1.8.7才能正常订阅。", - "v2.2": "修复运行报错问题" - } - }, - "MediaServerMsg": { - "name": "媒体库服务器通知", - "description": "发送Emby/Jellyfin/Plex服务器的播放、入库等通知消息。", - "labels": "消息通知,媒体库", - "version": "1.2", - "icon": "mediaplay.png", - "author": "jxxghp", - "level": 1, - "history": { - "v1.2": "播放通知增加超链接跳转(需要v1.9.4+)" - } - }, - "MediaServerRefresh": { - "name": "媒体库服务器刷新", - "description": "入库后自动刷新Emby/Jellyfin/Plex服务器海报墙。", - "labels": "媒体库", - "version": "1.2", - "icon": "refresh2.png", - "author": "jxxghp", - "level": 1 - }, - "WebHook": { - "name": "Webhook", - "description": "事件发生时向第三方地址发送请求。", - "version": "1.0", - "icon": "webhook.png", - "author": "jxxghp", - "level": 1 - }, - "ChatGPT": { - "name": "ChatGPT", - "description": "消息交互支持与ChatGPT对话。", - "labels": "消息通知,识别", - "version": "1.3", - "icon": "Chatgpt_A.png", - "author": "jxxghp", - "level": 1 - }, - "NAStoolSync": { - "name": "历史记录同步", - "description": "同步NAStool历史记录、下载记录、插件记录到MoviePilot。", - "version": "1.0", - "icon": "Nastools_A.png", - "author": "thsrite", - "level": 1 - }, - "MessageForward": { - "name": "消息转发", - "description": "根据正则转发通知到其他WeChat应用。", - "labels": "消息通知", - "version": "1.1", - "icon": "forward.png", - "author": "thsrite", - "level": 1 - }, - "AutoBackup": { - "name": "自动备份", - "description": "自动备份数据和配置文件。", - "labels": "系统设置", - "version": "1.3", - "icon": "Time_machine_B.png", - "author": "thsrite", - "level": 1, - "history": { - "v1.3": "去除已废弃的环境变量引用", - "v1.2": "增强API安全性" - } - }, - "IYUUAutoSeed": { - "name": "IYUU自动辅种", - "description": "基于IYUU官方Api实现自动辅种。", - "labels": "做种,IYUU", - "version": "1.9.5", - "icon": "IYUU.png", - "author": "jxxghp", - "level": 2, - "history": { - "v1.9.5": "Revert qBittorrent跳检之后自动开始", - "v1.9.4": "修复qBittorrent辅种后不会自动开始做种", - "v1.9.3": "修复Monika因缺少rsskey,种子下载失败的问题", - "v1.9.2": "适配馒头使用API下载种子", - "v1.9.1": "支持自定义辅种的种子分类", - "v1.9": "支持自定义辅种后标签,支持将站点名作为标签", - "v1.8.2": "qBittorrent 支持跳过校验", - "v1.8.1": "判断辅种失败的情况下,是否是由于token未进行站点绑定导致的", - "v1.8": "适配新版本IYUU开发版", - "v1.7": "适配馒头最新变化,需要升级至v1.8.5+版本且维护好Authorization", - "v1.6": "增加不辅种小体积种子功能", - "v1.5": "支持馒头新架构辅种" - } - }, - "CrossSeed": { - "name": "青蛙辅种助手", - "description": "参考ReseedPuppy和IYUU辅种插件实现自动辅种,支持站点:青蛙、AGSVPT、麒麟、UBits、聆音、憨憨等。", - "labels": "做种", - "version": "2.3", - "icon": "qingwa.png", - "author": "233@qingwa", - "level": 2, - "history": { - "v2.2": "站点停用后会同步暂停对该站点的辅种", - "v2.3": "站点辅种支持代理" - } - }, - "VCBAnimeMonitor": { - "name": "整理VCB动漫压制组作品", - "description": "一款辅助整理&提高识别VCB-Stuido动漫压制组作品的插件", - "labels": "文件整理,识别", - "version": "1.8.2.1", - "icon": "vcbmonitor.png", - "author": "pixel@qingwa", - "level": 2, - "history": { - "v1.8.2.1": "修复日志输出&同步目录监控插件功能", - "v1.8.2": "提高识别率", - "v1.8.1": "重构插件,测试版", - "v1.8": "增加了元数据刮削开关,升级后需要手动打开,否则默认不刮削", - "v1.7.1": "修复偶尔安装失败问题" - } - }, - "TorrentTransfer": { - "name": "自动转移做种", - "description": "定期转移下载器中的做种任务到另一个下载器。", - "labels": "做种", - "version": "1.5", - "icon": "seed.png", - "author": "jxxghp", - "level": 2, - "history": { - "v1.5":"修复在转移时只保留了第一个tracker,导致红种问题。此修复确保保留所有的tracker,以提高在不同网络条件下的可达性。", - "v1.4": "支持自动删除源下载器在目的下载器中存在的种子" - } - }, - "RssSubscribe": { - "name": "自定义订阅", - "description": "定时刷新RSS报文,识别内容后添加订阅或直接下载。", - "labels": "订阅", - "version": "1.5", - "icon": "rss.png", - "author": "jxxghp", - "level": 2, - "history": { - "v1.5": "支持按种子大小过滤种子", - "v1.4": "修复剧集本地是否存在的判断错误问题", - "v1.3": "支持手动删除订阅历史记录" - } - }, - "SyncDownloadFiles": { - "name": "下载器文件同步", - "description": "同步下载器的文件信息到数据库,删除文件时联动删除下载任务。", - "labels": "下载管理", - "version": "1.1.1", - "icon": "Youtube-dl_A.png", - "author": "thsrite", - "level": 1, - "history": { - "v1.1.1": "修复时区问题导致的上次同步后8h内的种子不同步的问题" - } - }, - "BrushFlow": { - "name": "站点刷流", - "description": "自动托管刷流,将会提高对应站点的访问频率。", - "labels": "刷流,仪表板", - "version": "3.7", - "icon": "brush.jpg", - "author": "jxxghp,InfinityPacer", - "level": 2, - "history": { - "v3.7": "下载数量调整为仅获取刷流标签种子并修复了一些细节问题", - "v3.6": "优化检查服务中的时间管控", - "v3.5": "移除「删种排除MoviePilot任务」配置项(请使用「删除排除标签」替代),完善刷流任务触发插件事件相关逻辑(联动H&R助手)", - "v3.4": "移除「记录更多日志」配置项并调整为DEBUG日志,支持「删除排除标签」配置项,增加刷流任务时支持触发插件事件(联动H&R助手)", - "v3.3": "支持QB删除种子时强制汇报Tracker,站点独立配置增加「站点全局H&R」配置项", - "v3.2": "支持推送QB种子时启用「先下载首尾文件块」选项", - "v3.1": "支持仪表板显示站点刷流数据,需要主程序升级v1.8.7+版本", - "v3.0": "优化不同站点刷流到相同种子的逻辑,修复数据页滚动闪烁,部分日志优化", - "v2.9": "优化动态删除消息推送,优化配置页UI显示及部分配置项,支持配置种子分类以及开启自动分类管理,取消单独适配站点时区逻辑,可通过配置项「pubtime」自行适配", - "v2.8": "优化UI显示以及提升性能", - "v2.7": "动态删除种子规则调整(请注意查阅插件文档),站点独立配置样式优化、日志优化,修复部分配置项无法配置小数的问题,修复部分场景可能导致重复下载的问题", - "v2.6": "修复排除订阅功能", - "v2.5": "增加H&R做种时间、下载器监控配置项,刷流前置条件逻辑调整,代理下载种子默认为关闭" - } - }, - "DownloadingMsg": { - "name": "下载进度推送", - "description": "定时推送正在下载进度。", - "labels": "消息通知,下载管理", - "version": "1.1", - "icon": "downloadmsg.png", - "author": "thsrite", - "level": 2 - }, - "AutoClean": { - "name": "定时清理媒体库", - "description": "定时清理用户下载的种子、源文件、媒体库文件。", - "labels": "媒体库", - "version": "1.1", - "icon": "clean.png", - "author": "thsrite", - "level": 2 - }, - "InvitesSignin": { - "name": "药丸签到", - "description": "药丸论坛签到。", - "labels": "站点", - "version": "1.4", - "icon": "invites.png", - "author": "thsrite", - "level": 2, - "history": { - "v1.4": "自定义保留消息天数" - } - }, - "PersonMeta": { - "name": "演职人员刮削", - "description": "刮削演职人员图片以及中文名称。", - "labels": "媒体库,刮削", - "version": "1.4", - "icon": "actor.png", - "author": "jxxghp", - "level": 1, - "history": { - "v1.4": "人物图片调整为优先从TMDB获取,避免douban图片CDN加载过慢的问题", - "v1.3": "修复v1.8.5版本后刮削报错问题" - } - }, - "MoviePilotUpdateNotify": { - "name": "MoviePilot更新推送", - "description": "MoviePilot推送release更新通知、自动重启。", - "labels": "消息通知,自动更新", - "version": "1.4", - "icon": "Moviepilot_A.png", - "author": "thsrite", - "level": 1, - "history": { - "v1.4": "兼容更新内容带版本号的情况", - "v1.3": "增加前端版本更新检查,需要主程序升级至v1.8.4+版本" - } - }, - "CloudDiskDel": { - "name": "云盘文件删除", - "description": "媒体库删除strm文件后同步删除云盘资源。", - "labels": "媒体库", - "version": "1.3", - "icon": "clouddisk.png", - "author": "thsrite", - "level": 1 - }, - "BarkMsg": { - "name": "Bark消息推送", - "description": "支持使用Bark发送消息通知。", - "labels": "消息通知", - "version": "1.1", - "icon": "Bark_A.png", - "author": "jxxghp", - "level": 1 - }, - "IyuuMsg": { - "name": "IYUU消息推送", - "description": "支持使用IYUU发送消息通知。", - "labels": "消息通知,IYUU", - "version": "1.2", - "icon": "Iyuu_A.png", - "author": "jxxghp", - "level": 1 - }, - "PushDeerMsg": { - "name": "PushDeer消息推送", - "description": "支持使用PushDeer发送消息通知。", - "labels": "消息通知", - "version": "1.1", - "icon": "pushdeer.png", - "author": "jxxghp", - "level": 1 - }, - "ConfigCenter": { - "name": "配置中心", - "description": "快速调整部分系统设定。", - "labels": "系统设置", - "version": "2.6", - "icon": "setting.png", - "author": "jxxghp", - "level": 1, - "history": { - "v2.6": "支持DOH相关配置项", - "v2.5": "增加Github加速服务器设置项" - } - }, - "WorkWechatMsg": { - "name": "企微机器人消息推送", - "description": "支持使用企业微信群聊机器人发送消息通知。", - "labels": "消息通知", - "version": "1.0", - "icon": "Wecom_A.png", - "author": "叮叮当", - "level": 1 - }, - "EpisodeGroupMeta": { - "name": "TMDB剧集组刮削", - "description": "从TMDB剧集组刮削季集的实际顺序。", - "labels": "刮削", - "version": "1.1", - "icon": "Element_A.png", - "author": "叮叮当", - "level": 1 - }, - "CustomIndexer": { - "name": "自定义索引站点", - "description": "修改或扩展内建索引器支持的站点。", - "labels": "站点", - "version": "1.0", - "icon": "spider.png", - "author": "jxxghp", - "level": 1 - }, - "FFmpegThumb": { - "name": "FFmpeg缩略图", - "description": "TheMovieDb没有背景图片时使用FFmpeg截取视频文件缩略图", - "labels": "刮削", - "version": "1.2", - "icon": "ffmpeg.png", - "author": "jxxghp", - "level": 1 - }, - "PushPlusMsg": { - "name": "PushPlus消息推送", - "description": "支持使用PushPlus发送消息通知。", - "labels": "消息通知", - "version": "1.1", - "icon": "Pushplus_A.png", - "author": "cheng", - "level": 1 - }, - "DownloadSiteTag": { - "name": "下载任务分类与标签", - "description": "自动给下载任务分类与打站点标签、剧集名称标签", - "labels": "下载管理", - "version": "2.1", - "icon": "Youtube-dl_B.png", - "author": "叮叮当", - "level": 1, - "history": { - "v2.1": "修复错误的TmdbHelper模块引用" - } - }, - "RemoveLink": { - "name": "清理硬链接", - "description": "监控目录内文件被删除时,同步删除监控目录内所有和它硬链接的文件", - "labels": "文件整理", - "version": "2.2", - "icon": "Ombi_A.png", - "author": "DzAvril", - "level": 1, - "history": { - "v2.2": "修复直接删除文件夹导致的插件崩溃的bug", - "v2.1": "联动删除历史记录", - "v2.0": "联动删除种子,需安装插件[下载器助手]并打开监听源文件事件", - "v1.9": "增加清理刮削文件功能(beta)", - "v1.8": "增加清理空目录功能(beta)", - "v1.7": "修复因未监测重命名事件导致的清理硬链接失败的问题", - "v1.6": "提升插件性能" - } - }, - "LinkMonitor": { - "name": "实时硬链接", - "description": "监控目录文件变化,实时硬链接。", - "labels": "文件整理", - "version": "1.6", - "icon": "Linkace_C.png", - "author": "jxxghp", - "level": 1, - "history": { - "v1.6": "增强API安全性" - } - }, - "CategoryEditor": { - "name": "二级分类策略", - "description": "编辑下载目录和媒体库目录的二级分类规则。", - "labels": "文件整理", - "version": "1.2", - "icon": "Bookstack_A.png", - "author": "jxxghp", - "level": 1 - }, - "RemoteIdentifiers": { - "name": "共享识别词", - "description": "从Github、Etherpad等远程文件中获取共享识别词并应用。", - "labels": "识别", - "version": "2.2", - "icon": "words.png", - "author": "honue", - "level": 1 - }, - "NeoDBSync": { - "name": "NeoDB 想看", - "description": "同步 NeoDB 想看条目,自动添加订阅。", - "labels": "订阅", - "version": "1.1", - "icon": "NeoDB.jpeg", - "author": "hcplantern", - "level": 1, - "history": { - "v1.1": "直接添加订阅,不提前进行搜索下载" - } - }, - "PlayletCategory": { - "name": "短剧自动分类", - "description": "网络短剧自动整理到独立的分类目录。", - "labels": "文件整理", - "version": "2.0", - "icon": "Amule_A.png", - "author": "jxxghp", - "level": 1, - "history": { - "v2.0": "适配新的目录结构变化,短剧分类名称调整为配置目录路径,升级后需要重新调整设置后才能使用。" - } - }, - "DiagParamAdjust": { - "name": "诊断参数调整", - "description": "Emby专用插件|暂时性解决emby字幕偏移问题,需要emby安装Diagnostics插件。", - "labels": "Emby", - "version": "1.3", - "icon": "Gatus_A.png", - "author": "jeblove", - "level": 1 - }, - "QbCommand": { - "name": "QB远程操作", - "description": "通过定时任务或交互命令远程操作QB暂停/开始/限速等。", - "labels": "下载管理,Qbittorrent", - "version": "1.5", - "icon": "Qbittorrent_A.png", - "author": "DzAvril", - "level": 1, - "history": { - "v1.5": "可选特定路径下的做种不会被暂停", - "v1.4": "可选某些站点不再做种(暂停做种后不会被恢复)" - } - }, - "TrCommand": { - "name": "TR远程操作", - "description": "通过定时任务或交互命令远程操作TR暂停/开始/限速等。", - "labels": "下载管理,Transmission", - "version": "1.1", - "icon": "Transmission_A.png", - "author": "Hoey", - "level": 1 - }, - "IpDetect": { - "name": "本地IP检测", - "description": "如果QB、TR等服务在本地部署,当本地IP改变时自动修改其Server IP。", - "labels": "系统设置", - "version": "1.1", - "icon": "ipAddress.png", - "author": "DzAvril", - "level": 1 - }, - "TrackerEditor": { - "name": "Tracker替换", - "description": "批量替换种子tracker,支持周期性巡检(如为TR,仅支持4.0以上版本)。", - "labels": "做种", - "version": "1.5", - "icon": "trackereditor_A.png", - "author": "honue", - "level": 1 - }, - "ContractCheck": { - "name": "契约检查", - "description": "定时检查保种契约达成情况。", - "labels": "做种", - "version": "1.4", - "icon": "contract.png", - "author": "DzAvril", - "level": 1, - "history": { - "v1.4": "支持仪表板组件显示", - "v1.3": "修复观众做种数据异常问题", - "v1.2": "修复契约检查无数据返回的问题" - } - }, - "FeiShuMsg": { - "name": "飞书机器人消息通知", - "description": "支持使用飞书群聊机器人发送消息通知。", - "labels": "消息通知", - "version": "1.0", - "icon": "FeiShu_A.png", - "author": "InfinityPacer", - "level": 2 - }, - "IyuuAuth": { - "name": "IYUU站点绑定", - "description": "为IYUU账号绑定认证站点,以便用于用户认证和辅种。", - "labels": "IYUU", - "version": "1.1", - "icon": "Iyuu_A.png", - "author": "jxxghp", - "level": 1, - "history": { - "v1.1": "修复IYUU站点绑定失败问题" - } - }, - "NtfyMsg": { - "name": "ntfy消息推送", - "description": "支持使用ntfy发送消息通知。", - "labels": "消息通知", - "version": "1.0", - "icon": "Ntfy_A.png", - "author": "lethargicScribe", - "level": 1 - }, - "TmdbWallpaper": { - "name": "登录壁纸本地化", - "description": "将MoviePilot的登录壁纸下载到本地。", - "labels": "工具", - "version": "1.1", - "icon": "Macos_Sierra.png", - "author": "jxxghp", - "level": 1, - "history": { - "v1.1": "修复下载Bing每日壁纸时文件名错乱的问题" - } - }, - "MPServerStatus": { - "name": "MoviePilot服务器监控", - "description": "在仪表板中实时显示MoviePilot公共服务器状态。", - "labels": "仪表板", - "version": "1.0", - "icon": "Duplicati_A.png", - "author": "jxxghp", - "level": 1 - }, - "CleanInvalidSeed": { - "name": "清理QB无效做种", - "description": "清理已经被站点删除的种子及对应源文件,仅支持QB", - "labels": "Qbittorrent", - "version": "2.2", - "icon": "clean_a.png", - "author": "DzAvril", - "level": 1, - "history": { - "v2.2": "支持仅标记模式", - "v2.1": "1. 修复删除无效做种没有tg通知的问题。2. 检测未工作做种排除已暂停做种", - "v2.0": "修复检测不到无效做种的bug", - "v1.9": "增加自定义需删除做种的tracker的错误信息", - "v1.8": "增加远程命令切换全量通知;修复bug", - "v1.7": "修复因消息内容包含'_'导致telegram API调用失败的问题", - "v1.6": "修复当种子有多个标签时,通过标签过滤不删除种子会失效的问题", - "v1.5": "1. 增加通过分类、标签过滤不删除种子功能;2. 全量通知提供更多信息", - "v1.4": "修复插件功能失效的问题", - "v1.3": "1. 增加远程命令 2. 根据tracker error_message字段进行过滤,避免误删", - "v1.2": "修复配置页空白的问题", - "v1.1": "更新使用说明,以防使用不当误删文件", - "v1.0": "定时清理已经被站点删除的种子及对应源文件" - } - }, - "TrendingShow": { - "name": "流行趋势轮播", - "description": "在仪表板中显示流行趋势海报轮播图。", - "labels": "仪表板", - "version": "1.3", - "icon": "TrendingShow.jpg", - "author": "jxxghp", - "level": 1, - "history": { - "v1.3": "调整组件大小", - "v1.2": "不同屏幕大小,支持分开设置" - } - }, - "DailyWord": { - "name": "每日一言", - "description": "在仪表板中显示每日一言卡片。", - "labels": "仪表板", - "version": "1.1", - "icon": "Calibre_B.png", - "author": "jxxghp", - "level": 1 - }, - "ZvideoHelper": { - "name": "极影视助手", - "description": "极影视功能扩展", - "labels": "媒体库", - "version": "1.3", - "icon": "zvideo.png", - "author": "DzAvril", - "level": 1, - "history": { - "v1.3": "降低对豆瓣接口的请求频率", - "v1.2": "修复无法获取豆瓣评分的问题", - "v1.1": "支持将极影视评分修改为豆瓣评分", - "v1.0": "同步极影视在看/已看状态到豆瓣" - } + "AutoSignIn": { + "name": "站点自动签到", + "description": "自动模拟登录、签到站点。", + "labels": "站点", + "version": "2.4.2", + "icon": "signin.png", + "author": "thsrite", + "level": 2, + "history": { + "v2.4.2": "修复PT时间签到失败问题", + "v2.4.1": "修复海胆签到失败问题", + "v2.4": "适配m-team Api地址变化", + "v2.3.2": "修复YemaPT登录失败,支持YemaPT自动签到", + "v2.3.1": "修复签到报错问题", + "v2.3": "优化模拟登录逻辑,支持YemaPT模拟登录", + "v2.2": "适配馒头最新变化,需要升级至v1.8.5+版本且维护好Authorization", + "v2.1": "增强API安全性", + "v2.0": "站点签到时更新站点使用统计信息,需要主程序升级至v1.8.3+版本", + "v1.9": "支持馒头新架构自动签到" } + }, + "CustomSites": { + "name": "自定义站点", + "description": "增加自定义站点为签到和统计使用。", + "labels": "站点", + "version": "1.0", + "icon": "world.png", + "author": "lightolly", + "level": 2 + }, + "SiteStatistic": { + "name": "站点数据统计", + "description": "自动统计和展示站点数据。", + "labels": "站点,仪表板", + "version": "4.0.1", + "icon": "statistic.png", + "author": "lightolly", + "level": 2, + "history": { + "v4.0.1": "修复PTT的魔力值统计", + "v4.0": "修复插件数据页异常", + "v3.9.3": "修复PTT的用户等级统计", + "v3.9.2": "修复YemaPT的上传下载统计错误", + "v3.9.1": "修复mteam域名地址", + "v3.9": "修复YemaPT站点数据统计", + "v3.8": "适配m-team Api地址变化", + "v3.7": "修复观众做种数据统计", + "v3.6": "支持站点数据统计刷新后触发插件事件", + "v3.5": "站点数据统计支持YemaPT", + "v3.4": "修复馒头站点数据统计", + "v3.3": "支持选择仪表板组件规格", + "v3.2": "支持在仪表板中显示站点统计信息,需要主程序升级至v1.8.7+版本", + "v3.1": "修复观众无法统计做总数和做种体积的bug", + "v3.0": "适配馒头数据统计,需要升级至v1.8.5+版本,且在站点信息中维护好API Key", + "v2.9": "增强API安全性", + "v2.8": "修复馒头未读消息统计", + "v2.7": "修复憨憨种子信息只统计第一页的问题,增加移除失效统计选项", + "v2.6": "支持馒头新架构数据统计" + } + }, + "SiteRefresh": { + "name": "站点自动更新", + "description": "使用浏览器模拟登录站点获取Cookie和UA。", + "labels": "站点", + "version": "1.2", + "icon": "Chrome_A.png", + "author": "thsrite", + "level": 2 + }, + "DoubanSync": { + "name": "豆瓣想看", + "description": "同步豆瓣想看数据,自动添加订阅。", + "labels": "订阅", + "version": "1.8", + "icon": "douban.png", + "author": "jxxghp", + "level": 2, + "history": { + "v1.8": "不同步在看条目", + "v1.7": "增强API安全性", + "v1.6": "同步历史记录支持手动删除,需要主程序升级至v1.8.4+版本", + "v1.5": "豆瓣信息识别后直接添加订阅,不进行搜索下载" + } + }, + "DirMonitor": { + "name": "目录监控", + "description": "监控目录文件发生变化时实时整理到媒体库。", + "labels": "文件整理", + "version": "2.4", + "icon": "directory.png", + "author": "jxxghp", + "level": 1, + "history": { + "v2.4": "修复目录监控不使用ChatGPT辅助识别问题", + "v2.3": "特殊场景下补充转移成功历史记录", + "v2.2": "更新目录设置说明", + "v2.1": "增加了元数据刮削开关,升级后需要手动打开,否则默认不刮削", + "v2.0": "增强API安全性", + "v1.9": "修复目录监控不能正确获取下载历史记录进行识别的问题" + } + }, + "ChineseSubFinder": { + "name": "ChineseSubFinder", + "description": "整理入库时通知ChineseSubFinder下载字幕。", + "labels": "字幕", + "version": "1.1", + "icon": "chinesesubfinder.png", + "author": "jxxghp", + "level": 1 + }, + "DoubanRank": { + "name": "豆瓣榜单订阅", + "description": "监控豆瓣热门榜单,自动添加订阅。", + "labels": "订阅", + "version": "1.9.1", + "icon": "movie.jpg", + "author": "jxxghp", + "level": 2, + "history": { + "v1.9.1": "优化媒体类型的判断处理", + "v1.9": "增强API安全性", + "v1.8": "订阅历史记录支持手动删除,需要主程序升级至v1.8.4+版本" + } + }, + "LibraryScraper": { + "name": "媒体库刮削", + "description": "定时对媒体库进行刮削,补齐缺失元数据和图片。", + "labels": "刮削", + "version": "1.5", + "icon": "scraper.png", + "author": "jxxghp", + "level": 1, + "history": { + "v1.5": "修复未获取fanart图片的问题", + "v1.4.1": "修复nfo文件读取失败时任务中断问题" + } + }, + "TorrentRemover": { + "name": "自动删种", + "description": "自动删除下载器中的下载任务。", + "labels": "做种", + "version": "1.2.2", + "icon": "delete.jpg", + "author": "jxxghp", + "level": 2 + }, + "MediaSyncDel": { + "name": "媒体文件同步删除", + "description": "同步删除历史记录、源文件和下载任务。", + "labels": "文件整理", + "version": "1.7", + "icon": "mediasyncdel.png", + "author": "thsrite", + "level": 1, + "history": { + "v1.7": "修复重新整理被一并删除问题", + "v1.6": "修复删除辅种", + "v1.5": "支持手动删除订阅历史记录(本次更新之后的历史)" + } + }, + "CustomHosts": { + "name": "自定义Hosts", + "description": "修改系统hosts文件,加速网络访问。", + "labels": "网络", + "version": "1.2", + "icon": "hosts.png", + "author": "thsrite", + "level": 1, + "history": { + "v1.2": "支持写入注释", + "v1.1": "关闭插件时自动恢复系统hosts" + } + }, + "SpeedLimiter": { + "name": "播放限速", + "description": "外网播放媒体库视频时,自动对下载器进行限速。", + "labels": "网络", + "version": "1.2", + "icon": "Librespeed_A.png", + "author": "Shurelol", + "level": 1, + "history": { + "v1.2": "增加不限速路径配置,以应对网盘直链播放的情况" + } + }, + "CloudflareSpeedTest": { + "name": "Cloudflare IP优选", + "description": "🌩 测试 Cloudflare CDN 延迟和速度,自动优选IP。", + "labels": "网络,站点", + "version": "1.4", + "icon": "cloudflare.jpg", + "author": "thsrite", + "level": 1, + "history": { + "v1.4": "修复立即运行一次", + "v1.3": "调整插件开启状态判断条件", + "v1.2": "增强API安全性" + } + }, + "BestFilmVersion": { + "name": "收藏洗版", + "description": "Jellyfin/Emby/Plex点击收藏电影后,自动订阅洗版。", + "labels": "订阅", + "version": "2.2", + "icon": "like.jpg", + "author": "wlj", + "level": 2, + "history": { + "v2.3": "修复定时任务运行问题,Jellyfin的Webhook需要主程序大于1.8.7才能正常订阅。", + "v2.2": "修复运行报错问题" + } + }, + "MediaServerMsg": { + "name": "媒体库服务器通知", + "description": "发送Emby/Jellyfin/Plex服务器的播放、入库等通知消息。", + "labels": "消息通知,媒体库", + "version": "1.3", + "icon": "mediaplay.png", + "author": "jxxghp", + "level": 1, + "history": { + "v1.3": "兼容处理Emby部分客户端暂停重复推送停止播放webhook的场景", + "v1.2": "播放通知增加超链接跳转(需要v1.9.4+)" + } + }, + "MediaServerRefresh": { + "name": "媒体库服务器刷新", + "description": "入库后自动刷新Emby/Jellyfin/Plex服务器海报墙。", + "labels": "媒体库", + "version": "1.2", + "icon": "refresh2.png", + "author": "jxxghp", + "level": 1 + }, + "WebHook": { + "name": "Webhook", + "description": "事件发生时向第三方地址发送请求。", + "version": "1.0", + "icon": "webhook.png", + "author": "jxxghp", + "level": 1 + }, + "ChatGPT": { + "name": "ChatGPT", + "description": "消息交互支持与ChatGPT对话。", + "labels": "消息通知,识别", + "version": "1.3", + "icon": "Chatgpt_A.png", + "author": "jxxghp", + "level": 1 + }, + "NAStoolSync": { + "name": "历史记录同步", + "description": "同步NAStool历史记录、下载记录、插件记录到MoviePilot。", + "version": "1.0", + "icon": "Nastools_A.png", + "author": "thsrite", + "level": 1 + }, + "MessageForward": { + "name": "消息转发", + "description": "根据正则转发通知到其他WeChat应用。", + "labels": "消息通知", + "version": "1.1", + "icon": "forward.png", + "author": "thsrite", + "level": 1 + }, + "AutoBackup": { + "name": "自动备份", + "description": "自动备份数据和配置文件。", + "labels": "系统设置", + "version": "1.3", + "icon": "Time_machine_B.png", + "author": "thsrite", + "level": 1, + "history": { + "v1.3": "去除已废弃的环境变量引用", + "v1.2": "增强API安全性" + } + }, + "IYUUAutoSeed": { + "name": "IYUU自动辅种", + "description": "基于IYUU官方Api实现自动辅种。", + "labels": "做种,IYUU", + "version": "1.9.5", + "icon": "IYUU.png", + "author": "jxxghp", + "level": 2, + "history": { + "v1.9.5": "Revert qBittorrent跳检之后自动开始", + "v1.9.4": "修复qBittorrent辅种后不会自动开始做种", + "v1.9.3": "修复Monika因缺少rsskey,种子下载失败的问题", + "v1.9.2": "适配馒头使用API下载种子", + "v1.9.1": "支持自定义辅种的种子分类", + "v1.9": "支持自定义辅种后标签,支持将站点名作为标签", + "v1.8.2": "qBittorrent 支持跳过校验", + "v1.8.1": "判断辅种失败的情况下,是否是由于token未进行站点绑定导致的", + "v1.8": "适配新版本IYUU开发版", + "v1.7": "适配馒头最新变化,需要升级至v1.8.5+版本且维护好Authorization", + "v1.6": "增加不辅种小体积种子功能", + "v1.5": "支持馒头新架构辅种" + } + }, + "CrossSeed": { + "name": "青蛙辅种助手", + "description": "参考ReseedPuppy和IYUU辅种插件实现自动辅种,支持站点:青蛙、AGSVPT、麒麟、UBits、聆音、憨憨等。", + "labels": "做种", + "version": "2.3", + "icon": "qingwa.png", + "author": "233@qingwa", + "level": 2, + "history": { + "v2.2": "站点停用后会同步暂停对该站点的辅种", + "v2.3": "站点辅种支持代理" + } + }, + "VCBAnimeMonitor": { + "name": "整理VCB动漫压制组作品", + "description": "一款辅助整理&提高识别VCB-Stuido动漫压制组作品的插件", + "labels": "文件整理,识别", + "version": "1.8.2.1", + "icon": "vcbmonitor.png", + "author": "pixel@qingwa", + "level": 2, + "history": { + "v1.8.2.1": "修复日志输出&同步目录监控插件功能", + "v1.8.2": "提高识别率", + "v1.8.1": "重构插件,测试版", + "v1.8": "增加了元数据刮削开关,升级后需要手动打开,否则默认不刮削", + "v1.7.1": "修复偶尔安装失败问题" + } + }, + "TorrentTransfer": { + "name": "自动转移做种", + "description": "定期转移下载器中的做种任务到另一个下载器。", + "labels": "做种", + "version": "1.5", + "icon": "seed.png", + "author": "jxxghp", + "level": 2, + "history": { + "v1.5": "修复在转移时只保留了第一个tracker,导致红种问题。此修复确保保留所有的tracker,以提高在不同网络条件下的可达性。", + "v1.4": "支持自动删除源下载器在目的下载器中存在的种子" + } + }, + "RssSubscribe": { + "name": "自定义订阅", + "description": "定时刷新RSS报文,识别内容后添加订阅或直接下载。", + "labels": "订阅", + "version": "1.5", + "icon": "rss.png", + "author": "jxxghp", + "level": 2, + "history": { + "v1.5": "支持按种子大小过滤种子", + "v1.4": "修复剧集本地是否存在的判断错误问题", + "v1.3": "支持手动删除订阅历史记录" + } + }, + "SyncDownloadFiles": { + "name": "下载器文件同步", + "description": "同步下载器的文件信息到数据库,删除文件时联动删除下载任务。", + "labels": "下载管理", + "version": "1.1.1", + "icon": "Youtube-dl_A.png", + "author": "thsrite", + "level": 1, + "history": { + "v1.1.1": "修复时区问题导致的上次同步后8h内的种子不同步的问题" + } + }, + "BrushFlow": { + "name": "站点刷流", + "description": "自动托管刷流,将会提高对应站点的访问频率。", + "labels": "刷流,仪表板", + "version": "3.7", + "icon": "brush.jpg", + "author": "jxxghp,InfinityPacer", + "level": 2, + "history": { + "v3.7": "下载数量调整为仅获取刷流标签种子并修复了一些细节问题", + "v3.6": "优化检查服务中的时间管控", + "v3.5": "移除「删种排除MoviePilot任务」配置项(请使用「删除排除标签」替代),完善刷流任务触发插件事件相关逻辑(联动H&R助手)", + "v3.4": "移除「记录更多日志」配置项并调整为DEBUG日志,支持「删除排除标签」配置项,增加刷流任务时支持触发插件事件(联动H&R助手)", + "v3.3": "支持QB删除种子时强制汇报Tracker,站点独立配置增加「站点全局H&R」配置项", + "v3.2": "支持推送QB种子时启用「先下载首尾文件块」选项", + "v3.1": "支持仪表板显示站点刷流数据,需要主程序升级v1.8.7+版本", + "v3.0": "优化不同站点刷流到相同种子的逻辑,修复数据页滚动闪烁,部分日志优化", + "v2.9": "优化动态删除消息推送,优化配置页UI显示及部分配置项,支持配置种子分类以及开启自动分类管理,取消单独适配站点时区逻辑,可通过配置项「pubtime」自行适配", + "v2.8": "优化UI显示以及提升性能", + "v2.7": "动态删除种子规则调整(请注意查阅插件文档),站点独立配置样式优化、日志优化,修复部分配置项无法配置小数的问题,修复部分场景可能导致重复下载的问题", + "v2.6": "修复排除订阅功能", + "v2.5": "增加H&R做种时间、下载器监控配置项,刷流前置条件逻辑调整,代理下载种子默认为关闭" + } + }, + "DownloadingMsg": { + "name": "下载进度推送", + "description": "定时推送正在下载进度。", + "labels": "消息通知,下载管理", + "version": "1.1", + "icon": "downloadmsg.png", + "author": "thsrite", + "level": 2 + }, + "AutoClean": { + "name": "定时清理媒体库", + "description": "定时清理用户下载的种子、源文件、媒体库文件。", + "labels": "媒体库", + "version": "1.1", + "icon": "clean.png", + "author": "thsrite", + "level": 2 + }, + "InvitesSignin": { + "name": "药丸签到", + "description": "药丸论坛签到。", + "labels": "站点", + "version": "1.4", + "icon": "invites.png", + "author": "thsrite", + "level": 2, + "history": { + "v1.4": "自定义保留消息天数" + } + }, + "PersonMeta": { + "name": "演职人员刮削", + "description": "刮削演职人员图片以及中文名称。", + "labels": "媒体库,刮削", + "version": "1.4", + "icon": "actor.png", + "author": "jxxghp", + "level": 1, + "history": { + "v1.4": "人物图片调整为优先从TMDB获取,避免douban图片CDN加载过慢的问题", + "v1.3": "修复v1.8.5版本后刮削报错问题" + } + }, + "MoviePilotUpdateNotify": { + "name": "MoviePilot更新推送", + "description": "MoviePilot推送release更新通知、自动重启。", + "labels": "消息通知,自动更新", + "version": "1.4", + "icon": "Moviepilot_A.png", + "author": "thsrite", + "level": 1, + "history": { + "v1.4": "兼容更新内容带版本号的情况", + "v1.3": "增加前端版本更新检查,需要主程序升级至v1.8.4+版本" + } + }, + "CloudDiskDel": { + "name": "云盘文件删除", + "description": "媒体库删除strm文件后同步删除云盘资源。", + "labels": "媒体库", + "version": "1.3", + "icon": "clouddisk.png", + "author": "thsrite", + "level": 1 + }, + "BarkMsg": { + "name": "Bark消息推送", + "description": "支持使用Bark发送消息通知。", + "labels": "消息通知", + "version": "1.1", + "icon": "Bark_A.png", + "author": "jxxghp", + "level": 1 + }, + "IyuuMsg": { + "name": "IYUU消息推送", + "description": "支持使用IYUU发送消息通知。", + "labels": "消息通知,IYUU", + "version": "1.2", + "icon": "Iyuu_A.png", + "author": "jxxghp", + "level": 1 + }, + "PushDeerMsg": { + "name": "PushDeer消息推送", + "description": "支持使用PushDeer发送消息通知。", + "labels": "消息通知", + "version": "1.1", + "icon": "pushdeer.png", + "author": "jxxghp", + "level": 1 + }, + "ConfigCenter": { + "name": "配置中心", + "description": "快速调整部分系统设定。", + "labels": "系统设置", + "version": "2.6", + "icon": "setting.png", + "author": "jxxghp", + "level": 1, + "history": { + "v2.6": "支持DOH相关配置项", + "v2.5": "增加Github加速服务器设置项" + } + }, + "WorkWechatMsg": { + "name": "企微机器人消息推送", + "description": "支持使用企业微信群聊机器人发送消息通知。", + "labels": "消息通知", + "version": "1.0", + "icon": "Wecom_A.png", + "author": "叮叮当", + "level": 1 + }, + "EpisodeGroupMeta": { + "name": "TMDB剧集组刮削", + "description": "从TMDB剧集组刮削季集的实际顺序。", + "labels": "刮削", + "version": "1.1", + "icon": "Element_A.png", + "author": "叮叮当", + "level": 1 + }, + "CustomIndexer": { + "name": "自定义索引站点", + "description": "修改或扩展内建索引器支持的站点。", + "labels": "站点", + "version": "1.0", + "icon": "spider.png", + "author": "jxxghp", + "level": 1 + }, + "FFmpegThumb": { + "name": "FFmpeg缩略图", + "description": "TheMovieDb没有背景图片时使用FFmpeg截取视频文件缩略图", + "labels": "刮削", + "version": "1.2", + "icon": "ffmpeg.png", + "author": "jxxghp", + "level": 1 + }, + "PushPlusMsg": { + "name": "PushPlus消息推送", + "description": "支持使用PushPlus发送消息通知。", + "labels": "消息通知", + "version": "1.1", + "icon": "Pushplus_A.png", + "author": "cheng", + "level": 1 + }, + "DownloadSiteTag": { + "name": "下载任务分类与标签", + "description": "自动给下载任务分类与打站点标签、剧集名称标签", + "labels": "下载管理", + "version": "2.1", + "icon": "Youtube-dl_B.png", + "author": "叮叮当", + "level": 1, + "history": { + "v2.1": "修复错误的TmdbHelper模块引用" + } + }, + "RemoveLink": { + "name": "清理硬链接", + "description": "监控目录内文件被删除时,同步删除监控目录内所有和它硬链接的文件", + "labels": "文件整理", + "version": "2.2", + "icon": "Ombi_A.png", + "author": "DzAvril", + "level": 1, + "history": { + "v2.2": "修复直接删除文件夹导致的插件崩溃的bug", + "v2.1": "联动删除历史记录", + "v2.0": "联动删除种子,需安装插件[下载器助手]并打开监听源文件事件", + "v1.9": "增加清理刮削文件功能(beta)", + "v1.8": "增加清理空目录功能(beta)", + "v1.7": "修复因未监测重命名事件导致的清理硬链接失败的问题", + "v1.6": "提升插件性能" + } + }, + "LinkMonitor": { + "name": "实时硬链接", + "description": "监控目录文件变化,实时硬链接。", + "labels": "文件整理", + "version": "1.6", + "icon": "Linkace_C.png", + "author": "jxxghp", + "level": 1, + "history": { + "v1.6": "增强API安全性" + } + }, + "CategoryEditor": { + "name": "二级分类策略", + "description": "编辑下载目录和媒体库目录的二级分类规则。", + "labels": "文件整理", + "version": "1.2", + "icon": "Bookstack_A.png", + "author": "jxxghp", + "level": 1 + }, + "RemoteIdentifiers": { + "name": "共享识别词", + "description": "从Github、Etherpad等远程文件中获取共享识别词并应用。", + "labels": "识别", + "version": "2.2", + "icon": "words.png", + "author": "honue", + "level": 1 + }, + "NeoDBSync": { + "name": "NeoDB 想看", + "description": "同步 NeoDB 想看条目,自动添加订阅。", + "labels": "订阅", + "version": "1.1", + "icon": "NeoDB.jpeg", + "author": "hcplantern", + "level": 1, + "history": { + "v1.1": "直接添加订阅,不提前进行搜索下载" + } + }, + "PlayletCategory": { + "name": "短剧自动分类", + "description": "网络短剧自动整理到独立的分类目录。", + "labels": "文件整理", + "version": "2.0", + "icon": "Amule_A.png", + "author": "jxxghp", + "level": 1, + "history": { + "v2.0": "适配新的目录结构变化,短剧分类名称调整为配置目录路径,升级后需要重新调整设置后才能使用。" + } + }, + "DiagParamAdjust": { + "name": "诊断参数调整", + "description": "Emby专用插件|暂时性解决emby字幕偏移问题,需要emby安装Diagnostics插件。", + "labels": "Emby", + "version": "1.3", + "icon": "Gatus_A.png", + "author": "jeblove", + "level": 1 + }, + "QbCommand": { + "name": "QB远程操作", + "description": "通过定时任务或交互命令远程操作QB暂停/开始/限速等。", + "labels": "下载管理,Qbittorrent", + "version": "1.5", + "icon": "Qbittorrent_A.png", + "author": "DzAvril", + "level": 1, + "history": { + "v1.5": "可选特定路径下的做种不会被暂停", + "v1.4": "可选某些站点不再做种(暂停做种后不会被恢复)" + } + }, + "TrCommand": { + "name": "TR远程操作", + "description": "通过定时任务或交互命令远程操作TR暂停/开始/限速等。", + "labels": "下载管理,Transmission", + "version": "1.1", + "icon": "Transmission_A.png", + "author": "Hoey", + "level": 1 + }, + "IpDetect": { + "name": "本地IP检测", + "description": "如果QB、TR等服务在本地部署,当本地IP改变时自动修改其Server IP。", + "labels": "系统设置", + "version": "1.1", + "icon": "ipAddress.png", + "author": "DzAvril", + "level": 1 + }, + "TrackerEditor": { + "name": "Tracker替换", + "description": "批量替换种子tracker,支持周期性巡检(如为TR,仅支持4.0以上版本)。", + "labels": "做种", + "version": "1.5", + "icon": "trackereditor_A.png", + "author": "honue", + "level": 1 + }, + "ContractCheck": { + "name": "契约检查", + "description": "定时检查保种契约达成情况。", + "labels": "做种", + "version": "1.4", + "icon": "contract.png", + "author": "DzAvril", + "level": 1, + "history": { + "v1.4": "支持仪表板组件显示", + "v1.3": "修复观众做种数据异常问题", + "v1.2": "修复契约检查无数据返回的问题" + } + }, + "FeiShuMsg": { + "name": "飞书机器人消息通知", + "description": "支持使用飞书群聊机器人发送消息通知。", + "labels": "消息通知", + "version": "1.0", + "icon": "FeiShu_A.png", + "author": "InfinityPacer", + "level": 2 + }, + "IyuuAuth": { + "name": "IYUU站点绑定", + "description": "为IYUU账号绑定认证站点,以便用于用户认证和辅种。", + "labels": "IYUU", + "version": "1.1", + "icon": "Iyuu_A.png", + "author": "jxxghp", + "level": 1, + "history": { + "v1.1": "修复IYUU站点绑定失败问题" + } + }, + "NtfyMsg": { + "name": "ntfy消息推送", + "description": "支持使用ntfy发送消息通知。", + "labels": "消息通知", + "version": "1.0", + "icon": "Ntfy_A.png", + "author": "lethargicScribe", + "level": 1 + }, + "TmdbWallpaper": { + "name": "登录壁纸本地化", + "description": "将MoviePilot的登录壁纸下载到本地。", + "labels": "工具", + "version": "1.1", + "icon": "Macos_Sierra.png", + "author": "jxxghp", + "level": 1, + "history": { + "v1.1": "修复下载Bing每日壁纸时文件名错乱的问题" + } + }, + "MPServerStatus": { + "name": "MoviePilot服务器监控", + "description": "在仪表板中实时显示MoviePilot公共服务器状态。", + "labels": "仪表板", + "version": "1.0", + "icon": "Duplicati_A.png", + "author": "jxxghp", + "level": 1 + }, + "CleanInvalidSeed": { + "name": "清理QB无效做种", + "description": "清理已经被站点删除的种子及对应源文件,仅支持QB", + "labels": "Qbittorrent", + "version": "2.2", + "icon": "clean_a.png", + "author": "DzAvril", + "level": 1, + "history": { + "v2.2": "支持仅标记模式", + "v2.1": "1. 修复删除无效做种没有tg通知的问题。2. 检测未工作做种排除已暂停做种", + "v2.0": "修复检测不到无效做种的bug", + "v1.9": "增加自定义需删除做种的tracker的错误信息", + "v1.8": "增加远程命令切换全量通知;修复bug", + "v1.7": "修复因消息内容包含'_'导致telegram API调用失败的问题", + "v1.6": "修复当种子有多个标签时,通过标签过滤不删除种子会失效的问题", + "v1.5": "1. 增加通过分类、标签过滤不删除种子功能;2. 全量通知提供更多信息", + "v1.4": "修复插件功能失效的问题", + "v1.3": "1. 增加远程命令 2. 根据tracker error_message字段进行过滤,避免误删", + "v1.2": "修复配置页空白的问题", + "v1.1": "更新使用说明,以防使用不当误删文件", + "v1.0": "定时清理已经被站点删除的种子及对应源文件" + } + }, + "TrendingShow": { + "name": "流行趋势轮播", + "description": "在仪表板中显示流行趋势海报轮播图。", + "labels": "仪表板", + "version": "1.3", + "icon": "TrendingShow.jpg", + "author": "jxxghp", + "level": 1, + "history": { + "v1.3": "调整组件大小", + "v1.2": "不同屏幕大小,支持分开设置" + } + }, + "DailyWord": { + "name": "每日一言", + "description": "在仪表板中显示每日一言卡片。", + "labels": "仪表板", + "version": "1.1", + "icon": "Calibre_B.png", + "author": "jxxghp", + "level": 1 + }, + "ZvideoHelper": { + "name": "极影视助手", + "description": "极影视功能扩展", + "labels": "媒体库", + "version": "1.3", + "icon": "zvideo.png", + "author": "DzAvril", + "level": 1, + "history": { + "v1.3": "降低对豆瓣接口的请求频率", + "v1.2": "修复无法获取豆瓣评分的问题", + "v1.1": "支持将极影视评分修改为豆瓣评分", + "v1.0": "同步极影视在看/已看状态到豆瓣" + } + } } diff --git a/plugins/mediaservermsg/__init__.py b/plugins/mediaservermsg/__init__.py index 125eabd..315f1d1 100644 --- a/plugins/mediaservermsg/__init__.py +++ b/plugins/mediaservermsg/__init__.py @@ -20,7 +20,7 @@ class MediaServerMsg(_PluginBase): # 插件图标 plugin_icon = "mediaplay.png" # 插件版本 - plugin_version = "1.2" + plugin_version = "1.3" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -40,6 +40,7 @@ class MediaServerMsg(_PluginBase): # 私有属性 _enabled = False _types = [] + _webhook_msg_keys = {} # 拼装消息内容 _webhook_actions = { @@ -198,6 +199,13 @@ class MediaServerMsg(_PluginBase): logger.info(f"未开启 {event_info.event} 类型的消息通知") return + expiring_key = f"{event_info.item_id}-{event_info.client}-{event_info.user_name}" + # 过滤停止播放重复消息 + if str(event_info.event) == "playback.stop" and expiring_key in self._webhook_msg_keys.keys(): + # 刷新过期时间 + self.__add_element(expiring_key) + return + # 消息标题 if event_info.item_type in ["TV", "SHOW"]: message_title = f"{self._webhook_actions.get(event_info.event)}剧集 {event_info.item_name}" @@ -255,10 +263,31 @@ class MediaServerMsg(_PluginBase): else: play_link = None + if str(event_info.event) == "playback.stop": + # 停止播放消息,添加到过期字典 + self.__add_element(expiring_key) + if str(event_info.event) == "playback.start": + # 开始播放消息,删除过期字典 + self.__remove_element(expiring_key) + # 发送消息 self.post_message(mtype=NotificationType.MediaServer, title=message_title, text=message_content, image=image_url, link=play_link) + def __add_element(self, key, duration=600): + expiration_time = time.time() + duration + # 如果元素已经存在,更新其过期时间 + self._webhook_msg_keys[key] = expiration_time + + def __remove_element(self, key): + self._webhook_msg_keys = {k: v for k, v in self._webhook_msg_keys.items() if k != key} + + def __get_elements(self): + current_time = time.time() + # 过滤掉过期的元素 + self._webhook_msg_keys = {k: v for k, v in self._webhook_msg_keys.items() if v > current_time} + return list(self._webhook_msg_keys.keys()) + def stop_service(self): """ 退出插件 From 215049de11780152ddf8dd5985b5b9cf115ebd40 Mon Sep 17 00:00:00 2001 From: Pixel-LH <2569646547@qq.com> Date: Tue, 3 Sep 2024 12:58:17 +0800 Subject: [PATCH 042/218] =?UTF-8?q?fix:vcbanimemonitor=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=A4=84=E7=90=86=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 ++- plugins/vcbanimemonitor/__init__.py | 2 +- plugins/vcbanimemonitor/remeta.py | 2 -- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index b9bac71..e80e3f3 100644 --- a/package.json +++ b/package.json @@ -319,11 +319,12 @@ "name": "整理VCB动漫压制组作品", "description": "一款辅助整理&提高识别VCB-Stuido动漫压制组作品的插件", "labels": "文件整理,识别", - "version": "1.8.2.1", + "version": "1.8.2.2", "icon": "vcbmonitor.png", "author": "pixel@qingwa", "level": 2, "history": { + "v1.8.2.2": "修复处理异常", "v1.8.2.1": "修复日志输出&同步目录监控插件功能", "v1.8.2": "提高识别率", "v1.8.1": "重构插件,测试版", diff --git a/plugins/vcbanimemonitor/__init__.py b/plugins/vcbanimemonitor/__init__.py index 5558f60..f81f257 100644 --- a/plugins/vcbanimemonitor/__init__.py +++ b/plugins/vcbanimemonitor/__init__.py @@ -76,7 +76,7 @@ class VCBAnimeMonitor(_PluginBase): # 插件图标 plugin_icon = "vcbmonitor.png" # 插件版本 - plugin_version = "1.8.2.1" + plugin_version = "1.8.2.2" # 插件作者 plugin_author = "pixel@qingwa" # 作者主页 diff --git a/plugins/vcbanimemonitor/remeta.py b/plugins/vcbanimemonitor/remeta.py index add6e32..ea261eb 100644 --- a/plugins/vcbanimemonitor/remeta.py +++ b/plugins/vcbanimemonitor/remeta.py @@ -98,9 +98,7 @@ class ReMeta: self.is_ova = self.vcb_meta.is_ova meta = MetaInfoPath(file_path) meta.title = self.vcb_meta.title - meta.name = self.vcb_meta.title meta.en_name = self.vcb_meta.title - meta.cn_name = self.vcb_meta.title if self.vcb_meta.type == "Movie": meta.type = MediaType.MOVIE else: From 48d555ac9831a70ba1ea4f49729b32513733a649 Mon Sep 17 00:00:00 2001 From: Attente <19653207+wikrin@users.noreply.github.com> Date: Fri, 6 Sep 2024 04:40:25 +0800 Subject: [PATCH 043/218] init commit --- .gitignore | 163 +++++++++++++ LICENSE | 674 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 837 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ae6c655 --- /dev/null +++ b/.gitignore @@ -0,0 +1,163 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +.idea/ +.vscode/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. From d961afb0101de010b7ffe4d32d02154751ed6464 Mon Sep 17 00:00:00 2001 From: Attente <19653207+wikrin@users.noreply.github.com> Date: Fri, 6 Sep 2024 04:45:56 +0800 Subject: [PATCH 044/218] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=EF=BC=9ABangumiColl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- icons/bangumi_b.png | Bin 0 -> 4355 bytes package.json | 14 + plugins/bangumicoll/__init__.py | 505 ++++++++++++++++++++++++++++++++ 3 files changed, 519 insertions(+) create mode 100644 icons/bangumi_b.png create mode 100644 package.json create mode 100644 plugins/bangumicoll/__init__.py diff --git a/icons/bangumi_b.png b/icons/bangumi_b.png new file mode 100644 index 0000000000000000000000000000000000000000..29ea9b4e36fe30a046e0ea952c6d3ae2b69ab92c GIT binary patch literal 4355 zcmV+e5&Z6nP)Px_y-7qtRCr$PU2Sq3ISyqjRqM?u;@m?2);Wnso`WPlNNn#(;{4MsqB+Hkw<_u_ zN@}~^YLWm5QY8E7uRxG|;A;Z>06SCB@9*{F^~#uAWA@jbF+cyc{qw1!<}bFXFYCvz z#`J$20qCBtwwwD}!02jesBPyz>xUnmF*k2*?!MIoc1>7owe^4N?|<~BdmQQ8U2X3^ zYw1s|rz(QHtRMPwKtPhzgG8$TIOF%frsemh`|@$Sd6w$US*`(s9Hwo|BsoV7v99wO z|5)&9dsof4tEQnA*qP~iFx9R$-pK=L3w@%^)C20Sxdop%vGvdV<&v z_-2y5={B{jl$1T>>8*3QR>t&CSKB`~bWP<*Ry%`GW)hGj??8K5KiDPR>q)Iux>ilh zARtLz0{CikE$P;@=P}p6>Sqv;97l|Zh<69Jauo{*6&(1O%Q^+1(HOP#B~s{71yeHg1p@w>Cess z*&X-O>I&Q~AyGW8)#sMn*rtN?sjWd0;ph((Yi+mO*WIQGVml6!=#r$RgNPJlnkNWI zLKy~hnHKJN(0g0qad%GV#tvF1Y!~buuF2Snh(h!kuV9fLRqh9f3f4oTozy^RbI1T)Dy1YCDKI@52K zm`xu9nVDyo>TbccIv)%mh{F+GB1x8V$Ac1kAV?>m#^^OO_Uh!opn2BDyIzL_}tf>KR^J9f)yh6h1t zIGss(o`NfWbqfa(=|%@Z=vXyD66}`q3=V?u@T#69aIJU-2SIpv%}Uqu^6nCnO)Hr5 zoeB^{3DMGD-i2!=Gc*Vy!)>-y$t1x+M46#M5E*XHQYDfE2NC5)20`T5iAa+BD_YAlitI^aY-R+kD(^o)cD&J01$)f*=YY`czzsBshquV^|PG z2S=}#OOONy5p@g;g6QBF)ROb%UF?yFvx=aOlUn|pe~x_|DB z`Dv57!L`!2CT0+QOoJK&Bng)13AB-Uh1X|2X%IvYOR}a@EMgNSgk}k{&N)H2R#EUB z3WFf=7?L%d0_exo#27UQulJIzGcO2&L;^@H?peX2s*uX~i68QnKj~gYcl@OBY6tQwNsw5D?#D=Or+MXrMTw z!=Dx?QKdw+OMMK72+un)5JZ9+p^W3OAfaRy5v*~DIr`gCl|KIxi2 zvA-5 zF#-p|565oAS|$Zm2y($Sek~lJr!553_I{!Wa)F)Uw#^$B7(rBs%n6su?GZXr<&OW0XZ<<>P3K05*9(+*E#Ej3@u+20yZXLrCTBs#Q*fz zqCiGx>qdYTxRVLudJR<(t3xnl_3e)vK&LCEiZwu0%?Q|y=j*-cZsRv$>F3c%1W7!5 zM6EK^Y;m+*hJeDAWwNV9B1pQ~gU)0blql6Q0;do657`Y(+Snr!t^O8fU z-MVFQa@frZ5=8q}9EG**nh`xqi2!#KoAR*^5=8Uj52ZmV0fg?h5fIEG!8n9Mkh5H` zQkZeoQ;HnQQ4wHzJY^ANZ@SN}q{~zQAO{3|2yhpbuC{mHNh0eE5^4-g4+LTn;GP!@ zjmew@2~KCR;GjGZ;Km`v5Cj1_25$}oK#&|LqmF73070rX9U7hk0T3hy%BZ7S1VE5# zO^1f(KmY_ep=^P`o=T1(S63Y%h%sigTYAW*w>PW*hE8SHWoEQiiooIt;y#epKY=LH zVO4Iq(e1(EO-JvYau8(w{V{MkX4jei-ad<5uG!JD5CXnywZ1*I#S?^almK_#L!+?3 z|A{vQou@JuPY`QU<_)p~QTE2zI0czf3M7SX67Okj_l;B#WMp9C*JdbzfW9zt2(ps| zy#f$q#>f(&5vN>^EfRZCpRxu)Ja(N~@d%BA;1?;R0RPhwV9xkfF?+!U1R>bNf@Gh6 zV`Sk&oI=1=D#D8|Y%Se4Y0L2z zr8OW31tr8Oktp$t=X9emgYfOcE+LImuC|+dc4dy0Hw`^f9a?)r%v@B-7ez3GNJoUH zoXv#63Fx_xu#Br_oCHYF(|0PP2P-4%W^`RtFhCG0QUW=K|13^G@v=^l2wc$FQ<0cd zOoXI@>}6VcPmU!)kXQtj;*(G|jJawGnmt~d7n2H>kfbf!%rSHb1W7;;#>{c5U6!5x zWIW+}7703TjFAw1G8ifS$+ph~f+&s?{>lq~XLs6MPv*Z}pYYkaxu*d%Qo%A$FAV!k z_gaetQ+z>?NP_TZ3tOSyWh~c8An(Pd`dkPezP!^Bn=M>Q~*?H&;)}*=pQJ z-txK5ER7k2KoXf7B|jiXXwsR8%a9sz!6=BnS()Aj(eHk#W)Bw*Jfax#3$!j*Z)N&l zRGWkrDY7N6&hgbcx-R=$#)FPoW($|GQpX1KvsC!mG3wyr%DLh%haS-$3TBWAnzGp< zel?0aICx*X_(AMiEsNP0)adhDoIrk=mPHg05| zchC_8Ii#t4usfv}BgbI)6u!M9#tI%mna4!F66PA*1>9-z?zAtSAj~_^DL!KFbZQ^l zkSzP=+!QR~9e=wF7zb}?oY(Fi}OXyao3}aWp+Hl z<{VdybeFMXlT<#%GViov+R9O$*`tJg5SvirYJ2y2&On?GiY*_y0#MzRTiReDjcpa7 z9A9ns=HXixLWWl9EdMT!R<#p7?vKIZ3F6u#NMs4GTFnFtq4xVe3_06DA#12Fk^keU z2?aoFD2iMvnN*npeeDe``=;uv%azGl-%wWw_r=d3moQ2xbU=tv3#K#&xuLT57w zfFRA75*SKS$@Tq9}sE%~Bl;P&Pq8l6k&3f?yWGxK4op zclBy$LvaK-HZ08xtSNwp&N>i~zP?*1K|qo^hFu-4l#;{tPJ6#w&b)Y*eY5PZF42GY0#1D8sM{ah*TY9e_Cv6aEq zVjC*B5#`f}j}ja2Pyx0*SOgi7#2!DvFMt4NDxa#t$4Gy8NoI34K`@ih4Mz z(J>}U?)IkJeB5rHiSn;vB7%S<6qxzJIb13Vu51~$TQ#mhOprH{s2mz&_qy}~+@)Q( zRO%L0IRqO;kfBS8rz_P_x{N~J>;acb+e&)ZC{zeCCdvo1zv&OBF1u^JQLy2WO73@zO5UrQ#mFW;7L| zN(g`;L?Hba#*VQA$E8C0RU+^g76U3?KKp+X?4F;Yyh=L#j xD_M7IZ@Syljmcea%&s%tGadmFfLVBv{{vxQ$bCF3@jd_m002ovPDHLkV1l6k&rARS literal 0 HcmV?d00001 diff --git a/package.json b/package.json new file mode 100644 index 0000000..384ccf0 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "BangumiColl": { + "name": "Bangumi收藏订阅", + "description": "Bangumi用户收藏添加到订阅", + "labels": "订阅", + "version": "1.0", + "icon": "https://raw.githubusercontent.com/wikrin/MoviePilot-Plugins/main/icons/bangumi_b.png", + "author": "Attente", + "level": 2, + "history": { + "v1.0": "将bangumi用户收藏添加到 MP 订阅,部分功能未实现" + } + }, +} diff --git a/plugins/bangumicoll/__init__.py b/plugins/bangumicoll/__init__.py new file mode 100644 index 0000000..7cb76b7 --- /dev/null +++ b/plugins/bangumicoll/__init__.py @@ -0,0 +1,505 @@ +import datetime + +from typing import Optional, Any, List, Dict + +import pytz +from apscheduler.schedulers.background import BackgroundScheduler + +from apscheduler.triggers.cron import CronTrigger + +from app.chain.subscribe import SubscribeChain +from app.core.config import settings + +from app.core.context import MediaInfo +from app.core.metainfo import MetaInfo +from app.log import logger +from app.plugins import _PluginBase +from app.db.site_oper import SiteOper +from app.utils.http import RequestUtils + + +class BangumiColl(_PluginBase): + # 插件名称 + plugin_name = "bangumi收藏订阅" + # 插件描述 + plugin_desc = "将bangumi用户收藏添加到订阅" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/wikrin/MoviePilot-Plugins/main/icons/bangumi_b.png" + # 插件版本 + plugin_version = "1.0" + # 插件作者 + plugin_author = "Attente" + # 作者主页 + author_url = "https://github.com/wikrin" + # 插件配置项ID前缀 + plugin_config_prefix = "bangumicoll_" + # 加载顺序 + plugin_order = 23 + # 可使用的用户级别 + auth_level = 2 + + # 私有变量 + _scheduler: Optional[BackgroundScheduler] = None + siteoper: SiteOper = None + + # 配置属性 + _enabled: bool = False + _cron: str = "" + _notify: bool = False + _onlyonce: bool = False + _include: str = "" + _exclude: str = "" + _uid: str = "" + _collection_type = [] + _collection: Dict = {} + _save_path: str = "" + _sites: list = [] + + + def init_plugin(self, config: dict = None): + self.subscribechain = SubscribeChain() + self.siteoper = SiteOper() + + # 停止现有任务 + self.stop_service() + + # 配置 + if config: + self._enabled = config.get("enabled") + self._cron = config.get("cron") + self._notify = config.get("notify") + self._onlyonce = config.get("onlyonce") + self._include = config.get("include") + self._exclude = config.get("exclude") + self._uid = config.get("uid") + self._collection_type = config.get("collection_type") or [3] + self._collection = config.get("collection") + self._save_path = config.get("save_path") + self._sites = config.get("sites") + + if self._onlyonce: + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + logger.info(f"bangumi收藏订阅启动,立即运行一次") + self._scheduler.add_job( + func=self.bangumi_coll, + trigger='date', + run_date=datetime.datetime.now(tz=pytz.timezone(settings.TZ)) + + datetime.timedelta(seconds=3), + ) + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + if self._onlyonce: + # 关闭一次性开关 + self._onlyonce = False + # 保存设置 + self.__update_config() + + def __update_config(self): + """ + 更新设置 + """ + self.update_config( + { + "enabled": self._enabled, + "notify": self._notify, + "onlyonce": self._onlyonce, + "cron": self._cron, + "uid": self._uid, + "collection_type": self._collection_type, + "collection": self._collection, + "include": self._include, + "exclude": self._exclude, + "save_path": self._save_path, + "sites": self._sites, + } + ) + + def get_api(self): + pass + + def get_command(self): + pass + + def get_form(self): + # 列出所有站点 + sites_options = [ + {"title": site.name, "value": site.id} + for site in self.siteoper.list_order_by_pri() + ] + + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 4}, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + }, + } + ], + }, + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 4}, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '发送通知', + }, + } + ], + }, + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 4}, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + }, + } + ], + }, + ], + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 6}, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '执行周期', + 'placeholder': '5位cron表达式,留空自动', + }, + } + ], + }, + ], + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 6}, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'uid', + 'label': 'UID/用户名', + 'placeholder': '设置了用户名填写用户名,否则填写UID', + }, + }, + ], + }, + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 6}, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'collection_type', + 'label': '收藏类型', + 'chips': True, + 'multiple': True, + 'items': [ + {'title': '在看', 'value': 3}, + {'title': '想看', 'value': 1}, + ], + }, + } + ], + }, + ], + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 6}, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'include', + 'label': '包含', + 'placeholder': '暂未实现', + }, + } + ], + }, + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 6}, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'exclude', + 'label': '排除', + 'placeholder': '暂未实现', + }, + } + ], + }, + ], + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 6}, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'save_path', + 'label': '保存目录', + 'placeholder': '留空自动', + }, + } + ], + }, + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 6}, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'sites', + 'label': '选择站点', + 'chips': True, + 'multiple': True, + 'items': sites_options, + }, + } + ], + }, + ], + }, + ], + } + ], { + "enabled": False, + "notify": False, + "onlyonce": False, + "cron": "", + "uid": "", + "collection_type": [3], + "include": "", + "exclude": "", + "save_path": "", + "sites": [], + } + + def get_page(self): + 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 and self._cron: + return [ + { + "id": "BangumiColl", + "name": "Bangumi收藏订阅", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self.bangumi_coll, + "kwargs": {}, + } + ] + elif self._enabled: + return [ + { + "id": "BangumiColl", + "name": "Bangumi收藏订阅", + "trigger": "interval", + "func": self.bangumi_coll, + "kwargs": {"hours": 6}, + } + ] + return [] + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) + + def get_state(self): + return self._enabled + + def bangumi_coll(self): + """ + 订阅bangumi用户收藏 + """ + if not self._uid: + logger.error("请设置UID") + return + + addr = f"https://api.bgm.tv/v0/users/{self._uid}/collections?subject_type=2" + headers = { + "User-Agent": "jxxghp/MoviePilot-Plugins (https://github.com/jxxghp/MoviePilot-Plugins)" + } + + try: + logger.info(f"查询bangumi条目信息:{addr} ...") + res = RequestUtils(headers=headers).get_res(url=addr) + res = res.json().get("data") + if not res: + logger.error(f"bangumi用户:{self._uid} ,未查询到数据") + except Exception as e: + logger.error(f"获取bangumi收藏数据失败:{addr} 失败:{str(e)}") + + # 解析出必要数据 + items: Dict[int, Dict[str, Any]] = {} + logger.info(f"解析bangumi条目信息...") + for item in res: + if item.get("type") not in self._collection_type: + continue + # 条目id + subject_id = item.get("subject_id") + # 主标题 + name = item['subject'].get('name') + # 中文标题 + name_cn = item['subject'].get('name_cn') + # 放送时间 + date = item['subject'].get('date') + ## 这里在后面添加排除规则 + items.update({subject_id: {"name": name, "name_cn": name_cn, "date": date}}) + ## 获取此插件添加的订阅 + db_sub = {i.bangumiid: i.id for i in self.subscribechain.subscribeoper.list() if i.bangumiid and i.username == "Bangumi订阅"} + # 新增条目 + new_sub = items.keys() - db_sub.keys() + # 移除条目, 这里暂时不做 + # del_sub = dbrid.keys() - items.keys() + logger.info(f"解析bangumi条目信息完成,共{len(items)}条,新增{len(new_sub)}条") + + # # 执行移除操作 + # if del_sub: + # del_items = {dbrid[i]: i for i in del_sub} + # logger.info(f"开始移除订阅...") + # self.delete_subscribe(del_items) + + # 执行添加操作 + if new_sub: + new_sub = {i: items[i] for i in new_sub} + logger.info(f"开始添加订阅...") + self.add_subscribe(new_sub) + + # 结束 + logger.info(f"bangumi收藏订阅执行完成") + + + + # 添加订阅 + def add_subscribe(self, items: Dict[int, Dict[str, Any]]): + for subject_id, item in items.items(): + meta = MetaInfo(item.get("name_cn")) + if not meta.name: + logger.warn(f"{item.get('name_cn')} 未识别到有效数据") + continue + # 由于bangumi的api不包含季度信息,不传入bangumi条目id,默认使用tmdb + mediainfo: MediaInfo = self.chain.recognize_media(meta=meta) + # 对比bangumi和tmdb的信息确定季度 + for info in mediainfo.season_info: + # 对比日期, 误差默认7天 + if not self.are_dates(item.get("date"), info.get("air_date")): + continue + else: + # 更新季度信息 + mediainfo.number_of_seasons = info.get("season_number") + # 更新集数信息 + mediainfo.number_of_episodes = info.get("episode_count") + + # 检查是否已经订阅 + subflag = self.subscribechain.exists(mediainfo=mediainfo, meta=meta) + if subflag: + logger.info(f'{mediainfo.title_year} {meta.season} 正在订阅中') + continue + + # 额外参数 + kwargs = { + "save_path": self._save_path, + "sites": self._sites, + } + # 添加到订阅 + self.subscribechain.add( + title=mediainfo.title, + year=mediainfo.year, + mtype=mediainfo.type, + tmdbid=mediainfo.tmdb_id, + bangumiid=subject_id, + season=mediainfo.number_of_seasons, + exist_ok=True, + username="Bangumi订阅", + **kwargs, + ) + + def delete_subscribe(self, del_items: dict): + pass + + @staticmethod + def are_dates(date_str1, date_str2, threshold_days: int = 7) -> bool: + """ + 对比两个日期字符串是否接近 + :param date_str1: 第一个日期字符串,格式为'YYYY-MM-DD' + :param date_str2: 第二个日期字符串,格式为'YYYY-MM-DD' + :param threshold_days: 阈值天数,默认为1天 + :return: 如果两个日期之间的差异小于等于阈值天数,则返回True,否则返回False + """ + # 将日期字符串转换为datetime对象 + date1 = datetime.datetime.strptime(date_str1, '%Y-%m-%d') + date2 = datetime.datetime.strptime(date_str2, '%Y-%m-%d') + + # 计算两个日期之间的差异 + delta = abs(date1 - date2) + + # 将阈值转换为timedelta对象 + threshold = datetime.timedelta(days=threshold_days) + + # 比较差异和阈值 + return delta <= threshold From 16984969e7544696b0a84122163dbd18ec9577b2 Mon Sep 17 00:00:00 2001 From: Attente <19653207+wikrin@users.noreply.github.com> Date: Fri, 6 Sep 2024 05:01:51 +0800 Subject: [PATCH 045/218] fix --- package.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 384ccf0..d81f56b 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,14 @@ { - "BangumiColl": { - "name": "Bangumi收藏订阅", - "description": "Bangumi用户收藏添加到订阅", - "labels": "订阅", - "version": "1.0", - "icon": "https://raw.githubusercontent.com/wikrin/MoviePilot-Plugins/main/icons/bangumi_b.png", - "author": "Attente", - "level": 2, - "history": { - "v1.0": "将bangumi用户收藏添加到 MP 订阅,部分功能未实现" - } - }, + "BangumiColl": { + "name": "Bangumi收藏订阅", + "description": "Bangumi用户收藏添加到订阅", + "labels": "订阅", + "version": "1.0", + "icon": "https://raw.githubusercontent.com/wikrin/MoviePilot-Plugins/main/icons/bangumi_b.png", + "author": "Attente", + "level": 2, + "history": { + "v1.0": "将bangumi用户收藏添加到 MP 订阅,部分功能未实现" + } + } } From 842b5650debcbc5204632484220f828b093580e8 Mon Sep 17 00:00:00 2001 From: Attente <19653207+wikrin@users.noreply.github.com> Date: Fri, 6 Sep 2024 08:55:22 +0800 Subject: [PATCH 046/218] =?UTF-8?q?=E6=9B=B4=E6=94=B9=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- icons/bangumi_b.png | Bin 4355 -> 5166 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/icons/bangumi_b.png b/icons/bangumi_b.png index 29ea9b4e36fe30a046e0ea952c6d3ae2b69ab92c..a6fffff4b55c59f02ca99699ee5fbfde0ee023b1 100644 GIT binary patch literal 5166 zcmcIoc{r5syB|!`n;98G*33+$Nn)~p31jR+_E3@~`h}ETFyKddgupU)G^w*|G{0(fm|ig92nYj?)@N1f16}zs7`XKHbo=T(S376(1tYfF zK%cc{pFZUmT}UD?tf{@+tZm$$MV~W9*UnURyX*Fy3x3xs@BCCjV35ohmaXl`D{?T| zBkPB<3yEowcv7y4{Ji1m;14t%VZ;;dN?nMIp(6A3V7m0gEL5DK@SY4>+7 zw=DS`!kRWyeC$s%F#$~3W!Jm^j5ES6Tw&RoI+_TAU2td+*>JHxoxlX#0PhW)(hL`4 zlKaa7;t?Wv=GT?YLbCcqNI@?_J@j0bVkt4X+z zW$p$NxXR8-pE9gOQ}}i)lExf`8K92wx(h~`0%6Grc(~l*8ckk&et5QJ20aHFsjG;! z60)621T@2|KvTc!d1iVJ`nz>o*A!w(P)OZjuc9;IF^Ic$LtaI2Y(fAKhCtFUSx)K7 z0%0-W8@);0C5*s5bq-=YT8^ccuBinGU;H+Ml7kXDMpvs2e2*^@&}cTnxoCVR_+4&z z>V^_(_L4ewGC<&7H!m^%g-+i3Li2HDuA8Dffm@d1k%aB8IBH*U9gD@on``H71vq7lgywY=+RY=#b@Sx;k5A7EoBt@Y)^NisDd;1 zIRY{K7WE<$;r_P9)(TtU+J%6I5H%2|*aXvwc-&Bhw$ZnLhWS|}v%YdeLt3ZL|b8_-f{cc0mZskE4?5;V8l*+ zKmu(0>!qRZ^WNXgCV#FBY(Z_IjnpG?NTw6I2s+xAixz}oFi3%dTkL=N%WH?9-*e&u z;V|Dn&*p?KVmccGLZ|WN?n{OlXE9wfIiiR0NQsf1X=iol9pSI`&HHN;^nyBpFHO#y z44s0Ot-dx+3u_h1+vK zo5_alCFwGDu%!)78%(U;(oJb*m)i8dQYpiC-D(5B1WNms)8!4k>vVjZzzdy=jT5Pt zUGCoxDOR@&FmCKf_sHQofVN zffr6Ft2g>J2MXziBXQ_VkR?0W||hjzwx?_%vCqH6{0723On5Mrm|XR@;8xQr)b~ z7I&gU_oT$krMa*p!D~d1r{-P*w=}iJv)V7?^sF?OHzv+lL z`~}CoEMs24qKoU)Y|9ZIg5>z$nU2)uut+gsV2>Q;Dp-7ZxL*IbK~qem+`}gQ>X!<7 zB2vf3{kBuyoLFz$m~7(ePD1iZST=baClLqp z44p9cC96mw4iW97A-R5=#L!HUmD^rSdh>c|O%rE7H2>>n+(rj~3yzi;pHx>(!=&c9$y#*(T+w}=M)`^B-@02t zw@jN}|Jd-f$&Ddq)Nz%80p`*$;&Tg!P(fJPy0!e)1VvPPxVNEXz;4m;EX=|#;OcH>dC+r}QHy3>tDs>K^nP9S05%=06#Xue?yy%Rb~< zc;$8l{je*D0St-QtW&=vN*q;CilUCrjUW$ggc_tDtp*h|{&{m_0#msC(2Z7#8X|Q{ zW%sM)z}BbV#HAzj!}an0I26QZ~4)w>WL4SHyLjSFu{#?h|L-*^=U){QP65Yb`twiTqNbB;pf2))9v(J|p_dM#iyR znXNiht3~GED86h3fRa@I;$|z>&y1R(E7erlwnox|@g%Hmavs*$^V^;u-xhq|8gZ|O zotfVb>#$B0>4voPK3w<9hF??J_O-U$*V)zRbRdTYf}oW&j&0_WMkb!=6zTw=+r)Oo zqu<&Pe){ngPu!p(2Ys;>et_>cfd#R@U8J0izitoQ_kp~kC+3nfh)U_k2SRM7>dmc5Owb4O`o>+z96XGoO?a}@;!?(4o z!EXJ7=NV=&H&V{_VX+e^3+Y&l76yaZpVqvsG34g1RGP0xL%+FP_0#r8TWH0hW+AgL zbDer%Cd>-Xjvkb9z05DhSdcps#5|88A%QoV-KSre^;;DRTtPF4M3IA@gFjrLOL--Z z_#@5bWVS6)QjA6=ZghMcW~?W2(gw@^-}|SX(}b4Hd|JLYa>!tA77CZQNf~3-Fq1W0?CF;Zd`!i+zmcItYZ=C3qt3O(t0@)Q*dKGo%L`g#E%&7t}yY0P4*S#DR zM~<#)@Qqw%!3mPxtRJ$IZ2fCy!17Mt#6_gu3#=0c#$}TSsKN`Z7-2AljoYYL?s8ad zKTL&z>DD38MjP~Wmal<&sX@YMW6H-^n>~r|!U{q$Ba-LV9_qboh#j(rlJ9IC#@KNI zF3#iezfXU^jRgyrX(wJ|k*EGCdSrDlC4!%_aqoL|VW+XNUd+sciC{Mtxo+Qc{T*P4 zoiJ8E%n0Plz^Tq}cgN;OBh|<7whe=in(|!|GxDpNvVLv9YtXa{+9^-rvG=iS7*sls zPOc%MFpsDcL(CmICbw(7`oI53`V`rxN0~3M``K*l!4xr2!zf>Hr`o>adb~MP`(^>>WV|O(s^@ixdQ;U1SZ}NO6Na-JzAl@@_AcKfy(YEku*<44U}H) zPeqz7_5KYwEy^_D-Xu&8Si!dipWJTa_hDSs^Urx)CqQUAEeHK z+M}zV8_=eH_WGnaJT6Sy>eA&hksJ2srmT8W<`3hTQyF$pNW`0pb3g96{`yMnZIgTf zyzD7Zr~#mK;KU(P*4s0>N>UR_g}bs6A3(fInW*=U--Q=zhJj7LrQrk`Z0P1A7{sW* z*``IA*mc`*;M%&GF~`&Rr%_>4us#!z(cp#je~~(zipjw!o}^)N~74%li6X~-*Mz_X_v6(+#m z^=c(<~RQ`7TOECP_zkKH*-dUGcAakl9u9Y!h?|d{j*CLBx1zz)t3erbT^WC zRY_C<#lBkpQ-Zd6J}cXT6b!mPB6Eprlj7X>G*VWHgr;o;EzXPH1NF|5+1xRWK{<8&ZYLg`2>uUP{{i%-8&?1T literal 4355 zcmV+e5&Z6nP)Px_y-7qtRCr$PU2Sq3ISyqjRqM?u;@m?2);Wnso`WPlNNn#(;{4MsqB+Hkw<_u_ zN@}~^YLWm5QY8E7uRxG|;A;Z>06SCB@9*{F^~#uAWA@jbF+cyc{qw1!<}bFXFYCvz z#`J$20qCBtwwwD}!02jesBPyz>xUnmF*k2*?!MIoc1>7owe^4N?|<~BdmQQ8U2X3^ zYw1s|rz(QHtRMPwKtPhzgG8$TIOF%frsemh`|@$Sd6w$US*`(s9Hwo|BsoV7v99wO z|5)&9dsof4tEQnA*qP~iFx9R$-pK=L3w@%^)C20Sxdop%vGvdV<&v z_-2y5={B{jl$1T>>8*3QR>t&CSKB`~bWP<*Ry%`GW)hGj??8K5KiDPR>q)Iux>ilh zARtLz0{CikE$P;@=P}p6>Sqv;97l|Zh<69Jauo{*6&(1O%Q^+1(HOP#B~s{71yeHg1p@w>Cess z*&X-O>I&Q~AyGW8)#sMn*rtN?sjWd0;ph((Yi+mO*WIQGVml6!=#r$RgNPJlnkNWI zLKy~hnHKJN(0g0qad%GV#tvF1Y!~buuF2Snh(h!kuV9fLRqh9f3f4oTozy^RbI1T)Dy1YCDKI@52K zm`xu9nVDyo>TbccIv)%mh{F+GB1x8V$Ac1kAV?>m#^^OO_Uh!opn2BDyIzL_}tf>KR^J9f)yh6h1t zIGss(o`NfWbqfa(=|%@Z=vXyD66}`q3=V?u@T#69aIJU-2SIpv%}Uqu^6nCnO)Hr5 zoeB^{3DMGD-i2!=Gc*Vy!)>-y$t1x+M46#M5E*XHQYDfE2NC5)20`T5iAa+BD_YAlitI^aY-R+kD(^o)cD&J01$)f*=YY`czzsBshquV^|PG z2S=}#OOONy5p@g;g6QBF)ROb%UF?yFvx=aOlUn|pe~x_|DB z`Dv57!L`!2CT0+QOoJK&Bng)13AB-Uh1X|2X%IvYOR}a@EMgNSgk}k{&N)H2R#EUB z3WFf=7?L%d0_exo#27UQulJIzGcO2&L;^@H?peX2s*uX~i68QnKj~gYcl@OBY6tQwNsw5D?#D=Or+MXrMTw z!=Dx?QKdw+OMMK72+un)5JZ9+p^W3OAfaRy5v*~DIr`gCl|KIxi2 zvA-5 zF#-p|565oAS|$Zm2y($Sek~lJr!553_I{!Wa)F)Uw#^$B7(rBs%n6su?GZXr<&OW0XZ<<>P3K05*9(+*E#Ej3@u+20yZXLrCTBs#Q*fz zqCiGx>qdYTxRVLudJR<(t3xnl_3e)vK&LCEiZwu0%?Q|y=j*-cZsRv$>F3c%1W7!5 zM6EK^Y;m+*hJeDAWwNV9B1pQ~gU)0blql6Q0;do657`Y(+Snr!t^O8fU z-MVFQa@frZ5=8q}9EG**nh`xqi2!#KoAR*^5=8Uj52ZmV0fg?h5fIEG!8n9Mkh5H` zQkZeoQ;HnQQ4wHzJY^ANZ@SN}q{~zQAO{3|2yhpbuC{mHNh0eE5^4-g4+LTn;GP!@ zjmew@2~KCR;GjGZ;Km`v5Cj1_25$}oK#&|LqmF73070rX9U7hk0T3hy%BZ7S1VE5# zO^1f(KmY_ep=^P`o=T1(S63Y%h%sigTYAW*w>PW*hE8SHWoEQiiooIt;y#epKY=LH zVO4Iq(e1(EO-JvYau8(w{V{MkX4jei-ad<5uG!JD5CXnywZ1*I#S?^almK_#L!+?3 z|A{vQou@JuPY`QU<_)p~QTE2zI0czf3M7SX67Okj_l;B#WMp9C*JdbzfW9zt2(ps| zy#f$q#>f(&5vN>^EfRZCpRxu)Ja(N~@d%BA;1?;R0RPhwV9xkfF?+!U1R>bNf@Gh6 zV`Sk&oI=1=D#D8|Y%Se4Y0L2z zr8OW31tr8Oktp$t=X9emgYfOcE+LImuC|+dc4dy0Hw`^f9a?)r%v@B-7ez3GNJoUH zoXv#63Fx_xu#Br_oCHYF(|0PP2P-4%W^`RtFhCG0QUW=K|13^G@v=^l2wc$FQ<0cd zOoXI@>}6VcPmU!)kXQtj;*(G|jJawGnmt~d7n2H>kfbf!%rSHb1W7;;#>{c5U6!5x zWIW+}7703TjFAw1G8ifS$+ph~f+&s?{>lq~XLs6MPv*Z}pYYkaxu*d%Qo%A$FAV!k z_gaetQ+z>?NP_TZ3tOSyWh~c8An(Pd`dkPezP!^Bn=M>Q~*?H&;)}*=pQJ z-txK5ER7k2KoXf7B|jiXXwsR8%a9sz!6=BnS()Aj(eHk#W)Bw*Jfax#3$!j*Z)N&l zRGWkrDY7N6&hgbcx-R=$#)FPoW($|GQpX1KvsC!mG3wyr%DLh%haS-$3TBWAnzGp< zel?0aICx*X_(AMiEsNP0)adhDoIrk=mPHg05| zchC_8Ii#t4usfv}BgbI)6u!M9#tI%mna4!F66PA*1>9-z?zAtSAj~_^DL!KFbZQ^l zkSzP=+!QR~9e=wF7zb}?oY(Fi}OXyao3}aWp+Hl z<{VdybeFMXlT<#%GViov+R9O$*`tJg5SvirYJ2y2&On?GiY*_y0#MzRTiReDjcpa7 z9A9ns=HXixLWWl9EdMT!R<#p7?vKIZ3F6u#NMs4GTFnFtq4xVe3_06DA#12Fk^keU z2?aoFD2iMvnN*npeeDe``=;uv%azGl-%wWw_r=d3moQ2xbU=tv3#K#&xuLT57w zfFRA75*SKS$@Tq9}sE%~Bl;P&Pq8l6k&3f?yWGxK4op zclBy$LvaK-HZ08xtSNwp&N>i~zP?*1K|qo^hFu-4l#;{tPJ6#w&b)Y*eY5PZF42GY0#1D8sM{ah*TY9e_Cv6aEq zVjC*B5#`f}j}ja2Pyx0*SOgi7#2!DvFMt4NDxa#t$4Gy8NoI34K`@ih4Mz z(J>}U?)IkJeB5rHiSn;vB7%S<6qxzJIb13Vu51~$TQ#mhOprH{s2mz&_qy}~+@)Q( zRO%L0IRqO;kfBS8rz_P_x{N~J>;acb+e&)ZC{zeCCdvo1zv&OBF1u^JQLy2WO73@zO5UrQ#mFW;7L| zN(g`;L?Hba#*VQA$E8C0RU+^g76U3?KKp+X?4F;Yyh=L#j xD_M7IZ@Syljmcea%&s%tGadmFfLVBv{{vxQ$bCF3@jd_m002ovPDHLkV1l6k&rARS From 8eed22c8a744ddea67afbdcb946834fe4e58f56b Mon Sep 17 00:00:00 2001 From: Attente <19653207+wikrin@users.noreply.github.com> Date: Fri, 6 Sep 2024 09:19:52 +0800 Subject: [PATCH 047/218] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=AD=97=E7=AC=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/bangumicoll/__init__.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/plugins/bangumicoll/__init__.py b/plugins/bangumicoll/__init__.py index 7cb76b7..b19b256 100644 --- a/plugins/bangumicoll/__init__.py +++ b/plugins/bangumicoll/__init__.py @@ -20,9 +20,9 @@ from app.utils.http import RequestUtils class BangumiColl(_PluginBase): # 插件名称 - plugin_name = "bangumi收藏订阅" + plugin_name = "Bangumi收藏订阅" # 插件描述 - plugin_desc = "将bangumi用户收藏添加到订阅" + plugin_desc = "将Bangumi用户收藏添加到订阅" # 插件图标 plugin_icon = "https://raw.githubusercontent.com/wikrin/MoviePilot-Plugins/main/icons/bangumi_b.png" # 插件版本 @@ -79,7 +79,7 @@ class BangumiColl(_PluginBase): if self._onlyonce: self._scheduler = BackgroundScheduler(timezone=settings.TZ) - logger.info(f"bangumi收藏订阅启动,立即运行一次") + logger.info(f"Bangumi收藏订阅启动,立即运行一次") self._scheduler.add_job( func=self.bangumi_coll, trigger='date', @@ -374,7 +374,7 @@ class BangumiColl(_PluginBase): def bangumi_coll(self): """ - 订阅bangumi用户收藏 + 订阅Bangumi用户收藏 """ if not self._uid: logger.error("请设置UID") @@ -390,13 +390,13 @@ class BangumiColl(_PluginBase): res = RequestUtils(headers=headers).get_res(url=addr) res = res.json().get("data") if not res: - logger.error(f"bangumi用户:{self._uid} ,未查询到数据") + logger.error(f"Bangumi用户:{self._uid} ,未查询到数据") except Exception as e: - logger.error(f"获取bangumi收藏数据失败:{addr} 失败:{str(e)}") + logger.error(f"获取Bangumi收藏数据失败:{addr} 失败:{str(e)}") # 解析出必要数据 items: Dict[int, Dict[str, Any]] = {} - logger.info(f"解析bangumi条目信息...") + logger.info(f"解析Bangumi条目信息...") for item in res: if item.get("type") not in self._collection_type: continue @@ -416,7 +416,7 @@ class BangumiColl(_PluginBase): new_sub = items.keys() - db_sub.keys() # 移除条目, 这里暂时不做 # del_sub = dbrid.keys() - items.keys() - logger.info(f"解析bangumi条目信息完成,共{len(items)}条,新增{len(new_sub)}条") + logger.info(f"解析Bangumi条目信息完成,共{len(items)}条,新增{len(new_sub)}条") # # 执行移除操作 # if del_sub: @@ -431,7 +431,7 @@ class BangumiColl(_PluginBase): self.add_subscribe(new_sub) # 结束 - logger.info(f"bangumi收藏订阅执行完成") + logger.info(f"Bangumi收藏订阅执行完成") @@ -442,9 +442,9 @@ class BangumiColl(_PluginBase): if not meta.name: logger.warn(f"{item.get('name_cn')} 未识别到有效数据") continue - # 由于bangumi的api不包含季度信息,不传入bangumi条目id,默认使用tmdb + # 由于Bangumi的api不包含季度信息,不传入Bangumi条目id,默认使用tmdb mediainfo: MediaInfo = self.chain.recognize_media(meta=meta) - # 对比bangumi和tmdb的信息确定季度 + # 对比Bangumi和tmdb的信息确定季度 for info in mediainfo.season_info: # 对比日期, 误差默认7天 if not self.are_dates(item.get("date"), info.get("air_date")): From fd44eb70b037aad4fa1f0e1c1a4bb8f1f25ba5f6 Mon Sep 17 00:00:00 2001 From: Attente <19653207+wikrin@users.noreply.github.com> Date: Mon, 9 Sep 2024 05:34:19 +0800 Subject: [PATCH 048/218] =?UTF-8?q?=E4=BF=AE=E6=94=B9:=20UA,=20=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E5=AD=97=E7=AC=A6=20=E6=96=B0=E5=A2=9E:=20=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=8F=96=E6=B6=88=E8=AE=A2=E9=98=85=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 +- plugins/bangumicoll/__init__.py | 97 +++++++++++++++++++++++---------- 2 files changed, 71 insertions(+), 29 deletions(-) diff --git a/package.json b/package.json index d81f56b..9a6f9e7 100644 --- a/package.json +++ b/package.json @@ -3,11 +3,12 @@ "name": "Bangumi收藏订阅", "description": "Bangumi用户收藏添加到订阅", "labels": "订阅", - "version": "1.0", + "version": "1.1", "icon": "https://raw.githubusercontent.com/wikrin/MoviePilot-Plugins/main/icons/bangumi_b.png", "author": "Attente", "level": 2, "history": { + "v1.1": "新增根据收藏状态移除由此插件添加的订阅", "v1.0": "将bangumi用户收藏添加到 MP 订阅,部分功能未实现" } } diff --git a/plugins/bangumicoll/__init__.py b/plugins/bangumicoll/__init__.py index b19b256..52a0dbc 100644 --- a/plugins/bangumicoll/__init__.py +++ b/plugins/bangumicoll/__init__.py @@ -1,12 +1,9 @@ import datetime - from typing import Optional, Any, List, Dict - import pytz + from apscheduler.schedulers.background import BackgroundScheduler - from apscheduler.triggers.cron import CronTrigger - from app.chain.subscribe import SubscribeChain from app.core.config import settings @@ -16,6 +13,9 @@ from app.log import logger from app.plugins import _PluginBase from app.db.site_oper import SiteOper from app.utils.http import RequestUtils +from app.db.subscribe_oper import SubscribeOper +from app.helper.subscribe import SubscribeHelper +from app.schemas.types import NotificationType class BangumiColl(_PluginBase): @@ -26,7 +26,7 @@ class BangumiColl(_PluginBase): # 插件图标 plugin_icon = "https://raw.githubusercontent.com/wikrin/MoviePilot-Plugins/main/icons/bangumi_b.png" # 插件版本 - plugin_version = "1.0" + plugin_version = "1.1" # 插件作者 plugin_author = "Attente" # 作者主页 @@ -41,6 +41,8 @@ class BangumiColl(_PluginBase): # 私有变量 _scheduler: Optional[BackgroundScheduler] = None siteoper: SiteOper = None + subscribehelper: SubscribeHelper = None + subscribeoper: SubscribeOper = None # 配置属性 _enabled: bool = False @@ -51,14 +53,14 @@ class BangumiColl(_PluginBase): _exclude: str = "" _uid: str = "" _collection_type = [] - _collection: Dict = {} _save_path: str = "" _sites: list = [] - def init_plugin(self, config: dict = None): self.subscribechain = SubscribeChain() self.siteoper = SiteOper() + self.subscribehelper = SubscribeHelper() + self.subscribeoper = SubscribeOper() # 停止现有任务 self.stop_service() @@ -73,7 +75,6 @@ class BangumiColl(_PluginBase): self._exclude = config.get("exclude") self._uid = config.get("uid") self._collection_type = config.get("collection_type") or [3] - self._collection = config.get("collection") self._save_path = config.get("save_path") self._sites = config.get("sites") @@ -110,7 +111,6 @@ class BangumiColl(_PluginBase): "cron": self._cron, "uid": self._uid, "collection_type": self._collection_type, - "collection": self._collection, "include": self._include, "exclude": self._exclude, "save_path": self._save_path, @@ -159,7 +159,7 @@ class BangumiColl(_PluginBase): 'component': 'VSwitch', 'props': { 'model': 'notify', - 'label': '发送通知', + 'label': '自动取消订阅并通知', }, } ], @@ -323,6 +323,7 @@ class BangumiColl(_PluginBase): pass # 注册定时任务 + def get_service(self) -> List[Dict[str, Any]]: """ 注册插件公共服务 @@ -382,11 +383,11 @@ class BangumiColl(_PluginBase): addr = f"https://api.bgm.tv/v0/users/{self._uid}/collections?subject_type=2" headers = { - "User-Agent": "jxxghp/MoviePilot-Plugins (https://github.com/jxxghp/MoviePilot-Plugins)" + "User-Agent": "wikrin/MoviePilot-Plugins (https://github.com/wikrin/MoviePilot-Plugins)" } try: - logger.info(f"查询bangumi条目信息:{addr} ...") + logger.info(f"查询bangumi条目信息:{addr}") res = RequestUtils(headers=headers).get_res(url=addr) res = res.json().get("data") if not res: @@ -399,7 +400,11 @@ class BangumiColl(_PluginBase): logger.info(f"解析Bangumi条目信息...") for item in res: if item.get("type") not in self._collection_type: + logger.debug( + f"条目: {item['subject'].get('name_cn')} 类型:{item.get('type')} 不符合" + ) continue + # 条目id subject_id = item.get("subject_id") # 主标题 @@ -411,32 +416,44 @@ class BangumiColl(_PluginBase): ## 这里在后面添加排除规则 items.update({subject_id: {"name": name, "name_cn": name_cn, "date": date}}) ## 获取此插件添加的订阅 - db_sub = {i.bangumiid: i.id for i in self.subscribechain.subscribeoper.list() if i.bangumiid and i.username == "Bangumi订阅"} + db_sub = { + i.bangumiid: i.id + for i in self.subscribechain.subscribeoper.list() + if i.bangumiid and i.username == "Bangumi订阅" + } # 新增条目 new_sub = items.keys() - db_sub.keys() - # 移除条目, 这里暂时不做 - # del_sub = dbrid.keys() - items.keys() + logger.debug(f"待新增条目:{new_sub}") + # 移除条目 + del_sub = db_sub.keys() - items.keys() + logger.debug(f"待移除条目:{del_sub}") + logger.info(f"解析Bangumi条目信息完成,共{len(items)}条,新增{len(new_sub)}条") - # # 执行移除操作 - # if del_sub: - # del_items = {dbrid[i]: i for i in del_sub} - # logger.info(f"开始移除订阅...") - # self.delete_subscribe(del_items) - + # 执行移除操作 + if del_sub and self._notify: + # 数据库id为键,bgm条目id为值 + del_items = {db_sub[i]: i for i in del_sub} + logger.info(f"开始移除订阅...") + self.delete_subscribe(del_items) + # 执行添加操作 if new_sub: + # bgm条目id为键,bgm条目信息为值 new_sub = {i: items[i] for i in new_sub} logger.info(f"开始添加订阅...") self.add_subscribe(new_sub) - + # 结束 logger.info(f"Bangumi收藏订阅执行完成") - - # 添加订阅 + def add_subscribe(self, items: Dict[int, Dict[str, Any]]): + ''' + 添加订阅 + :param items: bgm条目id为键,bgm条目信息为值 + ''' for subject_id, item in items.items(): meta = MetaInfo(item.get("name_cn")) if not meta.name: @@ -478,9 +495,33 @@ class BangumiColl(_PluginBase): username="Bangumi订阅", **kwargs, ) - - def delete_subscribe(self, del_items: dict): - pass + + # 移除订阅 + + def delete_subscribe(self, del_items: Dict[int, int]): + ''' + 删除订阅 + :param del_items: 数据库id为键,bgm条目id为值 + ''' + args = [i for i in del_items.keys()] + for arg in args: + subscribe_id = int(arg) + subscribe = self.subscribeoper.get(subscribe_id) + if subscribe: + self.subscribeoper.delete(subscribe_id) + # 统计订阅 + self.subscribehelper.sub_done_async( + {"tmdbid": subscribe.tmdbid, "doubanid": subscribe.doubanid} + ) + # 发送通知 + self.post_message( + mtype=NotificationType.Subscribe, + title=f"{subscribe.name}({subscribe.year}) 第{subscribe.season}季 已取消订阅", + text="原因: 未在Bangumi收藏中找到该条目\n" + + f"订阅用户: {subscribe.username}\n" + + f"创建时间: {subscribe.date}", + image=subscribe.backdrop, + ) @staticmethod def are_dates(date_str1, date_str2, threshold_days: int = 7) -> bool: @@ -488,7 +529,7 @@ class BangumiColl(_PluginBase): 对比两个日期字符串是否接近 :param date_str1: 第一个日期字符串,格式为'YYYY-MM-DD' :param date_str2: 第二个日期字符串,格式为'YYYY-MM-DD' - :param threshold_days: 阈值天数,默认为1天 + :param threshold_days: 阈值天数,默认为7天 :return: 如果两个日期之间的差异小于等于阈值天数,则返回True,否则返回False """ # 将日期字符串转换为datetime对象 From c005744c2c1df3cb19a655496504adb3df6c032e Mon Sep 17 00:00:00 2001 From: jxxghp Date: Mon, 9 Sep 2024 10:20:35 +0800 Subject: [PATCH 049/218] =?UTF-8?q?feat=EF=BC=9A=E6=A0=87=E8=AF=86?= =?UTF-8?q?=E5=85=BC=E5=AE=B9v2=E7=9A=84=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 111 ++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 84 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index f3ecb75..675599b 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "icon": "signin.png", "author": "thsrite", "level": 2, + "v2": true, "history": { "v2.4.1": "修复海胆签到失败问题", "v2.4": "适配m-team Api地址变化", @@ -26,7 +27,8 @@ "version": "1.0", "icon": "world.png", "author": "lightolly", - "level": 2 + "level": 2, + "v2": true }, "SiteStatistic": { "name": "站点数据统计", @@ -64,7 +66,8 @@ "version": "1.2", "icon": "Chrome_A.png", "author": "thsrite", - "level": 2 + "level": 2, + "v2": true }, "DoubanSync": { "name": "豆瓣想看", @@ -74,6 +77,7 @@ "icon": "douban.png", "author": "jxxghp", "level": 2, + "v2": true, "history": { "v1.8": "不同步在看条目", "v1.7": "增强API安全性", @@ -105,7 +109,8 @@ "version": "1.1", "icon": "chinesesubfinder.png", "author": "jxxghp", - "level": 1 + "level": 1, + "v2": true }, "DoubanRank": { "name": "豆瓣榜单订阅", @@ -115,6 +120,7 @@ "icon": "movie.jpg", "author": "jxxghp", "level": 2, + "v2": true, "history": { "v1.9.1": "优化媒体类型的判断处理", "v1.9": "增强API安全性", @@ -129,6 +135,7 @@ "icon": "scraper.png", "author": "jxxghp", "level": 1, + "v2": true, "history": { "v1.5": "修复未获取fanart图片的问题", "v1.4.1": "修复nfo文件读取失败时任务中断问题" @@ -141,7 +148,8 @@ "version": "1.2.2", "icon": "delete.jpg", "author": "jxxghp", - "level": 2 + "level": 2, + "v2": true }, "MediaSyncDel": { "name": "媒体文件同步删除", @@ -151,6 +159,7 @@ "icon": "mediasyncdel.png", "author": "thsrite", "level": 1, + "v2": true, "history": { "v1.7": "修复重新整理被一并删除问题", "v1.6": "修复删除辅种", @@ -165,6 +174,7 @@ "icon": "hosts.png", "author": "thsrite", "level": 1, + "v2": true, "history": { "v1.1": "关闭插件时自动恢复系统hosts" } @@ -177,6 +187,7 @@ "icon": "Librespeed_A.png", "author": "Shurelol", "level": 1, + "v2": true, "history": { "v1.2": "增加不限速路径配置,以应对网盘直链播放的情况" } @@ -189,6 +200,7 @@ "icon": "cloudflare.jpg", "author": "thsrite", "level": 1, + "v2": true, "history": { "v1.4": "修复立即运行一次", "v1.3": "调整插件开启状态判断条件", @@ -203,6 +215,7 @@ "icon": "like.jpg", "author": "wlj", "level": 2, + "v2": true, "history": { "v2.3": "修复定时任务运行问题,Jellyfin的Webhook需要主程序大于1.8.7才能正常订阅。", "v2.2": "修复运行报错问题" @@ -216,6 +229,7 @@ "icon": "mediaplay.png", "author": "jxxghp", "level": 1, + "v2": true, "history": { "v1.2": "播放通知增加超链接跳转(需要v1.9.4+)" } @@ -227,7 +241,8 @@ "version": "1.2", "icon": "refresh2.png", "author": "jxxghp", - "level": 1 + "level": 1, + "v2": true }, "WebHook": { "name": "Webhook", @@ -235,7 +250,8 @@ "version": "1.0", "icon": "webhook.png", "author": "jxxghp", - "level": 1 + "level": 1, + "v2": true }, "ChatGPT": { "name": "ChatGPT", @@ -244,7 +260,8 @@ "version": "1.3", "icon": "Chatgpt_A.png", "author": "jxxghp", - "level": 1 + "level": 1, + "v2": true }, "NAStoolSync": { "name": "历史记录同步", @@ -271,6 +288,7 @@ "icon": "Time_machine_B.png", "author": "thsrite", "level": 1, + "v2": true, "history": { "v1.3": "去除已废弃的环境变量引用", "v1.2": "增强API安全性" @@ -284,6 +302,7 @@ "icon": "IYUU.png", "author": "jxxghp", "level": 2, + "v2": true, "history": { "v1.9.3": "修复Monika因缺少rsskey,种子下载失败的问题", "v1.9.2": "适配馒头使用API下载种子", @@ -305,6 +324,7 @@ "icon": "qingwa.png", "author": "233@qingwa", "level": 2, + "v2": true, "history": { "v2.2": "站点停用后会同步暂停对该站点的辅种", "v2.3": "站点辅种支持代理" @@ -318,6 +338,7 @@ "icon": "vcbmonitor.png", "author": "pixel@qingwa", "level": 2, + "v2": true, "history": { "v1.8": "增加了元数据刮削开关,升级后需要手动打开,否则默认不刮削", "v1.7.1": "修复偶尔安装失败问题" @@ -331,6 +352,7 @@ "icon": "seed.png", "author": "jxxghp", "level": 2, + "v2": true, "history": { "v1.4": "支持自动删除源下载器在目的下载器中存在的种子" } @@ -343,6 +365,7 @@ "icon": "rss.png", "author": "jxxghp", "level": 2, + "v2": true, "history": { "v1.5": "支持按种子大小过滤种子", "v1.4": "修复剧集本地是否存在的判断错误问题", @@ -356,7 +379,8 @@ "version": "1.1", "icon": "Youtube-dl_A.png", "author": "thsrite", - "level": 1 + "level": 1, + "v2": true }, "BrushFlow": { "name": "站点刷流", @@ -366,6 +390,7 @@ "icon": "brush.jpg", "author": "jxxghp,InfinityPacer", "level": 2, + "v2": true, "history": { "v3.6": "优化检查服务中的时间管控", "v3.5": "移除「删种排除MoviePilot任务」配置项(请使用「删除排除标签」替代),完善刷流任务触发插件事件相关逻辑(联动H&R助手)", @@ -388,7 +413,8 @@ "version": "1.1", "icon": "downloadmsg.png", "author": "thsrite", - "level": 2 + "level": 2, + "v2": true }, "AutoClean": { "name": "定时清理媒体库", @@ -397,7 +423,8 @@ "version": "1.1", "icon": "clean.png", "author": "thsrite", - "level": 2 + "level": 2, + "v2": true }, "InvitesSignin": { "name": "药丸签到", @@ -407,6 +434,7 @@ "icon": "invites.png", "author": "thsrite", "level": 2, + "v2": true, "history": { "v1.4": "自定义保留消息天数" } @@ -419,6 +447,7 @@ "icon": "actor.png", "author": "jxxghp", "level": 1, + "v2": true, "history": { "v1.4": "人物图片调整为优先从TMDB获取,避免douban图片CDN加载过慢的问题", "v1.3": "修复v1.8.5版本后刮削报错问题" @@ -432,6 +461,7 @@ "icon": "Moviepilot_A.png", "author": "thsrite", "level": 1, + "v2": true, "history": { "v1.4": "兼容更新内容带版本号的情况", "v1.3": "增加前端版本更新检查,需要主程序升级至v1.8.4+版本" @@ -444,7 +474,8 @@ "version": "1.3", "icon": "clouddisk.png", "author": "thsrite", - "level": 1 + "level": 1, + "v2": true }, "BarkMsg": { "name": "Bark消息推送", @@ -453,7 +484,8 @@ "version": "1.1", "icon": "Bark_A.png", "author": "jxxghp", - "level": 1 + "level": 1, + "v2": true }, "IyuuMsg": { "name": "IYUU消息推送", @@ -462,7 +494,8 @@ "version": "1.2", "icon": "Iyuu_A.png", "author": "jxxghp", - "level": 1 + "level": 1, + "v2": true }, "PushDeerMsg": { "name": "PushDeer消息推送", @@ -471,7 +504,8 @@ "version": "1.1", "icon": "pushdeer.png", "author": "jxxghp", - "level": 1 + "level": 1, + "v2": true }, "ConfigCenter": { "name": "配置中心", @@ -481,6 +515,7 @@ "icon": "setting.png", "author": "jxxghp", "level": 1, + "v2": true, "history": { "v2.6": "支持DOH相关配置项", "v2.5": "增加Github加速服务器设置项" @@ -493,7 +528,8 @@ "version": "1.0", "icon": "Wecom_A.png", "author": "叮叮当", - "level": 1 + "level": 1, + "v2": true }, "EpisodeGroupMeta": { "name": "TMDB剧集组刮削", @@ -502,7 +538,8 @@ "version": "1.1", "icon": "Element_A.png", "author": "叮叮当", - "level": 1 + "level": 1, + "v2": true }, "CustomIndexer": { "name": "自定义索引站点", @@ -511,7 +548,8 @@ "version": "1.0", "icon": "spider.png", "author": "jxxghp", - "level": 1 + "level": 1, + "v2": true }, "FFmpegThumb": { "name": "FFmpeg缩略图", @@ -520,7 +558,8 @@ "version": "1.2", "icon": "ffmpeg.png", "author": "jxxghp", - "level": 1 + "level": 1, + "v2": true }, "PushPlusMsg": { "name": "PushPlus消息推送", @@ -529,7 +568,8 @@ "version": "1.0", "icon": "Pushplus_A.png", "author": "cheng", - "level": 1 + "level": 1, + "v2": true }, "DownloadSiteTag": { "name": "下载任务分类与标签", @@ -539,6 +579,7 @@ "icon": "Youtube-dl_B.png", "author": "叮叮当", "level": 1, + "v2": true, "history": { "v2.1": "修复错误的TmdbHelper模块引用" } @@ -551,6 +592,7 @@ "icon": "Ombi_A.png", "author": "DzAvril", "level": 1, + "v2": true, "history": { "v2.2": "修复直接删除文件夹导致的插件崩溃的bug", "v2.1": "联动删除历史记录", @@ -569,6 +611,7 @@ "icon": "Linkace_C.png", "author": "jxxghp", "level": 1, + "v2": true, "history": { "v1.6": "增强API安全性" } @@ -580,7 +623,8 @@ "version": "1.2", "icon": "Bookstack_A.png", "author": "jxxghp", - "level": 1 + "level": 1, + "v2": true }, "RemoteIdentifiers": { "name": "共享识别词", @@ -589,7 +633,8 @@ "version": "2.2", "icon": "words.png", "author": "honue", - "level": 1 + "level": 1, + "v2": true }, "NeoDBSync": { "name": "NeoDB 想看", @@ -599,6 +644,7 @@ "icon": "NeoDB.jpeg", "author": "hcplantern", "level": 1, + "v2": true, "history": { "v1.1": "直接添加订阅,不提前进行搜索下载" } @@ -611,6 +657,7 @@ "icon": "Amule_A.png", "author": "jxxghp", "level": 1, + "v2": true, "history": { "v2.0": "适配新的目录结构变化,短剧分类名称调整为配置目录路径,升级后需要重新调整设置后才能使用。" } @@ -622,7 +669,8 @@ "version": "1.3", "icon": "Gatus_A.png", "author": "jeblove", - "level": 1 + "level": 1, + "v2": true }, "QbCommand": { "name": "QB远程操作", @@ -653,7 +701,8 @@ "version": "1.1", "icon": "ipAddress.png", "author": "DzAvril", - "level": 1 + "level": 1, + "v2": true }, "TrackerEditor": { "name": "Tracker替换", @@ -685,7 +734,8 @@ "version": "1.0", "icon": "FeiShu_A.png", "author": "InfinityPacer", - "level": 2 + "level": 2, + "v2": true }, "IyuuAuth": { "name": "IYUU站点绑定", @@ -695,6 +745,7 @@ "icon": "Iyuu_A.png", "author": "jxxghp", "level": 1, + "v2": true, "history": { "v1.1": "修复IYUU站点绑定失败问题" } @@ -706,7 +757,8 @@ "version": "1.0", "icon": "Ntfy_A.png", "author": "lethargicScribe", - "level": 1 + "level": 1, + "v2": true }, "TmdbWallpaper": { "name": "登录壁纸本地化", @@ -716,6 +768,7 @@ "icon": "Macos_Sierra.png", "author": "jxxghp", "level": 1, + "v2": true, "history": { "v1.1": "修复下载Bing每日壁纸时文件名错乱的问题" } @@ -727,7 +780,8 @@ "version": "1.0", "icon": "Duplicati_A.png", "author": "jxxghp", - "level": 1 + "level": 1, + "v2": true }, "CleanInvalidSeed": { "name": "清理QB无效做种", @@ -761,6 +815,7 @@ "icon": "TrendingShow.jpg", "author": "jxxghp", "level": 1, + "v2": true, "history": { "v1.3": "调整组件大小", "v1.2": "不同屏幕大小,支持分开设置" @@ -773,7 +828,8 @@ "version": "1.1", "icon": "Calibre_B.png", "author": "jxxghp", - "level": 1 + "level": 1, + "v2": true }, "ZvideoHelper": { "name": "极影视助手", @@ -783,6 +839,7 @@ "icon": "zvideo.png", "author": "DzAvril", "level": 1, + "v2": true, "history": { "v1.3": "降低对豆瓣接口的请求频率", "v1.2": "修复无法获取豆瓣评分的问题", From 5f9b07333495f1fece348fff0272bb5c60d47452 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Tue, 10 Sep 2024 08:16:19 +0800 Subject: [PATCH 050/218] =?UTF-8?q?feat=EF=BC=9AV2=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 29 + package.v2.json | 14 + plugins.v2/sitestatistic/__init__.py | 1504 +++++++++++++++++ .../sitestatistic/siteuserinfo/__init__.py | 428 +++++ .../sitestatistic/siteuserinfo/discuz.py | 139 ++ .../sitestatistic/siteuserinfo/file_list.py | 127 ++ .../sitestatistic/siteuserinfo/gazelle.py | 163 ++ .../sitestatistic/siteuserinfo/ipt_project.py | 93 + .../sitestatistic/siteuserinfo/mtorrent.py | 200 +++ .../siteuserinfo/nexus_audiences.py | 22 + .../siteuserinfo/nexus_hhanclub.py | 61 + .../sitestatistic/siteuserinfo/nexus_php.py | 404 +++++ .../siteuserinfo/nexus_project.py | 24 + .../siteuserinfo/nexus_rabbit.py | 57 + .../sitestatistic/siteuserinfo/small_horse.py | 110 ++ .../sitestatistic/siteuserinfo/tnode.py | 103 ++ .../siteuserinfo/torrent_leech.py | 109 ++ .../sitestatistic/siteuserinfo/unit3d.py | 130 ++ plugins.v2/sitestatistic/siteuserinfo/yema.py | 113 ++ 19 files changed, 3830 insertions(+) create mode 100644 package.v2.json create mode 100644 plugins.v2/sitestatistic/__init__.py create mode 100644 plugins.v2/sitestatistic/siteuserinfo/__init__.py create mode 100644 plugins.v2/sitestatistic/siteuserinfo/discuz.py create mode 100644 plugins.v2/sitestatistic/siteuserinfo/file_list.py create mode 100644 plugins.v2/sitestatistic/siteuserinfo/gazelle.py create mode 100644 plugins.v2/sitestatistic/siteuserinfo/ipt_project.py create mode 100644 plugins.v2/sitestatistic/siteuserinfo/mtorrent.py create mode 100644 plugins.v2/sitestatistic/siteuserinfo/nexus_audiences.py create mode 100644 plugins.v2/sitestatistic/siteuserinfo/nexus_hhanclub.py create mode 100644 plugins.v2/sitestatistic/siteuserinfo/nexus_php.py create mode 100644 plugins.v2/sitestatistic/siteuserinfo/nexus_project.py create mode 100644 plugins.v2/sitestatistic/siteuserinfo/nexus_rabbit.py create mode 100644 plugins.v2/sitestatistic/siteuserinfo/small_horse.py create mode 100644 plugins.v2/sitestatistic/siteuserinfo/tnode.py create mode 100644 plugins.v2/sitestatistic/siteuserinfo/torrent_leech.py create mode 100644 plugins.v2/sitestatistic/siteuserinfo/unit3d.py create mode 100644 plugins.v2/sitestatistic/siteuserinfo/yema.py diff --git a/README.md b/README.md index 7aa629d..2057cd4 100644 --- a/README.md +++ b/README.md @@ -506,3 +506,32 @@ def get_dashboard(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Di } ``` - 新增加的插件请配置在`package.json`中的末尾,这样可被识别为最新增加,可用于用户排序。 + +### 10. 如何开发V2版本的插件以及实现插件多版本兼容? +- 将插件代码放置在`plugins.v2`文件夹,将插件的定义放置在`package.v2.json`中,可实现该插件仅 MoviePilot V2 版本可见 +- 如V1版本插件实际在V2版本可用,或在插件中主动兼容了V1和V2版本,则可在`package.json`中定义 `"v2": true`属性,以便在 MoviePilot V2 版本插件市场中显示 + +```json +{ + "CustomSites": { + "name": "自定义站点", + "description": "增加自定义站点为签到和统计使用。", + "labels": "站点", + "version": "1.0", + "icon": "world.png", + "author": "lightolly", + "level": 2, + "v2": true + } +} +``` + +- MoviePilot V2中 Settings 模块中新增了`VERSION_FLAG`属性,V2版本值为`v2`,可通过以下代码判断当前的版本,以便在插件中兼容处理: + +```python +from app.core.config import settings +if hasattr(settings, 'VERSION_FLAG'): + version = settings.VERSION_FLAG # V2 +else: + version = "v1" +``` \ No newline at end of file diff --git a/package.v2.json b/package.v2.json new file mode 100644 index 0000000..bdf7828 --- /dev/null +++ b/package.v2.json @@ -0,0 +1,14 @@ +{ + "SiteStatistic": { + "name": "站点数据统计", + "description": "站点统计数据图表。", + "labels": "站点,仪表板", + "version": "1.0.0", + "icon": "statistic.png", + "author": "lightolly,jxxghp", + "level": 2, + "history": { + "v1.0.0": "MoviePilot V2 版本站点数据统计插件" + } + } +} \ No newline at end of file diff --git a/plugins.v2/sitestatistic/__init__.py b/plugins.v2/sitestatistic/__init__.py new file mode 100644 index 0000000..2c5622a --- /dev/null +++ b/plugins.v2/sitestatistic/__init__.py @@ -0,0 +1,1504 @@ +import json +import re +import warnings +from datetime import datetime, timedelta +from multiprocessing.dummy import Pool as ThreadPool +from threading import Lock +from typing import Optional, Any, List, Dict, Tuple + +import pytz +import requests +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from ruamel.yaml import CommentedMap + +from app import schemas +from app.core.config import settings +from app.core.event import Event, eventmanager +from app.db.models import PluginData +from app.db.site_oper import SiteOper +from app.helper.browser import PlaywrightHelper +from app.helper.module import ModuleHelper +from app.helper.sites import SitesHelper +from app.log import logger +from app.plugins import _PluginBase +from app.plugins.sitestatistic.siteuserinfo import ISiteUserInfo +from app.schemas.types import EventType, NotificationType +from app.utils.http import RequestUtils +from app.utils.object import ObjectUtils +from app.utils.string import StringUtils +from app.utils.timer import TimerUtils + +warnings.filterwarnings("ignore", category=FutureWarning) + +lock = Lock() + + +class SiteStatistic(_PluginBase): + # 插件名称 + plugin_name = "站点数据统计" + # 插件描述 + plugin_desc = "站点统计数据图表。" + # 插件图标 + plugin_icon = "statistic.png" + # 插件版本 + plugin_version = "1.0.0" + # 插件作者 + plugin_author = "lightolly,jxxghp" + # 作者主页 + author_url = "https://github.com/lightolly" + # 插件配置项ID前缀 + plugin_config_prefix = "sitestatistic_" + # 加载顺序 + plugin_order = 1 + # 可使用的用户级别 + auth_level = 2 + + # 私有属性 + sites = None + siteoper = None + _scheduler: Optional[BackgroundScheduler] = None + _last_update_time: Optional[datetime] = None + _sites_data: dict = {} + _site_schema: List[ISiteUserInfo] = None + + # 配置属性 + _enabled: bool = False + _onlyonce: bool = False + _sitemsg: bool = True + _cron: str = "" + _notify: bool = False + _queue_cnt: int = 5 + _remove_failed: bool = False + _statistic_type: str = None + _statistic_sites: list = [] + _dashboard_type: str = "today" + + def init_plugin(self, config: dict = None): + self.sites = SitesHelper() + self.siteoper = SiteOper() + # 停止现有任务 + self.stop_service() + + # 配置 + if config: + self._enabled = config.get("enabled") + self._onlyonce = config.get("onlyonce") + self._cron = config.get("cron") + 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 [] + self._dashboard_type = config.get("dashboard_type") or "today" + + # 过滤掉已删除的站点 + all_sites = [site.id for site in self.siteoper.list_order_by_pri()] + [site.get("id") for site in + self.__custom_sites()] + self._statistic_sites = [site_id for site_id in all_sites if site_id in self._statistic_sites] + self.__update_config() + + if self._enabled or self._onlyonce: + # 加载模块 + self._site_schema = ModuleHelper.load('app.plugins.sitestatistic.siteuserinfo', + filter_func=lambda _, obj: hasattr(obj, 'schema')) + + self._site_schema.sort(key=lambda x: x.order) + # 站点上一次更新时间 + self._last_update_time = None + # 站点数据 + self._sites_data = {} + + # 立即运行一次 + if self._onlyonce: + # 定时服务 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + logger.info(f"站点数据统计服务启动,立即运行一次") + self._scheduler.add_job(self.refresh_all_site_data, 'date', + run_date=datetime.now( + tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3) + ) + # 关闭一次性开关 + self._onlyonce = False + + # 保存配置 + self.__update_config() + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + """ + 定义远程控制命令 + :return: 命令关键字、事件、描述、附带数据 + """ + return [{ + "cmd": "/site_statistic", + "event": EventType.PluginAction, + "desc": "站点数据统计", + "category": "站点", + "data": { + "action": "site_statistic" + } + }] + + def get_api(self) -> List[Dict[str, Any]]: + """ + 获取插件API + [{ + "path": "/xx", + "endpoint": self.xxx, + "methods": ["GET", "POST"], + "summary": "API说明" + }] + """ + return [{ + "path": "/refresh_by_domain", + "endpoint": self.refresh_by_domain, + "methods": ["GET"], + "summary": "刷新站点数据", + "description": "刷新对应域名的站点数据", + }] + + 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 and self._cron: + return [{ + "id": "SiteStatistic", + "name": "站点数据统计服务", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self.refresh_all_site_data, + "kwargs": {} + }] + elif 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"SiteStatistic|{trigger.hour}:{trigger.minute}", + "name": "站点数据统计服务", + "trigger": "cron", + "func": self.refresh_all_site_data, + "kwargs": { + "hour": trigger.hour, + "minute": trigger.minute + } + }) + return ret_jobs + return [] + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + # 站点的可选项(内置站点 + 自定义站点) + customSites = self.__custom_sites() + + site_options = ([{"title": site.name, "value": site.id} + for site in self.siteoper.list_order_by_pri()] + + [{"title": site.get("name"), "value": site.get("id")} + for site in customSites]) + + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '发送通知', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '执行周期', + 'placeholder': '5位cron表达式,留空自动' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'queue_cnt', + 'label': '队列数量' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'statistic_type', + 'label': '统计类型', + 'items': [ + {'title': '全量', 'value': 'all'}, + {'title': '增量', 'value': 'add'} + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'dashboard_type', + 'label': '仪表板组件', + 'items': [ + {'title': '今日数据', 'value': 'today'}, + {'title': '汇总数据', 'value': 'total'}, + {'title': '所有数据', 'value': 'all'} + ] + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'chips': True, + 'multiple': True, + 'model': 'statistic_sites', + 'label': '统计站点', + 'items': site_options + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'sitemsg', + 'label': '站点未读消息', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'remove_failed', + 'label': '移除失效站点', + } + } + ] + }, + ] + } + ] + } + ], { + "enabled": False, + "onlyonce": False, + "notify": True, + "sitemsg": True, + "cron": "5 1 * * *", + "queue_cnt": 5, + "remove_failed": False, + "statistic_type": "all", + "statistic_sites": [], + "dashboard_type": 'today' + } + + def __get_data(self) -> Tuple[str, dict, dict]: + """ + 获取今天的日期、今天的站点数据、昨天的站点数据 + """ + # 最近一天的签到数据 + stattistic_data: Dict[str, Dict[str, Any]] = {} + # 昨天数据 + yesterday_sites_data: Dict[str, Dict[str, Any]] = {} + # 获取最近所有数据 + data_list: List[PluginData] = self.get_data(key=None) + if not data_list: + return "", {}, {} + # 取key符合日期格式的数据 + data_list = [data for data in data_list if re.match(r"\d{4}-\d{2}-\d{2}", data.key)] + # 按日期倒序排序 + data_list.sort(key=lambda x: x.key, reverse=True) + # 今天的日期 + today = data_list[0].key + # 数据按时间降序排序 + datas = [json.loads(data.value) for data in data_list if ObjectUtils.is_obj(data.value)] + if len(data_list) > 0: + stattistic_data = datas[0] + if len(data_list) > 1: + yesterday_sites_data = datas[1] + + # 数据按时间降序排序 + stattistic_data = dict(sorted(stattistic_data.items(), + key=lambda item: item[1].get('upload') or 0, + reverse=True)) + return today, stattistic_data, yesterday_sites_data + + @staticmethod + def __get_total_elements(today: str, stattistic_data: dict, yesterday_sites_data: dict, + dashboard: str = "today") -> List[dict]: + """ + 获取统计元素 + """ + + def __gb(value: int) -> float: + """ + 转换为GB,保留1位小数 + """ + if not value: + return 0 + return round(float(value) / 1024 / 1024 / 1024, 1) + + def __sub_dict(d1: dict, d2: dict) -> dict: + """ + 计算两个字典相同Key值的差值(如果值为数字),返回新字典 + """ + if not d1: + return {} + if not d2: + return d1 + d = {k: int(d1.get(k)) - int(d2.get(k)) for k in d1 + if k in d2 and str(d1.get(k)).isdigit() and str(d2.get(k)).isdigit()} + # 把小于0的数据变成0 + for k, v in d.items(): + if str(v).isdigit() and int(v) < 0: + d[k] = 0 + return d + + if dashboard in ['total', 'all']: + # 总上传量 + total_upload = sum([int(data.get("upload")) + for data in stattistic_data.values() if data.get("upload")]) + # 总下载量 + total_download = sum([int(data.get("download")) + for data in stattistic_data.values() if data.get("download")]) + # 总做种数 + total_seed = sum([int(data.get("seeding")) + for data in stattistic_data.values() if data.get("seeding")]) + # 总做种体积 + total_seed_size = sum([int(data.get("seeding_size")) + for data in stattistic_data.values() if data.get("seeding_size")]) + + total_elements = [ + # 总上传量 + { + 'component': 'VCol', + 'props': { + 'cols': 6, + 'md': 3 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'VAvatar', + 'props': { + 'rounded': True, + 'variant': 'text', + 'class': 'me-3' + }, + 'content': [ + { + 'component': 'VImg', + 'props': { + 'src': '/plugin_icon/upload.png' + } + } + ] + }, + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '总上传量' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': StringUtils.str_filesize(total_upload) + } + ] + } + ] + } + ] + } + ] + }, + ] + }, + # 总下载量 + { + 'component': 'VCol', + 'props': { + 'cols': 6, + 'md': 3, + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'VAvatar', + 'props': { + 'rounded': True, + 'variant': 'text', + 'class': 'me-3' + }, + 'content': [ + { + 'component': 'VImg', + 'props': { + 'src': '/plugin_icon/download.png' + } + } + ] + }, + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '总下载量' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': StringUtils.str_filesize(total_download) + } + ] + } + ] + } + ] + } + ] + }, + ] + }, + # 总做种数 + { + 'component': 'VCol', + 'props': { + 'cols': 6, + 'md': 3 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'VAvatar', + 'props': { + 'rounded': True, + 'variant': 'text', + 'class': 'me-3' + }, + 'content': [ + { + 'component': 'VImg', + 'props': { + 'src': '/plugin_icon/seed.png' + } + } + ] + }, + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '总做种数' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': f'{"{:,}".format(total_seed)}' + } + ] + } + ] + } + ] + } + ] + }, + ] + }, + # 总做种体积 + { + 'component': 'VCol', + 'props': { + 'cols': 6, + 'md': 3 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'VAvatar', + 'props': { + 'rounded': True, + 'variant': 'text', + 'class': 'me-3' + }, + 'content': [ + { + 'component': 'VImg', + 'props': { + 'src': '/plugin_icon/database.png' + } + } + ] + }, + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '总做种体积' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': StringUtils.str_filesize(total_seed_size) + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + else: + total_elements = [] + + if dashboard in ["today", "all"]: + # 计算增量数据集 + inc_data = {} + for site, data in stattistic_data.items(): + inc = __sub_dict(data, yesterday_sites_data.get(site)) + if inc: + inc_data[site] = inc + # 今日上传 + uploads = {k: v for k, v in inc_data.items() if v.get("upload")} + # 今日上传站点 + upload_sites = [site for site in uploads.keys()] + # 今日上传数据 + upload_datas = [__gb(data.get("upload")) for data in uploads.values()] + # 今日上传总量 + today_upload = round(sum(upload_datas), 2) + # 今日下载 + downloads = {k: v for k, v in inc_data.items() if v.get("download")} + # 今日下载站点 + download_sites = [site for site in downloads.keys()] + # 今日下载数据 + download_datas = [__gb(data.get("download")) for data in downloads.values()] + # 今日下载总量 + today_download = round(sum(download_datas), 2) + # 今日上传下载元素 + today_elements = [ + # 上传量图表 + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VApexChart', + 'props': { + 'height': 300, + 'options': { + 'chart': { + 'type': 'pie', + }, + 'labels': upload_sites, + 'title': { + 'text': f'今日上传({today})共 {today_upload} GB' + }, + 'legend': { + 'show': True + }, + 'plotOptions': { + 'pie': { + 'expandOnClick': False + } + }, + 'noData': { + 'text': '暂无数据' + } + }, + 'series': upload_datas + } + } + ] + }, + # 下载量图表 + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VApexChart', + 'props': { + 'height': 300, + 'options': { + 'chart': { + 'type': 'pie', + }, + 'labels': download_sites, + 'title': { + 'text': f'今日下载({today})共 {today_download} GB' + }, + 'legend': { + 'show': True + }, + 'plotOptions': { + 'pie': { + 'expandOnClick': False + } + }, + 'noData': { + 'text': '暂无数据' + } + }, + 'series': download_datas + } + } + ] + } + ] + else: + today_elements = [] + # 合并返回 + return total_elements + today_elements + + def get_dashboard(self) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], List[dict]]]: + """ + 获取插件仪表盘页面,需要返回:1、仪表板col配置字典;2、仪表板页面元素配置json(含数据);3、全局配置(自动刷新等) + 1、col配置参考: + { + "cols": 12, "md": 6 + } + 2、页面配置使用Vuetify组件拼装,参考:https://vuetifyjs.com/ + 3、全局配置参考: + { + "refresh": 10 // 自动刷新时间,单位秒 + } + """ + # 列配置 + cols = { + "cols": 12 + } + # 全局配置 + attrs = {} + # 获取数据 + today, stattistic_data, yesterday_sites_data = self.__get_data() + # 汇总 + # 站点统计 + elements = [ + { + 'component': 'VRow', + 'content': self.__get_total_elements( + today=today, + stattistic_data=stattistic_data, + yesterday_sites_data=yesterday_sites_data, + dashboard=self._dashboard_type + ) + } + ] + return cols, attrs, elements + + def get_page(self) -> List[dict]: + """ + 拼装插件详情页面,需要返回页面配置,同时附带数据 + """ + + def format_bonus(bonus): + try: + return f'{float(bonus):,.1f}' + except ValueError: + return '0.0' + + # 获取数据 + today, stattistic_data, yesterday_sites_data = self.__get_data() + if not stattistic_data: + return [ + { + 'component': 'div', + 'text': '暂无数据', + 'props': { + 'class': 'text-center', + } + } + ] + + # 站点统计 + site_totals = self.__get_total_elements( + today=today, + stattistic_data=stattistic_data, + yesterday_sites_data=yesterday_sites_data, + dashboard='all' + ) + + # 站点数据明细 + site_trs = [ + { + 'component': 'tr', + 'props': { + 'class': 'text-sm' + }, + 'content': [ + { + 'component': 'td', + 'props': { + 'class': 'whitespace-nowrap break-keep text-high-emphasis' + }, + 'text': site + }, + { + 'component': 'td', + 'text': data.get("username") + }, + { + 'component': 'td', + 'text': data.get("user_level") + }, + { + 'component': 'td', + 'props': { + 'class': 'text-success' + }, + 'text': StringUtils.str_filesize(data.get("upload")) + }, + { + 'component': 'td', + 'props': { + 'class': 'text-error' + }, + 'text': StringUtils.str_filesize(data.get("download")) + }, + { + 'component': 'td', + 'text': data.get('ratio') + }, + { + 'component': 'td', + 'text': format_bonus(data.get('bonus') or 0) + }, + { + 'component': 'td', + 'text': data.get('seeding') + }, + { + 'component': 'td', + 'text': StringUtils.str_filesize(data.get('seeding_size')) + } + ] + } for site, data in stattistic_data.items() if not data.get("err_msg") + ] + + # 拼装页面 + return [ + { + 'component': 'VRow', + 'content': site_totals + [ + # 各站点数据明细 + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + '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': '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': site_trs + } + ] + } + ] + } + ] + } + ] + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) + + def __build_class(self, html_text: str) -> Any: + for site_schema in self._site_schema: + try: + if site_schema.match(html_text): + return site_schema + except Exception as e: + logger.error(f"站点匹配失败 {str(e)}") + return None + + def build(self, site_info: CommentedMap) -> Optional[ISiteUserInfo]: + """ + 构建站点信息 + """ + site_name = site_info.get("name") + site_cookie = site_info.get("cookie") + apikey = site_info.get("apikey") + token = site_info.get("token") + if not site_cookie and not apikey and not token: + return None + url = site_info.get("url") + proxy = site_info.get("proxy") + ua = site_info.get("ua") + # 会话管理 + with requests.Session() as session: + proxies = settings.PROXY if proxy else None + proxy_server = settings.PROXY_SERVER if proxy else None + render = site_info.get("render") + logger.debug(f"站点 {site_name} url={url},site_cookie={site_cookie},ua={ua},api_key={apikey},token={token},proxy={proxy}") + if render: + # 演染模式 + html_text = PlaywrightHelper().get_page_source(url=url, + cookies=site_cookie, + ua=ua, + proxies=proxy_server) + else: + # 普通模式 + res = RequestUtils(cookies=site_cookie, + session=session, + ua=ua, + proxies=proxies + ).get_res(url=url) + if res and res.status_code == 200: + if re.search(r"charset=\"?utf-8\"?", res.text, re.IGNORECASE): + res.encoding = "utf-8" + else: + res.encoding = res.apparent_encoding + html_text = res.text + # 第一次登录反爬 + if html_text.find("title") == -1: + i = html_text.find("window.location") + if i == -1: + return None + tmp_url = url + html_text[i:html_text.find(";")] \ + .replace("\"", "") \ + .replace("+", "") \ + .replace(" ", "") \ + .replace("window.location=", "") + res = RequestUtils(cookies=site_cookie, + session=session, + ua=ua, + proxies=proxies + ).get_res(url=tmp_url) + if res and res.status_code == 200: + if "charset=utf-8" in res.text or "charset=UTF-8" in res.text: + res.encoding = "UTF-8" + else: + res.encoding = res.apparent_encoding + html_text = res.text + if not html_text: + return None + elif res is not None: + logger.error("站点 %s 被反爬限制:%s, 状态码:%s" % (site_name, url, res.status_code)) + return None + else: + logger.error("站点 %s 无法访问:%s" % (site_name, url)) + return None + + # 兼容假首页情况,假首页通常没有 schemas.Response: + """ + 刷新一个站点数据,可由API调用 + """ + if apikey != settings.API_TOKEN: + return schemas.Response(success=False, message="API密钥错误") + site_info = self.sites.get_indexer(domain) + if site_info: + site_data = self.__refresh_site_data(site_info) + if site_data: + return schemas.Response( + success=True, + message=f"站点 {domain} 刷新成功", + data=site_data.to_dict() + ) + return schemas.Response( + success=False, + message=f"站点 {domain} 刷新数据失败,未获取到数据" + ) + return schemas.Response( + success=False, + message=f"站点 {domain} 不存在" + ) + + def __refresh_site_data(self, site_info: CommentedMap) -> Optional[ISiteUserInfo]: + """ + 更新单个site 数据信息 + :param site_info: + :return: + """ + site_name = site_info.get('name') + site_url = site_info.get('url') + if not site_url: + return None + unread_msg_notify = True + try: + site_user_info: ISiteUserInfo = self.build(site_info=site_info) + if site_user_info: + logger.debug(f"站点 {site_name} 开始以 {site_user_info.site_schema()} 模型解析") + # 开始解析 + site_user_info.parse() + logger.debug(f"站点 {site_name} 解析完成") + + # 获取不到数据时,仅返回错误信息,不做历史数据更新 + if site_user_info.err_msg: + self._sites_data.update({site_name: {"err_msg": site_user_info.err_msg}}) + return None + + if self._sitemsg: + # 发送通知,存在未读消息 + self.__notify_unread_msg(site_name, site_user_info, unread_msg_notify) + + # 分享率接近1时,发送消息提醒 + if site_user_info.ratio and float(site_user_info.ratio) < 1: + self.post_message(mtype=NotificationType.SiteMessage, + title=f"【站点分享率低预警】", + text=f"站点 {site_user_info.site_name} 分享率 {site_user_info.ratio},请注意!") + + self._sites_data.update( + { + site_name: { + "upload": site_user_info.upload, + "username": site_user_info.username, + "user_level": site_user_info.user_level, + "join_at": site_user_info.join_at, + "download": site_user_info.download, + "ratio": site_user_info.ratio, + "seeding": site_user_info.seeding, + "seeding_size": site_user_info.seeding_size, + "leeching": site_user_info.leeching, + "bonus": site_user_info.bonus, + "url": site_url, + "err_msg": site_user_info.err_msg, + "message_unread": site_user_info.message_unread, + "updated_at": datetime.now().strftime('%Y-%m-%d') + } + }) + return site_user_info + + except Exception as e: + import traceback + logger.error(f"站点 {site_name} 获取流量数据失败:{str(e)}") + logger.error(traceback.format_exc()) + return None + + def __notify_unread_msg(self, site_name: str, site_user_info: ISiteUserInfo, unread_msg_notify: bool): + if site_user_info.message_unread <= 0: + return + if self._sites_data.get(site_name, {}).get('message_unread') == site_user_info.message_unread: + return + if not unread_msg_notify: + return + + # 解析出内容,则发送内容 + if len(site_user_info.message_unread_contents) > 0: + for head, date, content in site_user_info.message_unread_contents: + msg_title = f"【站点 {site_user_info.site_name} 消息】" + msg_text = f"时间:{date}\n标题:{head}\n内容:\n{content}" + self.post_message(mtype=NotificationType.SiteMessage, title=msg_title, text=msg_text) + else: + self.post_message(mtype=NotificationType.SiteMessage, + title=f"站点 {site_user_info.site_name} 收到 " + f"{site_user_info.message_unread} 条新消息,请登陆查看") + + @eventmanager.register(EventType.PluginAction) + def refresh(self, event: Event): + """ + 刷新站点数据 + """ + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "site_statistic": + return + logger.info("收到命令,开始刷新站点数据 ...") + self.post_message(channel=event.event_data.get("channel"), + title="开始刷新站点数据 ...", + userid=event.event_data.get("user")) + self.refresh_all_site_data() + if event: + self.post_message(channel=event.event_data.get("channel"), + title="站点数据刷新完成!", userid=event.event_data.get("user")) + + def refresh_all_site_data(self): + """ + 多线程刷新站点下载上传量,默认间隔6小时 + """ + if not self.sites.get_indexers(): + return + + logger.info("开始刷新站点数据 ...") + + with lock: + + all_sites = [site for site in self.sites.get_indexers() if not site.get("public")] + self.__custom_sites() + # 没有指定站点,默认使用全部站点 + if not self._statistic_sites: + refresh_sites = all_sites + else: + refresh_sites = [site for site in all_sites if + site.get("id") in self._statistic_sites] + 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: + messages = {} + # 总上传 + incUploads = 0 + # 总下载 + incDownloads = 0 + + 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 += 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: + 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(sorted_messages)) + + # 保存数据 + self.save_data(today_date, self._sites_data) + + # 更新时间 + self.save_data("last_update_time", today_date) + + self.eventmanager.send_event(etype=EventType.PluginAction, data={ + "action": "sitestatistic_refresh_complete" + }) + + logger.info("站点数据刷新完成") + + def __custom_sites(self) -> List[Any]: + custom_sites = [] + custom_sites_config = self.get_config("CustomSites") + if custom_sites_config and custom_sites_config.get("enabled"): + custom_sites = custom_sites_config.get("sites") + return custom_sites + + def __update_config(self): + self.update_config({ + "enabled": self._enabled, + "onlyonce": self._onlyonce, + "cron": self._cron, + "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, + "dashboard_type": self._dashboard_type + }) + + @eventmanager.register(EventType.SiteDeleted) + def site_deleted(self, event): + """ + 删除对应站点选中 + """ + site_id = event.event_data.get("site_id") + config = self.get_config() + if config: + statistic_sites = config.get("statistic_sites") + if statistic_sites: + if isinstance(statistic_sites, str): + statistic_sites = [statistic_sites] + + # 删除对应站点 + if site_id: + statistic_sites = [site for site in statistic_sites if int(site) != int(site_id)] + else: + # 清空 + statistic_sites = [] + + # 若无站点,则停止 + if len(statistic_sites) == 0: + self._enabled = False + + self._statistic_sites = statistic_sites + # 保存配置 + self.__update_config() diff --git a/plugins.v2/sitestatistic/siteuserinfo/__init__.py b/plugins.v2/sitestatistic/siteuserinfo/__init__.py new file mode 100644 index 0000000..92da174 --- /dev/null +++ b/plugins.v2/sitestatistic/siteuserinfo/__init__.py @@ -0,0 +1,428 @@ +# -*- coding: utf-8 -*- +import json +import re +from abc import ABCMeta, abstractmethod +from enum import Enum +from typing import Optional +from urllib.parse import urljoin, urlsplit + +from requests import Session + +from app.core.config import settings +from app.helper.cloudflare import under_challenge +from app.log import logger +from app.utils.http import RequestUtils +from app.utils.site import SiteUtils + +SITE_BASE_ORDER = 1000 + + +# 站点框架 +class SiteSchema(Enum): + DiscuzX = "Discuz!" + Gazelle = "Gazelle" + Ipt = "IPTorrents" + NexusPhp = "NexusPhp" + NexusProject = "NexusProject" + NexusRabbit = "NexusRabbit" + NexusHhanclub = "NexusHhanclub" + NexusAudiences = "NexusAudiences" + SmallHorse = "Small Horse" + Unit3d = "Unit3d" + TorrentLeech = "TorrentLeech" + FileList = "FileList" + TNode = "TNode" + MTorrent = "MTorrent" + Yema = "Yema" + + +class ISiteUserInfo(metaclass=ABCMeta): + # 站点模版 + schema = SiteSchema.NexusPhp + # 站点解析时判断顺序,值越小越先解析 + order = SITE_BASE_ORDER + # 请求模式 cookie/apikey + request_mode = "cookie" + + def __init__(self, site_name: str, + url: str, + site_cookie: str, + apikey: str, + token: str, + index_html: str, + session: Session = None, + ua: str = None, + emulate: bool = False, + proxy: bool = None): + super().__init__() + # 站点信息 + self.site_name = None + self.site_url = None + self.apikey = apikey + self.token = token + # 用户信息 + self.username = None + self.userid = None + # 未读消息 + self.message_unread = 0 + self.message_unread_contents = [] + + # 流量信息 + self.upload = 0 + self.download = 0 + self.ratio = 0 + + # 种子信息 + self.seeding = 0 + self.leeching = 0 + self.uploaded = 0 + self.completed = 0 + self.incomplete = 0 + self.seeding_size = 0 + self.leeching_size = 0 + self.uploaded_size = 0 + self.completed_size = 0 + self.incomplete_size = 0 + # 做种人数, 种子大小 + self.seeding_info = [] + + # 用户详细信息 + self._user_basic_page = None + self._user_basic_params = None + self._user_basic_headers = None + self.user_level = None + self.join_at = None + self.bonus = 0.0 + + # 错误信息 + self.err_msg = None + # 内部数据 + self._addition_headers = None + + # 站点页面 + self._brief_page = "index.php" + self._user_detail_page = "userdetails.php?id=" + self._user_detail_params = None + self._user_detail_headers = None + self._user_traffic_page = "index.php" + self._user_traffic_params = None + self._user_traffic_headers = None + self._user_mail_unread_page = "messages.php?action=viewmailbox&box=1&unread=yes" + self._sys_mail_unread_page = "messages.php?action=viewmailbox&box=-2&unread=yes" + self._mail_unread_params = None + self._mail_unread_headers = None + self._mail_content_params = None + self._mail_content_headers = None + self._torrent_seeding_page = "getusertorrentlistajax.php?userid=" + self._torrent_seeding_params = None + self._torrent_seeding_headers = None + + split_url = urlsplit(url) + self.site_name = site_name + self.site_url = url + self.site_domain = split_url.netloc + self._base_url = f"{split_url.scheme}://{split_url.netloc}" + self._site_cookie = site_cookie + self._index_html = index_html + self._session = session if session else None + self._ua = ua + + self._emulate = emulate + self._proxy = proxy + + def site_schema(self) -> SiteSchema: + """ + 站点解析模型 + :return: 站点解析模型 + """ + return self.schema + + @classmethod + def match(cls, html_text: str) -> bool: + """ + 是否匹配当前解析模型 + :param html_text: 站点首页html + :return: 是否匹配 + """ + pass + + def parse(self): + """ + 解析站点信息 + :return: + """ + # 检查是否已经登录 + if not self._parse_logged_in(self._index_html): + return + # 解析站点页面 + self._parse_site_page(self._index_html) + # 解析用户基础信息 + if self._user_basic_page: + self._parse_user_base_info( + self._get_page_content( + url=urljoin(self._base_url, self._user_basic_page), + params=self._user_basic_params, + headers=self._user_basic_headers + ) + ) + else: + self._parse_user_base_info(self._index_html) + # 解析用户详细信息 + if self._user_detail_page: + self._parse_user_detail_info( + self._get_page_content( + url=urljoin(self._base_url, self._user_detail_page), + params=self._user_detail_params, + headers=self._user_detail_headers + ) + ) + # 解析用户未读消息 + self._pase_unread_msgs() + # 解析用户上传、下载、分享率等信息 + if self._user_traffic_page: + self._parse_user_traffic_info( + self._get_page_content( + url=urljoin(self._base_url, self._user_traffic_page), + params=self._user_traffic_params, + headers=self._user_traffic_headers + ) + ) + # 解析用户做种信息 + self._parse_seeding_pages() + self.seeding_info = json.dumps(self.seeding_info) + + def _pase_unread_msgs(self): + """ + 解析所有未读消息标题和内容 + :return: + """ + unread_msg_links = [] + if self.message_unread > 0: + links = {self._user_mail_unread_page, self._sys_mail_unread_page} + for link in links: + if not link: + continue + msg_links = [] + next_page = self._parse_message_unread_links( + self._get_page_content( + url=urljoin(self._base_url, link), + params=self._mail_unread_params, + headers=self._mail_unread_headers + ), + msg_links) + while next_page: + next_page = self._parse_message_unread_links( + self._get_page_content( + url=urljoin(self._base_url, next_page), + params=self._mail_unread_params, + headers=self._mail_unread_headers + ), + 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}") + head, date, content = self._parse_message_content( + self._get_page_content( + urljoin(self._base_url, msg_link), + params=self._mail_content_params, + headers=self._mail_content_headers + ) + ) + logger.debug(f"{self.site_name} 标题 {head} 时间 {date} 内容 {content}") + self.message_unread_contents.append((head, date, content)) + + def _parse_seeding_pages(self): + """ + 解析做种页面 + """ + if self._torrent_seeding_page: + # 第一页 + next_page = self._parse_user_torrent_seeding_info( + self._get_page_content( + url=urljoin(self._base_url, self._torrent_seeding_page), + params=self._torrent_seeding_params, + headers=self._torrent_seeding_headers + ) + ) + + # 其他页处理 + while next_page is not None and next_page is not False: + next_page = self._parse_user_torrent_seeding_info( + self._get_page_content( + url=urljoin(urljoin(self._base_url, self._torrent_seeding_page), next_page), + params=self._torrent_seeding_params, + headers=self._torrent_seeding_headers + ), + multi_page=True) + + @staticmethod + def _prepare_html_text(html_text): + """ + 处理掉HTML中的干扰部分 + """ + return re.sub(r"#\d+", "", re.sub(r"\d+px", "", html_text)) + + @abstractmethod + def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: + """ + 获取未阅读消息链接 + :param html_text: + :return: + """ + pass + + def _get_page_content(self, url: str, params: dict = None, headers: dict = None): + """ + :param url: 网页地址 + :param params: post参数 + :param headers: 额外的请求头 + :return: + """ + req_headers = None + proxies = settings.PROXY if self._proxy else None + if self._ua or headers or self._addition_headers: + req_headers = { + "User-Agent": f"{self._ua}" + } + + if headers: + req_headers.update(headers) + else: + req_headers.update({ + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + }) + if self._addition_headers: + req_headers.update(self._addition_headers) + + if self.request_mode == "apikey": + # 使用apikey请求,通过请求头传递 + cookie = None + session = None + else: + # 使用cookie请求 + cookie = self._site_cookie + session = self._session + + if params: + if req_headers.get("Content-Type") == "application/json": + res = RequestUtils(cookies=cookie, + session=session, + timeout=60, + proxies=proxies, + headers=req_headers).post_res(url=url, json=params) + else: + res = RequestUtils(cookies=cookie, + session=session, + timeout=60, + proxies=proxies, + headers=req_headers).post_res(url=url, data=params) + else: + res = RequestUtils(cookies=cookie, + session=session, + timeout=60, + proxies=proxies, + headers=req_headers).get_res(url=url) + if res is not None and res.status_code in (200, 500, 403): + if req_headers and "application/json" in str(req_headers.get("Accept")): + return json.dumps(res.json()) + else: + # 如果cloudflare 有防护,尝试使用浏览器仿真 + if under_challenge(res.text): + logger.warn( + f"{self.site_name} 检测到Cloudflare,请更新Cookie和UA") + return "" + if re.search(r"charset=\"?utf-8\"?", res.text, re.IGNORECASE): + res.encoding = "utf-8" + else: + res.encoding = res.apparent_encoding + return res.text + + return "" + + @abstractmethod + def _parse_site_page(self, html_text: str): + """ + 解析站点相关信息页面 + :param html_text: + :return: + """ + pass + + @abstractmethod + def _parse_user_base_info(self, html_text: str): + """ + 解析用户基础信息 + :param html_text: + :return: + """ + pass + + def _parse_logged_in(self, html_text): + """ + 解析用户是否已经登陆 + :param html_text: + :return: True/False + """ + logged_in = SiteUtils.is_logged_in(html_text) + if not logged_in: + self.err_msg = "未检测到已登陆,请检查cookies是否过期" + logger.warn(f"{self.site_name} 未登录,跳过后续操作") + + return logged_in + + @abstractmethod + def _parse_user_traffic_info(self, html_text: str): + """ + 解析用户的上传,下载,分享率等信息 + :param html_text: + :return: + """ + pass + + @abstractmethod + def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]: + """ + 解析用户的做种相关信息 + :param html_text: + :param multi_page: 是否多页数据 + :return: 下页地址 + """ + pass + + @abstractmethod + def _parse_user_detail_info(self, html_text: str): + """ + 解析用户的详细信息 + 加入时间/等级/魔力值等 + :param html_text: + :return: + """ + pass + + @abstractmethod + def _parse_message_content(self, html_text): + """ + 解析短消息内容 + :param html_text: + :return: head: message, date: time, content: message content + """ + pass + + def to_dict(self): + """ + 转化为字典 + """ + attributes = [ + attr for attr in dir(self) + if not callable(getattr(self, attr)) and not attr.startswith("_") + ] + return { + attr: getattr(self, attr).value + if isinstance(getattr(self, attr), SiteSchema) + else getattr(self, attr) for attr in attributes + } diff --git a/plugins.v2/sitestatistic/siteuserinfo/discuz.py b/plugins.v2/sitestatistic/siteuserinfo/discuz.py new file mode 100644 index 0000000..03fbb81 --- /dev/null +++ b/plugins.v2/sitestatistic/siteuserinfo/discuz.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +import re +from typing import Optional + +from lxml import etree + +from app.plugins.sitestatistic.siteuserinfo import ISiteUserInfo, SITE_BASE_ORDER, SiteSchema +from app.utils.string import StringUtils + + +class DiscuzUserInfo(ISiteUserInfo): + schema = SiteSchema.DiscuzX + order = SITE_BASE_ORDER + 10 + + @classmethod + def match(cls, html_text: str) -> bool: + html = etree.HTML(html_text) + if not html: + return False + + printable_text = html.xpath("string(.)") if html else "" + return 'Powered by Discuz!' in printable_text + + def _parse_user_base_info(self, html_text: str): + html_text = self._prepare_html_text(html_text) + html = etree.HTML(html_text) + + user_info = html.xpath('//a[contains(@href, "&uid=")]') + if user_info: + user_id_match = re.search(r"&uid=(\d+)", user_info[0].attrib['href']) + if user_id_match and user_id_match.group().strip(): + self.userid = user_id_match.group(1) + self._torrent_seeding_page = f"forum.php?&mod=torrents&cat_5up=on" + self._user_detail_page = user_info[0].attrib['href'] + self.username = user_info[0].text.strip() + + def _parse_site_page(self, html_text: str): + # TODO + pass + + def _parse_user_detail_info(self, html_text: str): + """ + 解析用户额外信息,加入时间,等级 + :param html_text: + :return: + """ + html = etree.HTML(html_text) + if not html: + return None + + # 用户等级 + user_levels_text = html.xpath('//a[contains(@href, "usergroup")]/text()') + if user_levels_text: + self.user_level = user_levels_text[-1].strip() + + # 加入日期 + join_at_text = html.xpath('//li[em[text()="注册时间"]]/text()') + if join_at_text: + self.join_at = StringUtils.unify_datetime_str(join_at_text[0].strip()) + + # 分享率 + ratio_text = html.xpath('//li[contains(.//text(), "分享率")]//text()') + if ratio_text: + ratio_match = re.search(r"\(([\d,.]+)\)", ratio_text[0]) + if ratio_match and ratio_match.group(1).strip(): + self.bonus = StringUtils.str_float(ratio_match.group(1)) + + # 积分 + bouns_text = html.xpath('//li[em[text()="积分"]]/text()') + if bouns_text: + self.bonus = StringUtils.str_float(bouns_text[0].strip()) + + # 上传 + upload_text = html.xpath('//li[em[contains(text(),"上传量")]]/text()') + if upload_text: + self.upload = StringUtils.num_filesize(upload_text[0].strip().split('/')[-1]) + + # 下载 + download_text = html.xpath('//li[em[contains(text(),"下载量")]]/text()') + if download_text: + self.download = StringUtils.num_filesize(download_text[0].strip().split('/')[-1]) + + def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]: + """ + 做种相关信息 + :param html_text: + :param multi_page: 是否多页数据 + :return: 下页地址 + """ + html = etree.HTML(html_text) + if not html: + return None + + size_col = 3 + seeders_col = 4 + # 搜索size列 + if html.xpath('//tr[position()=1]/td[.//img[@class="size"] and .//img[@alt="size"]]'): + size_col = len(html.xpath('//tr[position()=1]/td[.//img[@class="size"] ' + 'and .//img[@alt="size"]]/preceding-sibling::td')) + 1 + # 搜索seeders列 + if html.xpath('//tr[position()=1]/td[.//img[@class="seeders"] and .//img[@alt="seeders"]]'): + seeders_col = len(html.xpath('//tr[position()=1]/td[.//img[@class="seeders"] ' + 'and .//img[@alt="seeders"]]/preceding-sibling::td')) + 1 + + page_seeding = 0 + page_seeding_size = 0 + page_seeding_info = [] + seeding_sizes = html.xpath(f'//tr[position()>1]/td[{size_col}]') + seeding_seeders = html.xpath(f'//tr[position()>1]/td[{seeders_col}]//text()') + if seeding_sizes and seeding_seeders: + page_seeding = len(seeding_sizes) + + for i in range(0, len(seeding_sizes)): + size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip()) + seeders = StringUtils.str_int(seeding_seeders[i]) + + page_seeding_size += size + page_seeding_info.append([seeders, size]) + + self.seeding += page_seeding + self.seeding_size += page_seeding_size + self.seeding_info.extend(page_seeding_info) + + # 是否存在下页数据 + next_page = None + next_page_text = html.xpath('//a[contains(.//text(), "下一页") or contains(.//text(), "下一頁")]/@href') + if next_page_text: + next_page = next_page_text[-1].strip() + + return next_page + + def _parse_user_traffic_info(self, html_text: str): + pass + + def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: + return None + + def _parse_message_content(self, html_text): + return None, None, None diff --git a/plugins.v2/sitestatistic/siteuserinfo/file_list.py b/plugins.v2/sitestatistic/siteuserinfo/file_list.py new file mode 100644 index 0000000..9bf6f31 --- /dev/null +++ b/plugins.v2/sitestatistic/siteuserinfo/file_list.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +import re +from typing import Optional + +from lxml import etree + +from app.plugins.sitestatistic.siteuserinfo import ISiteUserInfo, SITE_BASE_ORDER, SiteSchema +from app.utils.string import StringUtils + + +class FileListSiteUserInfo(ISiteUserInfo): + schema = SiteSchema.FileList + order = SITE_BASE_ORDER + 50 + + @classmethod + def match(cls, html_text: str) -> bool: + html = etree.HTML(html_text) + if not html: + return False + + printable_text = html.xpath("string(.)") if html else "" + return 'Powered by FileList' in printable_text + + def _parse_site_page(self, html_text: str): + html_text = self._prepare_html_text(html_text) + + user_detail = re.search(r"userdetails.php\?id=(\d+)", html_text) + if user_detail and user_detail.group().strip(): + self._user_detail_page = user_detail.group().strip().lstrip('/') + self.userid = user_detail.group(1) + + self._torrent_seeding_page = f"snatchlist.php?id={self.userid}&action=torrents&type=seeding" + + def _parse_user_base_info(self, html_text: str): + html_text = self._prepare_html_text(html_text) + html = etree.HTML(html_text) + + ret = html.xpath(f'//a[contains(@href, "userdetails") and contains(@href, "{self.userid}")]//text()') + if ret: + self.username = str(ret[0]) + + def _parse_user_traffic_info(self, html_text: str): + """ + 上传/下载/分享率 [做种数/魔力值] + :param html_text: + :return: + """ + return + + def _parse_user_detail_info(self, html_text: str): + html_text = self._prepare_html_text(html_text) + html = etree.HTML(html_text) + + upload_html = html.xpath('//table//tr/td[text()="Uploaded"]/following-sibling::td//text()') + if upload_html: + self.upload = StringUtils.num_filesize(upload_html[0]) + download_html = html.xpath('//table//tr/td[text()="Downloaded"]/following-sibling::td//text()') + if download_html: + self.download = StringUtils.num_filesize(download_html[0]) + + ratio_html = html.xpath('//table//tr/td[text()="Share ratio"]/following-sibling::td//text()') + if ratio_html: + share_ratio = StringUtils.str_float(ratio_html[0]) + self.ratio = 0 if self.download == 0 else share_ratio + + seed_html = html.xpath('//table//tr/td[text()="Seed bonus"]/following-sibling::td//text()') + if seed_html: + self.seeding = StringUtils.str_int(seed_html[1]) + self.seeding_size = StringUtils.num_filesize(seed_html[3]) + + user_level_html = html.xpath('//table//tr/td[text()="Class"]/following-sibling::td//text()') + if user_level_html: + self.user_level = user_level_html[0].strip() + + join_at_html = html.xpath('//table//tr/td[contains(text(), "Join")]/following-sibling::td//text()') + if join_at_html: + join_at = (join_at_html[0].split("("))[0].strip() + self.join_at = StringUtils.unify_datetime_str(join_at) + + bonus_html = html.xpath('//a[contains(@href, "shop.php")]') + if bonus_html: + self.bonus = StringUtils.str_float(bonus_html[0].xpath("string(.)").strip()) + pass + + def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]: + """ + 做种相关信息 + :param html_text: + :param multi_page: 是否多页数据 + :return: 下页地址 + """ + html = etree.HTML(html_text) + if not html: + return None + + size_col = 6 + seeders_col = 7 + + page_seeding = 0 + page_seeding_size = 0 + page_seeding_info = [] + seeding_sizes = html.xpath(f'//table/tr[position()>1]/td[{size_col}]') + seeding_seeders = html.xpath(f'//table/tr[position()>1]/td[{seeders_col}]') + if seeding_sizes and seeding_seeders: + page_seeding = len(seeding_sizes) + + for i in range(0, len(seeding_sizes)): + size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip()) + seeders = StringUtils.str_int(seeding_seeders[i].xpath("string(.)").strip()) + + page_seeding_size += size + page_seeding_info.append([seeders, size]) + + # self.seeding += page_seeding + # self.seeding_size += page_seeding_size + self.seeding_info.extend(page_seeding_info) + + # 是否存在下页数据 + next_page = None + + return next_page + + def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: + return None + + def _parse_message_content(self, html_text): + return None, None, None diff --git a/plugins.v2/sitestatistic/siteuserinfo/gazelle.py b/plugins.v2/sitestatistic/siteuserinfo/gazelle.py new file mode 100644 index 0000000..ae2de5e --- /dev/null +++ b/plugins.v2/sitestatistic/siteuserinfo/gazelle.py @@ -0,0 +1,163 @@ +# -*- coding: utf-8 -*- +import re +from typing import Optional + +from lxml import etree + +from app.plugins.sitestatistic.siteuserinfo import ISiteUserInfo, SITE_BASE_ORDER, SiteSchema +from app.utils.string import StringUtils + + +class GazelleSiteUserInfo(ISiteUserInfo): + schema = SiteSchema.Gazelle + order = SITE_BASE_ORDER + + @classmethod + def match(cls, html_text: str) -> bool: + html = etree.HTML(html_text) + if not html: + return False + + printable_text = html.xpath("string(.)") if html else "" + + return "Powered by Gazelle" in printable_text or "DIC Music" in printable_text + + def _parse_user_base_info(self, html_text: str): + html_text = self._prepare_html_text(html_text) + html = etree.HTML(html_text) + + tmps = html.xpath('//a[contains(@href, "user.php?id=")]') + if tmps: + user_id_match = re.search(r"user.php\?id=(\d+)", tmps[0].attrib['href']) + if user_id_match and user_id_match.group().strip(): + self.userid = user_id_match.group(1) + self._torrent_seeding_page = f"torrents.php?type=seeding&userid={self.userid}" + self._user_detail_page = f"user.php?id={self.userid}" + self.username = tmps[0].text.strip() + + tmps = html.xpath('//*[@id="header-uploaded-value"]/@data-value') + if tmps: + self.upload = StringUtils.num_filesize(tmps[0]) + else: + tmps = html.xpath('//li[@id="stats_seeding"]/span/text()') + if tmps: + self.upload = StringUtils.num_filesize(tmps[0]) + + tmps = html.xpath('//*[@id="header-downloaded-value"]/@data-value') + if tmps: + self.download = StringUtils.num_filesize(tmps[0]) + else: + tmps = html.xpath('//li[@id="stats_leeching"]/span/text()') + if tmps: + self.download = StringUtils.num_filesize(tmps[0]) + + self.ratio = 0.0 if self.download <= 0.0 else round(self.upload / self.download, 3) + + tmps = html.xpath('//a[contains(@href, "bonus.php")]/@data-tooltip') + if tmps: + bonus_match = re.search(r"([\d,.]+)", tmps[0]) + if bonus_match and bonus_match.group(1).strip(): + self.bonus = StringUtils.str_float(bonus_match.group(1)) + else: + tmps = html.xpath('//a[contains(@href, "bonus.php")]') + if tmps: + bonus_text = tmps[0].xpath("string(.)") + bonus_match = re.search(r"([\d,.]+)", bonus_text) + if bonus_match and bonus_match.group(1).strip(): + self.bonus = StringUtils.str_float(bonus_match.group(1)) + + def _parse_site_page(self, html_text: str): + # TODO + pass + + def _parse_user_detail_info(self, html_text: str): + """ + 解析用户额外信息,加入时间,等级 + :param html_text: + :return: + """ + html = etree.HTML(html_text) + if not html: + return None + + # 用户等级 + user_levels_text = html.xpath('//*[@id="class-value"]/@data-value') + if user_levels_text: + self.user_level = user_levels_text[0].strip() + else: + user_levels_text = html.xpath('//li[contains(text(), "用户等级")]/text()') + if user_levels_text: + self.user_level = user_levels_text[0].split(':')[1].strip() + + # 加入日期 + join_at_text = html.xpath('//*[@id="join-date-value"]/@data-value') + if join_at_text: + self.join_at = StringUtils.unify_datetime_str(join_at_text[0].strip()) + else: + join_at_text = html.xpath( + '//div[contains(@class, "box_userinfo_stats")]//li[contains(text(), "加入时间")]/span/text()') + if join_at_text: + self.join_at = StringUtils.unify_datetime_str(join_at_text[0].strip()) + + def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]: + """ + 做种相关信息 + :param html_text: + :param multi_page: 是否多页数据 + :return: 下页地址 + """ + html = etree.HTML(html_text) + if not html: + return None + + size_col = 3 + # 搜索size列 + if html.xpath('//table[contains(@id, "torrent")]//tr[1]/td'): + size_col = len(html.xpath('//table[contains(@id, "torrent")]//tr[1]/td')) - 3 + # 搜索seeders列 + seeders_col = size_col + 2 + + page_seeding = 0 + page_seeding_size = 0 + page_seeding_info = [] + seeding_sizes = html.xpath(f'//table[contains(@id, "torrent")]//tr[position()>1]/td[{size_col}]') + seeding_seeders = html.xpath(f'//table[contains(@id, "torrent")]//tr[position()>1]/td[{seeders_col}]/text()') + if seeding_sizes and seeding_seeders: + page_seeding = len(seeding_sizes) + + for i in range(0, len(seeding_sizes)): + size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip()) + seeders = int(seeding_seeders[i]) + + page_seeding_size += size + page_seeding_info.append([seeders, size]) + + if multi_page: + self.seeding += page_seeding + self.seeding_size += page_seeding_size + self.seeding_info.extend(page_seeding_info) + else: + if not self.seeding: + self.seeding = page_seeding + if not self.seeding_size: + self.seeding_size = page_seeding_size + if not self.seeding_info: + self.seeding_info = page_seeding_info + + # 是否存在下页数据 + next_page = None + next_page_text = html.xpath('//a[contains(.//text(), "Next") or contains(.//text(), "下一页")]/@href') + if next_page_text: + next_page = next_page_text[-1].strip() + + return next_page + + def _parse_user_traffic_info(self, html_text: str): + # TODO + pass + + def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: + return None + + def _parse_message_content(self, html_text): + return None, None, None diff --git a/plugins.v2/sitestatistic/siteuserinfo/ipt_project.py b/plugins.v2/sitestatistic/siteuserinfo/ipt_project.py new file mode 100644 index 0000000..9eeb217 --- /dev/null +++ b/plugins.v2/sitestatistic/siteuserinfo/ipt_project.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +import re +from typing import Optional + +from lxml import etree + +from app.plugins.sitestatistic.siteuserinfo import ISiteUserInfo, SITE_BASE_ORDER, SiteSchema +from app.utils.string import StringUtils + + +class IptSiteUserInfo(ISiteUserInfo): + schema = SiteSchema.Ipt + order = SITE_BASE_ORDER + 35 + + @classmethod + def match(cls, html_text: str) -> bool: + return 'IPTorrents' in html_text + + def _parse_user_base_info(self, html_text: str): + html_text = self._prepare_html_text(html_text) + html = etree.HTML(html_text) + tmps = html.xpath('//a[contains(@href, "/u/")]//text()') + tmps_id = html.xpath('//a[contains(@href, "/u/")]/@href') + if tmps: + self.username = str(tmps[-1]) + if tmps_id: + user_id_match = re.search(r"/u/(\d+)", tmps_id[0]) + if user_id_match and user_id_match.group().strip(): + self.userid = user_id_match.group(1) + self._user_detail_page = f"user.php?u={self.userid}" + self._torrent_seeding_page = f"peers?u={self.userid}" + + tmps = html.xpath('//div[@class = "stats"]/div/div') + if tmps: + self.upload = StringUtils.num_filesize(str(tmps[0].xpath('span/text()')[1]).strip()) + self.download = StringUtils.num_filesize(str(tmps[0].xpath('span/text()')[2]).strip()) + self.seeding = StringUtils.str_int(tmps[0].xpath('a')[2].xpath('text()')[0]) + self.leeching = StringUtils.str_int(tmps[0].xpath('a')[2].xpath('text()')[1]) + self.ratio = StringUtils.str_float(str(tmps[0].xpath('span/text()')[0]).strip().replace('-', '0')) + self.bonus = StringUtils.str_float(tmps[0].xpath('a')[3].xpath('text()')[0]) + + def _parse_site_page(self, html_text: str): + # TODO + pass + + def _parse_user_detail_info(self, html_text: str): + html = etree.HTML(html_text) + if not html: + return + + user_levels_text = html.xpath('//tr/th[text()="Class"]/following-sibling::td[1]/text()') + if user_levels_text: + self.user_level = user_levels_text[0].strip() + + # 加入日期 + join_at_text = html.xpath('//tr/th[text()="Join date"]/following-sibling::td[1]/text()') + if join_at_text: + self.join_at = StringUtils.unify_datetime_str(join_at_text[0].split(' (')[0]) + + def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]: + html = etree.HTML(html_text) + if not html: + return + # seeding start + seeding_end_pos = 3 + if html.xpath('//tr/td[text() = "Leechers"]'): + seeding_end_pos = len(html.xpath('//tr/td[text() = "Leechers"]/../preceding-sibling::tr')) + 1 + seeding_end_pos = seeding_end_pos - 3 + + page_seeding = 0 + page_seeding_size = 0 + seeding_torrents = html.xpath('//tr/td[text() = "Seeders"]/../following-sibling::tr/td[position()=6]/text()') + if seeding_torrents: + page_seeding = seeding_end_pos + for per_size in seeding_torrents[:seeding_end_pos]: + if '(' in per_size and ')' in per_size: + per_size = per_size.split('(')[-1] + per_size = per_size.split(')')[0] + + page_seeding_size += StringUtils.num_filesize(per_size) + + self.seeding = page_seeding + self.seeding_size = page_seeding_size + + def _parse_user_traffic_info(self, html_text: str): + # TODO + pass + + def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: + return None + + def _parse_message_content(self, html_text): + return None, None, None diff --git a/plugins.v2/sitestatistic/siteuserinfo/mtorrent.py b/plugins.v2/sitestatistic/siteuserinfo/mtorrent.py new file mode 100644 index 0000000..8c999d5 --- /dev/null +++ b/plugins.v2/sitestatistic/siteuserinfo/mtorrent.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- +import json +from typing import Optional, Tuple +from urllib.parse import urljoin + +from lxml import etree + +from app.log import logger +from app.plugins.sitestatistic.siteuserinfo import ISiteUserInfo, SITE_BASE_ORDER, SiteSchema +from app.utils.string import StringUtils + + +class MTorrentSiteUserInfo(ISiteUserInfo): + schema = SiteSchema.MTorrent + order = SITE_BASE_ORDER + 60 + request_mode = "apikey" + + # 用户级别字典 + MTeam_sysRoleList = { + "1": "User", + "2": "Power User", + "3": "Elite User", + "4": "Crazy User", + "5": "Insane User", + "6": "Veteran User", + "7": "Extreme User", + "8": "Ultimate User", + "9": "Nexus Master", + "10": "VIP", + "11": "Retiree", + "12": "Uploader", + "13": "Moderator", + "14": "Administrator", + "15": "Sysop", + "16": "Staff", + "17": "Offer memberStaff", + "18": "Bet memberStaff", + } + + @classmethod + def match(cls, html_text: str) -> bool: + html = etree.HTML(html_text) + if not html: + return False + if html.xpath("//title/text()") and "M-Team" in html.xpath("//title/text()")[0]: + return True + return False + + def _parse_site_page(self, html_text: str): + """ + 获取站点页面地址 + """ + # 更换api地址 + self._base_url = f"https://api.{StringUtils.get_url_domain(self._base_url)}" + self._user_traffic_page = None + self._user_detail_page = None + self._user_basic_page = "api/member/profile" + self._user_basic_params = { + "uid": self.userid + } + self._sys_mail_unread_page = None + self._user_mail_unread_page = "api/msg/search" + self._mail_unread_params = { + "keyword": "", + "box": "-2", + "type": "pageNumber", + "pageSize": 100 + } + self._torrent_seeding_page = "api/member/getUserTorrentList" + self._torrent_seeding_headers = { + "Content-Type": "application/json", + "Accept": "application/json, text/plain, */*" + } + self._addition_headers = { + "x-api-key": self.apikey, + } + + def _parse_logged_in(self, html_text): + """ + 判断是否登录成功, 通过判断是否存在用户信息 + 暂时跳过检测,待后续优化 + :param html_text: + :return: + """ + return True + + def _parse_user_base_info(self, html_text: str): + """ + 解析用户基本信息,这里把_parse_user_traffic_info和_parse_user_detail_info合并到这里 + """ + if not html_text: + return None + detail = json.loads(html_text) + if not detail or detail.get("code") != "0": + return + user_info = detail.get("data", {}) + self.userid = user_info.get("id") + self.username = user_info.get("username") + self.user_level = self.MTeam_sysRoleList.get(user_info.get("role") or "1") + self.join_at = user_info.get("memberStatus", {}).get("createdDate") + + self.upload = int(user_info.get("memberCount", {}).get("uploaded") or '0') + 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 = 99999 + + self._torrent_seeding_params = { + "pageNumber": 1, + "pageSize": 200, + "type": "SEEDING", + "userid": self.userid + } + + def _parse_user_traffic_info(self, html_text: str): + """ + 解析用户流量信息 + """ + pass + + def _parse_user_detail_info(self, html_text: str): + """ + 解析用户详细信息 + """ + pass + + def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]: + """ + 解析用户做种信息 + """ + if not html_text: + return None + seeding_info = json.loads(html_text) + if not seeding_info or seeding_info.get("code") != "0": + return None + torrents = seeding_info.get("data", {}).get("data", []) + page_seeding_size = 0 + page_seeding_info = [] + for info in torrents: + torrent = info.get("torrent", {}) + size = int(torrent.get("size") or '0') + seeders = int(torrent.get("source") or '0') + page_seeding_size += size + page_seeding_info.append([seeders, size]) + self.seeding += len(torrents) + self.seeding_size += page_seeding_size + self.seeding_info.extend(page_seeding_info) + + # 查询总做种数 + seeder_count = 0 + try: + result = self._get_page_content( + url=urljoin(self._base_url, "api/tracker/myPeerStatus"), + params={"uid": self.userid}, + ) + if result: + seeder_info = json.loads(result) + seeder_count = int(seeder_info.get("data", {}).get("seeder") or 0) + except Exception as e: + logger.error(f"获取做种数失败: {str(e)}") + if not seeder_count: + return None + if self.seeding >= seeder_count: + return None + # 还有下一页 + self._torrent_seeding_params["pageNumber"] += 1 + return "" + + def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: + """ + 解析未读消息链接,这里直接读出详情 + """ + if not html_text: + return None + messages_info = json.loads(html_text) + if not messages_info or messages_info.get("code") != "0": + return None + messages = messages_info.get("data", {}).get("data", []) + for message in messages: + if not message.get("unread"): + continue + head = message.get("title") + date = message.get("createdDate") + content = message.get("context") + if head and date and content: + self.message_unread_contents.append((head, date, content)) + # 设置已读 + self._get_page_content( + url=urljoin(self._base_url, f"api/msg/markRead"), + params={"msgId": message.get("id")} + ) + # 是否存在下页数据 + return None + + def _parse_message_content(self, html_text) -> Tuple[Optional[str], Optional[str], Optional[str]]: + """ + 解析消息内容 + """ + pass diff --git a/plugins.v2/sitestatistic/siteuserinfo/nexus_audiences.py b/plugins.v2/sitestatistic/siteuserinfo/nexus_audiences.py new file mode 100644 index 0000000..304dc26 --- /dev/null +++ b/plugins.v2/sitestatistic/siteuserinfo/nexus_audiences.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +from urllib.parse import urljoin + +from app.plugins.sitestatistic.siteuserinfo import SITE_BASE_ORDER, SiteSchema +from app.plugins.sitestatistic.siteuserinfo.nexus_php import NexusPhpSiteUserInfo + + +class NexusAudiencesSiteUserInfo(NexusPhpSiteUserInfo): + schema = SiteSchema.NexusAudiences + order = SITE_BASE_ORDER + 5 + + @classmethod + def match(cls, html_text: str) -> bool: + return 'audiences.me' in html_text + + def _parse_site_page(self, html_text: str): + super()._parse_site_page(html_text) + self._torrent_seeding_page = f"usertorrentlist.php?userid={self.userid}&type=seeding" + + def _parse_seeding_pages(self): + self._torrent_seeding_headers = {"Referer": urljoin(self._base_url, self._user_detail_page)} + super()._parse_seeding_pages() diff --git a/plugins.v2/sitestatistic/siteuserinfo/nexus_hhanclub.py b/plugins.v2/sitestatistic/siteuserinfo/nexus_hhanclub.py new file mode 100644 index 0000000..c85c96d --- /dev/null +++ b/plugins.v2/sitestatistic/siteuserinfo/nexus_hhanclub.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +import re + +from lxml import etree + +from app.plugins.sitestatistic.siteuserinfo import SITE_BASE_ORDER, SiteSchema +from app.plugins.sitestatistic.siteuserinfo.nexus_php import NexusPhpSiteUserInfo +from app.utils.string import StringUtils + + +class NexusHhanclubSiteUserInfo(NexusPhpSiteUserInfo): + schema = SiteSchema.NexusHhanclub + order = SITE_BASE_ORDER + 20 + + @classmethod + def match(cls, html_text: str) -> bool: + return 'hhanclub.top' in html_text + + def _parse_user_traffic_info(self, html_text): + super()._parse_user_traffic_info(html_text) + + html_text = self._prepare_html_text(html_text) + html = etree.HTML(html_text) + + # 上传、下载、分享率 + upload_match = re.search(r"[_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+[KMGTPI]*B)", + html.xpath('//*[@id="user-info-panel"]/div[2]/div[2]/div[4]/text()')[0]) + download_match = re.search(r"[_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+[KMGTPI]*B)", + html.xpath('//*[@id="user-info-panel"]/div[2]/div[2]/div[5]/text()')[0]) + ratio_match = re.search(r"分享率][::_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+)", + html.xpath('//*[@id="user-info-panel"]/div[2]/div[1]/div[1]/div/text()')[0]) + + # 计算分享率 + self.upload = StringUtils.num_filesize(upload_match.group(1).strip()) if upload_match else 0 + self.download = StringUtils.num_filesize(download_match.group(1).strip()) if download_match else 0 + # 优先使用页面上的分享率 + calc_ratio = 0.0 if self.download <= 0.0 else round(self.upload / self.download, 3) + self.ratio = StringUtils.str_float(ratio_match.group(1)) if ( + ratio_match and ratio_match.group(1).strip()) else calc_ratio + + def _parse_user_detail_info(self, html_text: str): + """ + 解析用户额外信息,加入时间,等级 + :param html_text: + :return: + """ + super()._parse_user_detail_info(html_text) + + html = etree.HTML(html_text) + if not html: + return + # 加入时间 + join_at_text = html.xpath('//*[@id="mainContent"]/div/div[2]/div[4]/div[3]/span[2]/text()[1]') + if join_at_text: + self.join_at = StringUtils.unify_datetime_str(join_at_text[0].split(' (')[0].strip()) + + def _get_user_level(self, html): + super()._get_user_level(html) + user_level_path = html.xpath('//*[@id="mainContent"]/div/div[2]/div[2]/div[4]/span[2]/img/@title') + if user_level_path: + self.user_level = user_level_path[0] diff --git a/plugins.v2/sitestatistic/siteuserinfo/nexus_php.py b/plugins.v2/sitestatistic/siteuserinfo/nexus_php.py new file mode 100644 index 0000000..13b357b --- /dev/null +++ b/plugins.v2/sitestatistic/siteuserinfo/nexus_php.py @@ -0,0 +1,404 @@ +# -*- coding: utf-8 -*- +import re +from typing import Optional + +from lxml import etree + +from app.log import logger +from app.plugins.sitestatistic.siteuserinfo import ISiteUserInfo, SITE_BASE_ORDER, SiteSchema +from app.utils.string import StringUtils + + +class NexusPhpSiteUserInfo(ISiteUserInfo): + schema = SiteSchema.NexusPhp + order = SITE_BASE_ORDER * 2 + + @classmethod + def match(cls, html_text: str) -> bool: + """ + 默认使用NexusPhp解析 + :param html_text: + :return: + """ + return True + + def _parse_site_page(self, html_text: str): + html_text = self._prepare_html_text(html_text) + + user_detail = re.search(r"userdetails.php\?id=(\d+)", html_text) + if user_detail and user_detail.group().strip(): + self._user_detail_page = user_detail.group().strip().lstrip('/') + self.userid = user_detail.group(1) + self._torrent_seeding_page = f"getusertorrentlistajax.php?userid={self.userid}&type=seeding" + else: + user_detail = re.search(r"(userdetails)", html_text) + if user_detail and user_detail.group().strip(): + self._user_detail_page = user_detail.group().strip().lstrip('/') + self.userid = None + self._torrent_seeding_page = None + + def _parse_message_unread(self, html_text): + """ + 解析未读短消息数量 + :param html_text: + :return: + """ + html = etree.HTML(html_text) + if not html: + return + + message_labels = html.xpath('//a[@href="messages.php"]/..') + message_labels.extend(html.xpath('//a[contains(@href, "messages.php")]/..')) + if message_labels: + message_text = message_labels[0].xpath("string(.)") + + logger.debug(f"{self.site_name} 消息原始信息 {message_text}") + message_unread_match = re.findall(r"[^Date](信息箱\s*|\(|你有\xa0)(\d+)", message_text) + + if message_unread_match and len(message_unread_match[-1]) == 2: + self.message_unread = StringUtils.str_int(message_unread_match[-1][1]) + elif message_text.isdigit(): + self.message_unread = StringUtils.str_int(message_text) + + def _parse_user_base_info(self, html_text: str): + """ + 解析用户基本信息 + """ + # 合并解析,减少额外请求调用 + self._parse_user_traffic_info(html_text) + self._user_traffic_page = None + + self._parse_message_unread(html_text) + + html = etree.HTML(html_text) + if not html: + return + + ret = html.xpath(f'//a[contains(@href, "userdetails") and contains(@href, "{self.userid}")]//b//text()') + if ret: + self.username = str(ret[0]) + return + ret = html.xpath(f'//a[contains(@href, "userdetails") and contains(@href, "{self.userid}")]//text()') + if ret: + self.username = str(ret[0]) + + ret = html.xpath('//a[contains(@href, "userdetails")]//strong//text()') + if ret: + self.username = str(ret[0]) + return + + def _parse_user_traffic_info(self, html_text): + """ + 解析用户流量信息 + """ + html_text = self._prepare_html_text(html_text) + upload_match = re.search(r"[^总]上[传傳]量?[::_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+[KMGTPI]*B)", html_text, + re.IGNORECASE) + self.upload = StringUtils.num_filesize(upload_match.group(1).strip()) if upload_match else 0 + download_match = re.search(r"[^总子影力]下[载載]量?[::_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+[KMGTPI]*B)", html_text, + re.IGNORECASE) + self.download = StringUtils.num_filesize(download_match.group(1).strip()) if download_match else 0 + ratio_match = re.search(r"分享率[::_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+)", html_text) + # 计算分享率 + calc_ratio = 0.0 if self.download <= 0.0 else round(self.upload / self.download, 3) + # 优先使用页面上的分享率 + self.ratio = StringUtils.str_float(ratio_match.group(1)) if ( + ratio_match and ratio_match.group(1).strip()) else calc_ratio + leeching_match = re.search(r"(Torrents leeching|下载中)[\u4E00-\u9FA5\D\s]+(\d+)[\s\S]+<", html_text) + self.leeching = StringUtils.str_int(leeching_match.group(2)) if leeching_match and leeching_match.group( + 2).strip() else 0 + html = etree.HTML(html_text) + has_ucoin, self.bonus = self._parse_ucoin(html) + if has_ucoin: + return + tmps = html.xpath('//a[contains(@href,"mybonus")]/text()') if html else None + if tmps: + bonus_text = str(tmps[0]).strip() + bonus_match = re.search(r"([\d,.]+)", bonus_text) + if bonus_match and bonus_match.group(1).strip(): + self.bonus = StringUtils.str_float(bonus_match.group(1)) + return + bonus_match = re.search(r"mybonus.[\[\]::<>/a-zA-Z_\-=\"'\s#;.(使用&说明魔力值豆]+\s*([\d,.]+)[\[<()&\s]", html_text) + try: + if bonus_match and bonus_match.group(1).strip(): + self.bonus = StringUtils.str_float(bonus_match.group(1)) + return + bonus_match = re.search(r"[魔力值|\]][\[\]::<>/a-zA-Z_\-=\"'\s#;]+\s*([\d,.]+|\"[\d,.]+\")[<>()&\s]", + html_text, + flags=re.S) + if bonus_match and bonus_match.group(1).strip(): + self.bonus = StringUtils.str_float(bonus_match.group(1).strip('"')) + except Exception as err: + logger.error(f"{self.site_name} 解析魔力值出错, 错误信息: {str(err)}") + + @staticmethod + def _parse_ucoin(html): + """ + 解析ucoin, 统一转换为铜币 + :param html: + :return: + """ + if html: + gold, silver, copper = None, None, None + + golds = html.xpath('//span[@class = "ucoin-symbol ucoin-gold"]//text()') + if golds: + gold = StringUtils.str_float(str(golds[-1])) + silvers = html.xpath('//span[@class = "ucoin-symbol ucoin-silver"]//text()') + if silvers: + silver = StringUtils.str_float(str(silvers[-1])) + coppers = html.xpath('//span[@class = "ucoin-symbol ucoin-copper"]//text()') + if coppers: + copper = StringUtils.str_float(str(coppers[-1])) + if gold or silver or copper: + gold = gold if gold else 0 + silver = silver if silver else 0 + copper = copper if copper else 0 + return True, gold * 100 * 100 + silver * 100 + copper + return False, 0.0 + + def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]: + """ + 做种相关信息 + :param html_text: + :param multi_page: 是否多页数据 + :return: 下页地址 + """ + html = etree.HTML(str(html_text).replace(r'\/', '/')) + if not html: + return None + + # 首页存在扩展链接,使用扩展链接 + seeding_url_text = html.xpath('//a[contains(@href,"torrents.php") ' + 'and contains(@href,"seeding")]/@href') + if multi_page is False and seeding_url_text and seeding_url_text[0].strip(): + self._torrent_seeding_page = seeding_url_text[0].strip() + return self._torrent_seeding_page + + size_col = 3 + seeders_col = 4 + # 搜索size列 + size_col_xpath = '//tr[position()=1]/' \ + 'td[(img[@class="size"] and img[@alt="size"])' \ + ' or (text() = "大小")' \ + ' or (a/img[@class="size" and @alt="size"])]' + if html.xpath(size_col_xpath): + size_col = len(html.xpath(f'{size_col_xpath}/preceding-sibling::td')) + 1 + # 搜索seeders列 + seeders_col_xpath = '//tr[position()=1]/' \ + 'td[(img[@class="seeders"] and img[@alt="seeders"])' \ + ' or (text() = "在做种")' \ + ' or (a/img[@class="seeders" and @alt="seeders"])]' + if html.xpath(seeders_col_xpath): + seeders_col = len(html.xpath(f'{seeders_col_xpath}/preceding-sibling::td')) + 1 + + page_seeding = 0 + page_seeding_size = 0 + page_seeding_info = [] + # 如果 table class="torrents",则增加table[@class="torrents"] + table_class = '//table[@class="torrents"]' if html.xpath('//table[@class="torrents"]') else '' + seeding_sizes = html.xpath(f'{table_class}//tr[position()>1]/td[{size_col}]') + seeding_seeders = html.xpath(f'{table_class}//tr[position()>1]/td[{seeders_col}]/b/a/text()') + if not seeding_seeders: + seeding_seeders = html.xpath(f'{table_class}//tr[position()>1]/td[{seeders_col}]//text()') + if seeding_sizes and seeding_seeders: + page_seeding = len(seeding_sizes) + + for i in range(0, len(seeding_sizes)): + size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip()) + seeders = StringUtils.str_int(seeding_seeders[i]) + + page_seeding_size += size + page_seeding_info.append([seeders, size]) + + self.seeding += page_seeding + self.seeding_size += page_seeding_size + self.seeding_info.extend(page_seeding_info) + + # 是否存在下页数据 + next_page = None + 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 + if self.userid not in next_page: + next_page = f'{next_page}&userid={self.userid}&type=seeding' + + return next_page + + def _parse_user_detail_info(self, html_text: str): + """ + 解析用户额外信息,加入时间,等级 + :param html_text: + :return: + """ + html = etree.HTML(html_text) + if not html: + return + + self._get_user_level(html) + + self._fixup_traffic_info(html) + + # 加入日期 + join_at_text = html.xpath( + '//tr/td[text()="加入日期" or text()="注册日期" or *[text()="加入日期"]]/following-sibling::td[1]//text()' + '|//div/b[text()="加入日期"]/../text()') + if join_at_text: + self.join_at = StringUtils.unify_datetime_str(join_at_text[0].split(' (')[0].strip()) + + # 做种体积 & 做种数 + # seeding 页面获取不到的话,此处再获取一次 + seeding_sizes = html.xpath('//tr/td[text()="当前上传"]/following-sibling::td[1]//' + 'table[tr[1][td[4 and text()="尺寸"]]]//tr[position()>1]/td[4]') + seeding_seeders = html.xpath('//tr/td[text()="当前上传"]/following-sibling::td[1]//' + 'table[tr[1][td[5 and text()="做种者"]]]//tr[position()>1]/td[5]//text()') + tmp_seeding = len(seeding_sizes) + tmp_seeding_size = 0 + tmp_seeding_info = [] + for i in range(0, len(seeding_sizes)): + size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip()) + seeders = StringUtils.str_int(seeding_seeders[i]) + + tmp_seeding_size += size + tmp_seeding_info.append([seeders, size]) + + if not self.seeding_size: + self.seeding_size = tmp_seeding_size + if not self.seeding: + self.seeding = tmp_seeding + if not self.seeding_info: + self.seeding_info = tmp_seeding_info + + seeding_sizes = html.xpath('//tr/td[text()="做种统计"]/following-sibling::td[1]//text()') + if seeding_sizes: + seeding_match = re.search(r"总做种数:\s+(\d+)", seeding_sizes[0], re.IGNORECASE) + seeding_size_match = re.search(r"总做种体积:\s+([\d,.\s]+[KMGTPI]*B)", seeding_sizes[0], re.IGNORECASE) + tmp_seeding = StringUtils.str_int(seeding_match.group(1)) if ( + seeding_match and seeding_match.group(1)) else 0 + tmp_seeding_size = StringUtils.num_filesize( + seeding_size_match.group(1).strip()) if seeding_size_match else 0 + if not self.seeding_size: + self.seeding_size = tmp_seeding_size + if not self.seeding: + self.seeding = tmp_seeding + + self._fixup_torrent_seeding_page(html) + + def _fixup_torrent_seeding_page(self, html): + """ + 修正种子页面链接 + :param html: + :return: + """ + # 单独的种子页面 + seeding_url_text = html.xpath('//a[contains(@href,"getusertorrentlist.php") ' + 'and contains(@href,"seeding")]/@href') + if seeding_url_text: + self._torrent_seeding_page = seeding_url_text[0].strip() + # 从JS调用种获取用户ID + seeding_url_text = html.xpath('//a[contains(@href, "javascript: getusertorrentlistajax") ' + 'and contains(@href,"seeding")]/@href') + csrf_text = html.xpath('//meta[@name="x-csrf"]/@content') + if not self._torrent_seeding_page and seeding_url_text: + user_js = re.search(r"javascript: getusertorrentlistajax\(\s*'(\d+)", seeding_url_text[0]) + if user_js and user_js.group(1).strip(): + self.userid = user_js.group(1).strip() + self._torrent_seeding_page = f"getusertorrentlistajax.php?userid={self.userid}&type=seeding" + elif seeding_url_text and csrf_text: + if csrf_text[0].strip(): + self._torrent_seeding_page \ + = f"ajax_getusertorrentlist.php" + self._torrent_seeding_params = {'userid': self.userid, 'type': 'seeding', 'csrf': csrf_text[0].strip()} + + # 分类做种模式 + # 临时屏蔽 + # seeding_url_text = html.xpath('//tr/td[text()="当前做种"]/following-sibling::td[1]' + # '/table//td/a[contains(@href,"seeding")]/@href') + # if seeding_url_text: + # self._torrent_seeding_page = seeding_url_text + + def _get_user_level(self, html): + # 等级 获取同一行等级数据,图片格式等级,取title信息,否则取文本信息 + user_levels_text = html.xpath('//tr/td[text()="等級" or text()="等级" or *[text()="等级"]]/' + 'following-sibling::td[1]/img[1]/@title') + if user_levels_text: + self.user_level = user_levels_text[0].strip() + return + + user_levels_text = html.xpath('//tr/td[text()="等級" or text()="等级"]/' + 'following-sibling::td[1 and not(img)]' + '|//tr/td[text()="等級" or text()="等级"]/' + 'following-sibling::td[1 and img[not(@title)]]') + if user_levels_text: + self.user_level = user_levels_text[0].xpath("string(.)").strip() + return + + user_levels_text = html.xpath('//tr/td[text()="等級" or text()="等级"]/' + 'following-sibling::td[1]') + if user_levels_text: + self.user_level = user_levels_text[0].xpath("string(.)").strip() + return + + # 适配PTT用户等级 + user_levels_text = html.xpath('//tr/td[text()="用户等级"]/following-sibling::td[1]/b/@title') + if user_levels_text: + self.user_level = user_levels_text[0].strip() + return + + user_levels_text = html.xpath('//a[contains(@href, "userdetails")]/text()') + if not self.user_level and user_levels_text: + for user_level_text in user_levels_text: + user_level_match = re.search(r"\[(.*)]", user_level_text) + if user_level_match and user_level_match.group(1).strip(): + self.user_level = user_level_match.group(1).strip() + break + + def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: + html = etree.HTML(html_text) + if not html: + return None + + message_links = html.xpath('//tr[not(./td/img[@alt="Read"])]/td/a[contains(@href, "viewmessage")]/@href') + msg_links.extend(message_links) + # 是否存在下页数据 + next_page = None + next_page_text = html.xpath('//a[contains(.//text(), "下一页") or contains(.//text(), "下一頁")]/@href') + if next_page_text: + next_page = next_page_text[-1].strip() + + return next_page + + def _parse_message_content(self, html_text): + html = etree.HTML(html_text) + if not html: + return None, None, None + # 标题 + message_head_text = None + message_head = html.xpath('//h1/text()' + '|//div[@class="layui-card-header"]/span[1]/text()') + if message_head: + message_head_text = message_head[-1].strip() + + # 消息时间 + message_date_text = None + message_date = html.xpath('//h1/following-sibling::table[.//tr/td[@class="colhead"]]//tr[2]/td[2]' + '|//div[@class="layui-card-header"]/span[2]/span[2]') + if message_date: + message_date_text = message_date[0].xpath("string(.)").strip() + + # 消息内容 + message_content_text = None + message_content = html.xpath('//h1/following-sibling::table[.//tr/td[@class="colhead"]]//tr[3]/td' + '|//div[contains(@class,"layui-card-body")]') + if message_content: + message_content_text = message_content[0].xpath("string(.)").strip() + + return message_head_text, message_date_text, message_content_text + + def _fixup_traffic_info(self, html): + # fixup bonus + if not self.bonus: + bonus_text = html.xpath('//tr/td[text()="魔力值" or text()="猫粮"]/following-sibling::td[1]/text()') + if bonus_text: + self.bonus = StringUtils.str_float(bonus_text[0].strip()) diff --git a/plugins.v2/sitestatistic/siteuserinfo/nexus_project.py b/plugins.v2/sitestatistic/siteuserinfo/nexus_project.py new file mode 100644 index 0000000..d64c59d --- /dev/null +++ b/plugins.v2/sitestatistic/siteuserinfo/nexus_project.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +import re + +from app.plugins.sitestatistic.siteuserinfo import SITE_BASE_ORDER, SiteSchema +from app.plugins.sitestatistic.siteuserinfo.nexus_php import NexusPhpSiteUserInfo + + +class NexusProjectSiteUserInfo(NexusPhpSiteUserInfo): + schema = SiteSchema.NexusProject + order = SITE_BASE_ORDER + 25 + + @classmethod + def match(cls, html_text: str) -> bool: + return 'Nexus Project' in html_text + + def _parse_site_page(self, html_text: str): + html_text = self._prepare_html_text(html_text) + + user_detail = re.search(r"userdetails.php\?id=(\d+)", html_text) + if user_detail and user_detail.group().strip(): + self._user_detail_page = user_detail.group().strip().lstrip('/') + self.userid = user_detail.group(1) + + self._torrent_seeding_page = f"viewusertorrents.php?id={self.userid}&show=seeding" diff --git a/plugins.v2/sitestatistic/siteuserinfo/nexus_rabbit.py b/plugins.v2/sitestatistic/siteuserinfo/nexus_rabbit.py new file mode 100644 index 0000000..08c4c52 --- /dev/null +++ b/plugins.v2/sitestatistic/siteuserinfo/nexus_rabbit.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +import json +from typing import Optional + +from lxml import etree + +from app.log import logger +from app.plugins.sitestatistic.siteuserinfo import SITE_BASE_ORDER, SiteSchema +from app.plugins.sitestatistic.siteuserinfo.nexus_php import NexusPhpSiteUserInfo + + +class NexusRabbitSiteUserInfo(NexusPhpSiteUserInfo): + schema = SiteSchema.NexusRabbit + order = SITE_BASE_ORDER + 5 + + @classmethod + def match(cls, html_text: str) -> bool: + html = etree.HTML(html_text) + if not html: + return False + + printable_text = html.xpath("string(.)") if html else "" + return 'Style by Rabbit' in printable_text + + def _parse_site_page(self, html_text: str): + super()._parse_site_page(html_text) + self._torrent_seeding_page = f"getusertorrentlistajax.php?page=1&limit=5000000&type=seeding&uid={self.userid}" + self._torrent_seeding_headers = {"Accept": "application/json, text/javascript, */*; q=0.01"} + + def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]: + """ + 做种相关信息 + :param html_text: + :param multi_page: 是否多页数据 + :return: 下页地址 + """ + + try: + torrents = json.loads(html_text).get('data') + except Exception as e: + logger.error(f"解析做种信息失败: {str(e)}") + return + + page_seeding_size = 0 + page_seeding_info = [] + + page_seeding = len(torrents) + for torrent in torrents: + seeders = int(torrent.get('seeders', 0)) + size = int(torrent.get('size', 0)) + page_seeding_size += int(torrent.get('size', 0)) + + page_seeding_info.append([seeders, size]) + + self.seeding += page_seeding + self.seeding_size += page_seeding_size + self.seeding_info.extend(page_seeding_info) diff --git a/plugins.v2/sitestatistic/siteuserinfo/small_horse.py b/plugins.v2/sitestatistic/siteuserinfo/small_horse.py new file mode 100644 index 0000000..d704a28 --- /dev/null +++ b/plugins.v2/sitestatistic/siteuserinfo/small_horse.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +import re +from typing import Optional + +from lxml import etree + +from app.plugins.sitestatistic.siteuserinfo import ISiteUserInfo, SITE_BASE_ORDER, SiteSchema +from app.utils.string import StringUtils + + +class SmallHorseSiteUserInfo(ISiteUserInfo): + schema = SiteSchema.SmallHorse + order = SITE_BASE_ORDER + 30 + + @classmethod + def match(cls, html_text: str) -> bool: + return 'Small Horse' in html_text + + def _parse_site_page(self, html_text: str): + html_text = self._prepare_html_text(html_text) + + user_detail = re.search(r"user.php\?id=(\d+)", html_text) + if user_detail and user_detail.group().strip(): + self._user_detail_page = user_detail.group().strip().lstrip('/') + self.userid = user_detail.group(1) + self._torrent_seeding_page = f"torrents.php?type=seeding&userid={self.userid}" + self._user_traffic_page = f"user.php?id={self.userid}" + + def _parse_user_base_info(self, html_text: str): + html_text = self._prepare_html_text(html_text) + html = etree.HTML(html_text) + ret = html.xpath('//a[contains(@href, "user.php")]//text()') + if ret: + self.username = str(ret[0]) + + def _parse_user_traffic_info(self, html_text: str): + """ + 上传/下载/分享率 [做种数/魔力值] + :param html_text: + :return: + """ + html_text = self._prepare_html_text(html_text) + html = etree.HTML(html_text) + tmps = html.xpath('//ul[@class = "stats nobullet"]') + if tmps: + if tmps[1].xpath("li") and tmps[1].xpath("li")[0].xpath("span//text()"): + self.join_at = StringUtils.unify_datetime_str(tmps[1].xpath("li")[0].xpath("span//text()")[0]) + self.upload = StringUtils.num_filesize(str(tmps[1].xpath("li")[2].xpath("text()")[0]).split(":")[1].strip()) + self.download = StringUtils.num_filesize( + str(tmps[1].xpath("li")[3].xpath("text()")[0]).split(":")[1].strip()) + if tmps[1].xpath("li")[4].xpath("span//text()"): + self.ratio = StringUtils.str_float(str(tmps[1].xpath("li")[4].xpath("span//text()")[0]).replace('∞', '0')) + else: + self.ratio = StringUtils.str_float(str(tmps[1].xpath("li")[5].xpath("text()")[0]).split(":")[1]) + self.bonus = StringUtils.str_float(str(tmps[1].xpath("li")[5].xpath("text()")[0]).split(":")[1]) + self.user_level = str(tmps[3].xpath("li")[0].xpath("text()")[0]).split(":")[1].strip() + self.leeching = StringUtils.str_int( + (tmps[4].xpath("li")[6].xpath("text()")[0]).split(":")[1].replace("[", "")) + + def _parse_user_detail_info(self, html_text: str): + pass + + def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]: + """ + 做种相关信息 + :param html_text: + :param multi_page: 是否多页数据 + :return: 下页地址 + """ + html = etree.HTML(html_text) + if not html: + return None + + size_col = 6 + seeders_col = 8 + + page_seeding = 0 + page_seeding_size = 0 + page_seeding_info = [] + seeding_sizes = html.xpath(f'//table[@id="torrent_table"]//tr[position()>1]/td[{size_col}]') + seeding_seeders = html.xpath(f'//table[@id="torrent_table"]//tr[position()>1]/td[{seeders_col}]') + if seeding_sizes and seeding_seeders: + page_seeding = len(seeding_sizes) + + for i in range(0, len(seeding_sizes)): + size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip()) + seeders = StringUtils.str_int(seeding_seeders[i].xpath("string(.)").strip()) + + page_seeding_size += size + page_seeding_info.append([seeders, size]) + + self.seeding += page_seeding + self.seeding_size += page_seeding_size + self.seeding_info.extend(page_seeding_info) + + # 是否存在下页数据 + next_page = None + next_pages = html.xpath('//ul[@class="pagination"]/li[contains(@class,"active")]/following-sibling::li') + if next_pages and len(next_pages) > 1: + page_num = next_pages[0].xpath("string(.)").strip() + if page_num.isdigit(): + next_page = f"{self._torrent_seeding_page}&page={page_num}" + + return next_page + + def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: + return None + + def _parse_message_content(self, html_text): + return None, None, None diff --git a/plugins.v2/sitestatistic/siteuserinfo/tnode.py b/plugins.v2/sitestatistic/siteuserinfo/tnode.py new file mode 100644 index 0000000..8f7ce7f --- /dev/null +++ b/plugins.v2/sitestatistic/siteuserinfo/tnode.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +import json +import re +from typing import Optional + +from app.plugins.sitestatistic.siteuserinfo import ISiteUserInfo, SITE_BASE_ORDER, SiteSchema +from app.utils.string import StringUtils + + +class TNodeSiteUserInfo(ISiteUserInfo): + schema = SiteSchema.TNode + order = SITE_BASE_ORDER + 60 + + @classmethod + def match(cls, html_text: str) -> bool: + return 'Powered By TNode' in html_text + + def _parse_site_page(self, html_text: str): + html_text = self._prepare_html_text(html_text) + + # + csrf_token = re.search(r'', html_text) + if csrf_token: + self._addition_headers = {'X-CSRF-TOKEN': csrf_token.group(1)} + self._user_detail_page = "api/user/getMainInfo" + self._torrent_seeding_page = "api/user/listTorrentActivity?id=&type=seeding&page=1&size=20000" + + def _parse_logged_in(self, html_text): + """ + 判断是否登录成功, 通过判断是否存在用户信息 + 暂时跳过检测,待后续优化 + :param html_text: + :return: + """ + return True + + def _parse_user_base_info(self, html_text: str): + self.username = self.userid + + def _parse_user_traffic_info(self, html_text: str): + pass + + def _parse_user_detail_info(self, html_text: str): + detail = json.loads(html_text) + if detail.get("status") != 200: + return + + user_info = detail.get("data", {}) + self.userid = user_info.get("id") + self.username = user_info.get("username") + self.user_level = user_info.get("class", {}).get("name") + self.join_at = user_info.get("regTime", 0) + self.join_at = StringUtils.unify_datetime_str(str(self.join_at)) + + self.upload = user_info.get("upload") + self.download = user_info.get("download") + self.ratio = 0 if self.download <= 0 else round(self.upload / self.download, 3) + self.bonus = user_info.get("bonus") + + self.message_unread = user_info.get("unreadAdmin", 0) + user_info.get("unreadInbox", 0) + user_info.get( + "unreadSystem", 0) + pass + + def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]: + """ + 解析用户做种信息 + """ + seeding_info = json.loads(html_text) + if seeding_info.get("status") != 200: + return + + torrents = seeding_info.get("data", {}).get("torrents", []) + + page_seeding_size = 0 + page_seeding_info = [] + for torrent in torrents: + size = torrent.get("size", 0) + seeders = torrent.get("seeding", 0) + + page_seeding_size += size + page_seeding_info.append([seeders, size]) + + self.seeding += len(torrents) + self.seeding_size += page_seeding_size + self.seeding_info.extend(page_seeding_info) + + # 是否存在下页数据 + next_page = None + + return next_page + + def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: + return None + + def _parse_message_content(self, html_text): + """ + 系统信息 api/message/listSystem?page=1&size=20 + 收件箱信息 api/message/listInbox?page=1&size=20 + 管理员信息 api/message/listAdmin?page=1&size=20 + :param html_text: + :return: + """ + return None, None, None diff --git a/plugins.v2/sitestatistic/siteuserinfo/torrent_leech.py b/plugins.v2/sitestatistic/siteuserinfo/torrent_leech.py new file mode 100644 index 0000000..96f973a --- /dev/null +++ b/plugins.v2/sitestatistic/siteuserinfo/torrent_leech.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +import re +from typing import Optional + +from lxml import etree + +from app.plugins.sitestatistic.siteuserinfo import ISiteUserInfo, SITE_BASE_ORDER, SiteSchema +from app.utils.string import StringUtils + + +class TorrentLeechSiteUserInfo(ISiteUserInfo): + schema = SiteSchema.TorrentLeech + order = SITE_BASE_ORDER + 40 + + @classmethod + def match(cls, html_text: str) -> bool: + return 'TorrentLeech' in html_text + + def _parse_site_page(self, html_text: str): + html_text = self._prepare_html_text(html_text) + + user_detail = re.search(r"/profile/([^/]+)/", html_text) + if user_detail and user_detail.group().strip(): + self._user_detail_page = user_detail.group().strip().lstrip('/') + self.userid = user_detail.group(1) + self._user_traffic_page = f"profile/{self.userid}/view" + self._torrent_seeding_page = f"profile/{self.userid}/seeding" + + def _parse_user_base_info(self, html_text: str): + self.username = self.userid + + def _parse_user_traffic_info(self, html_text: str): + """ + 上传/下载/分享率 [做种数/魔力值] + :param html_text: + :return: + """ + html_text = self._prepare_html_text(html_text) + html = etree.HTML(html_text) + upload_html = html.xpath('//div[contains(@class,"profile-uploaded")]//span/text()') + if upload_html: + self.upload = StringUtils.num_filesize(upload_html[0]) + download_html = html.xpath('//div[contains(@class,"profile-downloaded")]//span/text()') + if download_html: + self.download = StringUtils.num_filesize(download_html[0]) + ratio_html = html.xpath('//div[contains(@class,"profile-ratio")]//span/text()') + if ratio_html: + self.ratio = StringUtils.str_float(ratio_html[0].replace('∞', '0')) + + user_level_html = html.xpath('//table[contains(@class, "profileViewTable")]' + '//tr/td[text()="Class"]/following-sibling::td/text()') + if user_level_html: + self.user_level = user_level_html[0].strip() + + join_at_html = html.xpath('//table[contains(@class, "profileViewTable")]' + '//tr/td[text()="Registration date"]/following-sibling::td/text()') + if join_at_html: + self.join_at = StringUtils.unify_datetime_str(join_at_html[0].strip()) + + bonus_html = html.xpath('//span[contains(@class, "total-TL-points")]/text()') + if bonus_html: + self.bonus = StringUtils.str_float(bonus_html[0].strip()) + + def _parse_user_detail_info(self, html_text: str): + pass + + def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]: + """ + 做种相关信息 + :param html_text: + :param multi_page: 是否多页数据 + :return: 下页地址 + """ + html = etree.HTML(html_text) + if not html: + return None + + size_col = 2 + seeders_col = 7 + + page_seeding = 0 + page_seeding_size = 0 + page_seeding_info = [] + seeding_sizes = html.xpath(f'//tbody/tr/td[{size_col}]') + seeding_seeders = html.xpath(f'//tbody/tr/td[{seeders_col}]/text()') + if seeding_sizes and seeding_seeders: + page_seeding = len(seeding_sizes) + + for i in range(0, len(seeding_sizes)): + size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip()) + seeders = StringUtils.str_int(seeding_seeders[i]) + + page_seeding_size += size + page_seeding_info.append([seeders, size]) + + self.seeding += page_seeding + self.seeding_size += page_seeding_size + self.seeding_info.extend(page_seeding_info) + + # 是否存在下页数据 + next_page = None + + return next_page + + def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: + return None + + def _parse_message_content(self, html_text): + return None, None, None diff --git a/plugins.v2/sitestatistic/siteuserinfo/unit3d.py b/plugins.v2/sitestatistic/siteuserinfo/unit3d.py new file mode 100644 index 0000000..a40483e --- /dev/null +++ b/plugins.v2/sitestatistic/siteuserinfo/unit3d.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +import re +from typing import Optional + +from lxml import etree + +from app.plugins.sitestatistic.siteuserinfo import ISiteUserInfo, SITE_BASE_ORDER, SiteSchema +from app.utils.string import StringUtils + + +class Unit3dSiteUserInfo(ISiteUserInfo): + schema = SiteSchema.Unit3d + order = SITE_BASE_ORDER + 15 + + @classmethod + def match(cls, html_text: str) -> bool: + return "unit3d.js" in html_text + + def _parse_user_base_info(self, html_text: str): + html_text = self._prepare_html_text(html_text) + html = etree.HTML(html_text) + + tmps = html.xpath('//a[contains(@href, "/users/") and contains(@href, "settings")]/@href') + if tmps: + user_name_match = re.search(r"/users/(.+)/settings", tmps[0]) + if user_name_match and user_name_match.group().strip(): + self.username = user_name_match.group(1) + self._torrent_seeding_page = f"/users/{self.username}/active?perPage=100&client=&seeding=include" + self._user_detail_page = f"/users/{self.username}" + + tmps = html.xpath('//a[contains(@href, "bonus/earnings")]') + if tmps: + bonus_text = tmps[0].xpath("string(.)") + bonus_match = re.search(r"([\d,.]+)", bonus_text) + if bonus_match and bonus_match.group(1).strip(): + self.bonus = StringUtils.str_float(bonus_match.group(1)) + + def _parse_site_page(self, html_text: str): + # TODO + pass + + def _parse_user_detail_info(self, html_text: str): + """ + 解析用户额外信息,加入时间,等级 + :param html_text: + :return: + """ + html = etree.HTML(html_text) + if not html: + return None + + # 用户等级 + user_levels_text = html.xpath('//div[contains(@class, "content")]//span[contains(@class, "badge-user")]/text()') + if user_levels_text: + self.user_level = user_levels_text[0].strip() + + # 加入日期 + join_at_text = html.xpath('//div[contains(@class, "content")]//h4[contains(text(), "注册日期") ' + 'or contains(text(), "註冊日期") ' + 'or contains(text(), "Registration date")]/text()') + if join_at_text: + self.join_at = StringUtils.unify_datetime_str( + join_at_text[0].replace('注册日期', '').replace('註冊日期', '').replace('Registration date', '')) + + def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]: + """ + 做种相关信息 + :param html_text: + :param multi_page: 是否多页数据 + :return: 下页地址 + """ + html = etree.HTML(html_text) + if not html: + return None + + size_col = 9 + seeders_col = 2 + # 搜索size列 + if html.xpath('//thead//th[contains(@class,"size")]'): + size_col = len(html.xpath('//thead//th[contains(@class,"size")][1]/preceding-sibling::th')) + 1 + # 搜索seeders列 + if html.xpath('//thead//th[contains(@class,"seeders")]'): + seeders_col = len(html.xpath('//thead//th[contains(@class,"seeders")]/preceding-sibling::th')) + 1 + + page_seeding = 0 + page_seeding_size = 0 + page_seeding_info = [] + seeding_sizes = html.xpath(f'//tr[position()]/td[{size_col}]') + seeding_seeders = html.xpath(f'//tr[position()]/td[{seeders_col}]') + if seeding_sizes and seeding_seeders: + page_seeding = len(seeding_sizes) + + for i in range(0, len(seeding_sizes)): + size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip()) + seeders = StringUtils.str_int(seeding_seeders[i].xpath("string(.)").strip()) + + page_seeding_size += size + page_seeding_info.append([seeders, size]) + + self.seeding += page_seeding + self.seeding_size += page_seeding_size + self.seeding_info.extend(page_seeding_info) + + # 是否存在下页数据 + next_page = None + next_pages = html.xpath('//ul[@class="pagination"]/li[contains(@class,"active")]/following-sibling::li') + if next_pages and len(next_pages) > 1: + page_num = next_pages[0].xpath("string(.)").strip() + if page_num.isdigit(): + next_page = f"{self._torrent_seeding_page}&page={page_num}" + + return next_page + + def _parse_user_traffic_info(self, html_text: str): + html_text = self._prepare_html_text(html_text) + upload_match = re.search(r"[^总]上[传傳]量?[::_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+[KMGTPI]*B)", html_text, + re.IGNORECASE) + self.upload = StringUtils.num_filesize(upload_match.group(1).strip()) if upload_match else 0 + download_match = re.search(r"[^总子影力]下[载載]量?[::_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+[KMGTPI]*B)", html_text, + re.IGNORECASE) + self.download = StringUtils.num_filesize(download_match.group(1).strip()) if download_match else 0 + ratio_match = re.search(r"分享率[::_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+)", html_text) + self.ratio = StringUtils.str_float(ratio_match.group(1)) if ( + ratio_match and ratio_match.group(1).strip()) else 0.0 + + def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: + return None + + def _parse_message_content(self, html_text): + return None, None, None diff --git a/plugins.v2/sitestatistic/siteuserinfo/yema.py b/plugins.v2/sitestatistic/siteuserinfo/yema.py new file mode 100644 index 0000000..44a23d7 --- /dev/null +++ b/plugins.v2/sitestatistic/siteuserinfo/yema.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +import json +from typing import Optional, Tuple +from urllib.parse import urljoin + +from app.log import logger +from app.plugins.sitestatistic.siteuserinfo import ISiteUserInfo, SITE_BASE_ORDER, SiteSchema +from app.utils.string import StringUtils + + +class TYemaSiteUserInfo(ISiteUserInfo): + schema = SiteSchema.Yema + order = SITE_BASE_ORDER + 60 + + @classmethod + def match(cls, html_text: str) -> bool: + return 'YemaPT' in html_text + + def _parse_site_page(self, html_text: str): + """ + 获取站点页面地址 + """ + self._user_traffic_page = None + self._user_detail_page = None + self._user_basic_page = "api/consumer/fetchSelfDetail" + self._user_basic_params = {} + self._sys_mail_unread_page = None + self._user_mail_unread_page = None + self._mail_unread_params = {} + self._torrent_seeding_page = "/api/userTorrent/fetchSeedTorrentInfo" + self._torrent_seeding_params = { + # 虽然这个参数是无意义的,但这个 API 必须用 POST + "status": "seeding" + } + self._torrent_seeding_headers = {} + self._addition_headers = { + "Content-Type": "application/json", + "Accept": "application/json, text/plain, */*", + } + + def _parse_logged_in(self, html_text): + """ + 判断是否登录成功, 通过判断是否存在用户信息 + 暂时跳过检测,待后续优化 + :param html_text: + :return: + """ + return True + + def _parse_user_base_info(self, html_text: str): + """ + 解析用户基本信息,这里把_parse_user_traffic_info和_parse_user_detail_info合并到这里 + """ + if not html_text: + return None + detail = json.loads(html_text) + if not detail or not detail.get("success"): + return + user_info = detail.get("data", {}) + self.userid = user_info.get("id") + self.username = user_info.get("name") + self.user_level = user_info.get("level") + self.join_at = StringUtils.unify_datetime_str(user_info.get("registerTime")) + + self.upload = user_info.get('promotionUploadSize') + self.download = user_info.get('promotionDownloadSize') + self.ratio = round(self.upload / (self.download or 1), 2) + self.bonus = user_info.get("bonus") + self.message_unread = 0 + + def _parse_user_traffic_info(self, html_text: str): + """ + 解析用户流量信息 + """ + pass + + def _parse_user_detail_info(self, html_text: str): + """ + 解析用户详细信息 + """ + pass + + def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]: + """ + 解析用户做种信息 + """ + if not html_text: + return None + seeding_info = json.loads(html_text) + if not seeding_info or not seeding_info.get("success") or not seeding_info.get("data"): + return None + + torrents = seeding_info.get("data") + + self.seeding += torrents.get("num") + self.seeding_size += torrents.get("fileSize") + + # 是否存在下页数据 + next_page = None + + return next_page + + def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: + """ + 解析未读消息链接,这里直接读出详情 + """ + pass + + def _parse_message_content(self, html_text) -> Tuple[Optional[str], Optional[str], Optional[str]]: + """ + 解析消息内容 + """ + pass From c8ae39b8497b50e1b9aabb5f05bc473684ba7759 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 14 Sep 2024 12:06:49 +0800 Subject: [PATCH 051/218] =?UTF-8?q?fix=20=E7=99=BB=E5=BD=95=E5=A3=81?= =?UTF-8?q?=E7=BA=B8=E6=9C=AC=E5=9C=B0=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 ++- plugins/tmdbwallpaper/__init__.py | 36 ++++++++++++++++++------------- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index 16ac916..6d44ffc 100644 --- a/package.json +++ b/package.json @@ -778,12 +778,13 @@ "name": "登录壁纸本地化", "description": "将MoviePilot的登录壁纸下载到本地。", "labels": "工具", - "version": "1.1", + "version": "1.2", "icon": "Macos_Sierra.png", "author": "jxxghp", "level": 1, "v2": true, "history": { + "v1.2": "一次性下载多张壁纸", "v1.1": "修复下载Bing每日壁纸时文件名错乱的问题" } }, diff --git a/plugins/tmdbwallpaper/__init__.py b/plugins/tmdbwallpaper/__init__.py index 273ae23..86f3e06 100644 --- a/plugins/tmdbwallpaper/__init__.py +++ b/plugins/tmdbwallpaper/__init__.py @@ -21,7 +21,7 @@ class TmdbWallpaper(_PluginBase): # 插件图标 plugin_icon = "Macos_Sierra.png" # 插件版本 - plugin_version = "1.1" + plugin_version = "1.2" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -220,24 +220,30 @@ class TmdbWallpaper(_PluginBase): """ 下载MoviePilot的登录壁纸到本地 """ - if not self._savepath: - return - if settings.WALLPAPER == "tmdb": - url = TmdbChain().get_random_wallpager() - filename = url.split("/")[-1] - else: - url = WebUtils.get_bing_wallpaper() - filename = f"{datetime.now().strftime('%Y%m%d')}.jpg" - # 下载壁纸 - if url: + + def __save_file(_url: str, _filename: str): + """ + 保存文件 + """ try: savepath = Path(self._savepath) - logger.info(f"下载壁纸:{url}") - r = RequestUtils().get_res(url) + logger.info(f"下载壁纸:{_url}") + r = RequestUtils().get_res(_url) if r and r.status_code == 200: - with open(savepath / filename, "wb") as f: + with open(savepath / _filename, "wb") as f: f.write(r.content) except Exception as e: logger.error(f"下载壁纸失败:{str(e)}") + + if not self._savepath: + return + if settings.WALLPAPER == "tmdb": + urls = TmdbChain().get_trending_wallpapers() or [] + for url in urls: + filename = url.split("/")[-1] + __save_file(url, filename) else: - logger.error(f"获取壁纸地址失败") + url = WebUtils.get_bing_wallpaper() + if url: + filename = f"{datetime.now().strftime('%Y%m%d')}.jpg" + __save_file(url, filename) From 058c038c68fdf9acea05df58b30618709f2a80c3 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sat, 17 Aug 2024 02:17:35 +0800 Subject: [PATCH 052/218] =?UTF-8?q?fix(BrushFlow):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=8C=B9=E9=85=8D=E8=AE=A2=E9=98=85=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/brushflow/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugins/brushflow/__init__.py b/plugins/brushflow/__init__.py index e3d45b6..0df145b 100644 --- a/plugins/brushflow/__init__.py +++ b/plugins/brushflow/__init__.py @@ -3755,7 +3755,8 @@ class BrushFlow(_PluginBase): doubanid=subscribe.doubanid, cache=True) if mediainfo: - logger.info(f"subscribe {subscribe.name} {mediainfo.to_dict()}") + logger.info(f"订阅 {subscribe.name} 已识别到媒体信息") + logger.debug(f"subscribe {subscribe.name} {mediainfo.to_dict()}") subscribe_titles.extend(mediainfo.names) subscribe_titles = [title.strip() for title in subscribe_titles if title and title.strip()] self._subscribe_infos[subscribe_key] = subscribe_titles @@ -3769,7 +3770,7 @@ class BrushFlow(_PluginBase): for key in set(self._subscribe_infos) - current_keys: del self._subscribe_infos[key] - logger.info("已订阅标题匹配完成") + logger.info("订阅标题匹配完成") logger.debug(f"当前订阅的标题集合为:{self._subscribe_infos}") unique_titles = {title for titles in self._subscribe_infos.values() for title in titles} return unique_titles From a6e21e11960248b2781e41780f6b494d163f3c2f Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sun, 15 Sep 2024 19:14:23 +0800 Subject: [PATCH 053/218] feat(BrushFlow): add auto_archive_days --- plugins/brushflow/__init__.py | 72 +++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 3 deletions(-) diff --git a/plugins/brushflow/__init__.py b/plugins/brushflow/__init__.py index 0df145b..f56735c 100644 --- a/plugins/brushflow/__init__.py +++ b/plugins/brushflow/__init__.py @@ -64,6 +64,7 @@ class BrushConfig: self.delete_size_range = config.get("delete_size_range") self.up_speed = self.__parse_number(config.get("up_speed")) self.dl_speed = self.__parse_number(config.get("dl_speed")) + self.auto_archive_days = self.__parse_number(config.get("auto_archive_days")) self.save_path = config.get("save_path") self.clear_task = config.get("clear_task", False) self.archive_task = config.get("archive_task", False) @@ -1131,6 +1132,25 @@ class BrushFlow(_PluginBase): } } ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'auto_archive_days', + 'label': '自动归档记录天数', + 'placeholder': '超过此天数后自动归档', + 'type': 'number', + "min": "0" + } + } + ] } ] } @@ -2391,6 +2411,10 @@ class BrushFlow(_PluginBase): if downloader.delete_torrents(ids=need_delete_hashes, delete_file=True): for torrent_hash in need_delete_hashes: torrent_tasks[torrent_hash]["deleted"] = True + torrent_tasks[torrent_hash]["deleted_time"] = time.time() + + # 归档数据 + self.__auto_archive_tasks(torrent_tasks=torrent_tasks) self.__update_and_save_statistic_info(torrent_tasks) @@ -2818,6 +2842,7 @@ class BrushFlow(_PluginBase): torrent_task = torrent_tasks[hash_value] # 标记为已删除 torrent_task["deleted"] = True + torrent_task["deleted_time"] = time.time() # 处理日志相关内容 delete_tasks.append(torrent_task) site_name = torrent_task.get("site_name", "") @@ -2943,7 +2968,8 @@ class BrushFlow(_PluginBase): "seed_avgspeed": "平均上传速度", "seed_inactivetime": "未活动时间", "up_speed": "单任务上传限速", - "dl_speed": "单任务下载限速" + "dl_speed": "单任务下载限速", + "auto_archive_days": "自动清理记录天数" } config_range_number_attr_to_desc = { @@ -3015,6 +3041,7 @@ class BrushFlow(_PluginBase): "delete_size_range": brush_config.delete_size_range, "up_speed": brush_config.up_speed, "dl_speed": brush_config.dl_speed, + "auto_archive_days": brush_config.auto_archive_days, "save_path": brush_config.save_path, "clear_task": brush_config.clear_task, "archive_task": brush_config.archive_task, @@ -3838,6 +3865,45 @@ class BrushFlow(_PluginBase): """ return sum(task.get("size", 0) for task in torrent_tasks.values() if not task.get("deleted", False)) + def __auto_archive_tasks(self, torrent_tasks: Dict[str, dict]) -> None: + """ + 自动归档已经删除的种子数据 + """ + if not self._brush_config.auto_archive_days or self._brush_config.auto_archive_days <= 0: + logger.info("自动归档记录天数小于等于0,取消自动归档") + return + + # 用于存储已删除的数据 + archived_tasks: Dict[str, dict] = self.get_data("archived") or {} + + current_time = time.time() + archive_threshold_seconds = self._brush_config.auto_archive_days * 86400 # 将天数转换为秒数 + + # 准备一个列表,记录所有需要从原始数据中删除的键 + keys_to_delete = set() + + # 遍历所有 torrent 条目 + for key, value in torrent_tasks.items(): + deleted_time = value.get("deleted_time") + # 场景 1: 检查任务是否已被标记为删除且超出保留天数 + if (value.get("deleted") and isinstance(deleted_time, (int, float)) and + current_time - deleted_time > archive_threshold_seconds): + keys_to_delete.add(key) + archived_tasks[key] = value + continue + + # 场景 2: 检查没有明确删除时间的历史数据 + if value.get("deleted") and deleted_time is None: + keys_to_delete.add(key) + archived_tasks[key] = value + continue + + # 从原始字典中移除已删除的条目 + for key in keys_to_delete: + del torrent_tasks[key] + + self.save_data("archived", archived_tasks) + def __archive_tasks(self): """ 归档已经删除的种子数据 @@ -3848,7 +3914,7 @@ class BrushFlow(_PluginBase): archived_tasks: Dict[str, dict] = self.get_data("archived") or {} # 准备一个列表,记录所有需要从原始数据中删除的键 - keys_to_delete = [] + keys_to_delete = set() # 遍历所有 torrent 条目 for key, value in torrent_tasks.items(): @@ -3857,7 +3923,7 @@ class BrushFlow(_PluginBase): # 如果是,加入到归档字典中 archived_tasks[key] = value # 记录键,稍后删除 - keys_to_delete.append(key) + keys_to_delete.add(key) # 从原始字典中移除已删除的条目 for key in keys_to_delete: From abfa272f2ec5ea0a551bd572e6f770c1a394e129 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sun, 15 Sep 2024 19:15:22 +0800 Subject: [PATCH 054/218] feat(BrushFlow): v3.8 --- package.json | 3 ++- plugins/brushflow/__init__.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 6d44ffc..edef083 100644 --- a/package.json +++ b/package.json @@ -399,12 +399,13 @@ "name": "站点刷流", "description": "自动托管刷流,将会提高对应站点的访问频率。", "labels": "刷流,仪表板", - "version": "3.7", + "version": "3.8", "icon": "brush.jpg", "author": "jxxghp,InfinityPacer", "level": 2, "v2": true, "history": { + "v3.8": "添加自动归档记录天数配置项,支持定时归档已删除数据", "v3.7": "下载数量调整为仅获取刷流标签种子并修复了一些细节问题", "v3.6": "优化检查服务中的时间管控", "v3.5": "移除「删种排除MoviePilot任务」配置项(请使用「删除排除标签」替代),完善刷流任务触发插件事件相关逻辑(联动H&R助手)", diff --git a/plugins/brushflow/__init__.py b/plugins/brushflow/__init__.py index f56735c..faa6c05 100644 --- a/plugins/brushflow/__init__.py +++ b/plugins/brushflow/__init__.py @@ -258,7 +258,7 @@ class BrushFlow(_PluginBase): # 插件图标 plugin_icon = "brush.jpg" # 插件版本 - plugin_version = "3.7" + plugin_version = "3.8" # 插件作者 plugin_author = "jxxghp,InfinityPacer" # 作者主页 From 32accf7b55dab130da2d0235b555f12176f2e97b Mon Sep 17 00:00:00 2001 From: jxxghp Date: Thu, 26 Sep 2024 17:22:39 +0800 Subject: [PATCH 055/218] init v2 plugins --- package.json | 39 +- plugins.v2/autoclean/__init__.py | 603 +++ plugins.v2/bestfilmversion/__init__.py | 708 ++++ plugins.v2/brushflow/__init__.py | 4054 +++++++++++++++++++++ plugins.v2/cleaninvalidseed/__init__.py | 918 +++++ plugins.v2/clouddiskdel/__init__.py | 540 +++ plugins.v2/configcenter/__init__.py | 597 +++ plugins.v2/crossseed/__init__.py | 1232 +++++++ plugins.v2/diagparamadjust/__init__.py | 456 +++ plugins.v2/downloadsitetag/__init__.py | 812 +++++ plugins.v2/episodegroupmeta/__init__.py | 872 +++++ plugins.v2/ffmpegthumb/__init__.py | 360 ++ plugins.v2/ffmpegthumb/ffmpeg_helper.py | 82 + plugins.v2/iyuuautoseed/__init__.py | 1246 +++++++ plugins.v2/iyuuautoseed/iyuu_helper.py | 115 + plugins.v2/libraryscraper/__init__.py | 437 +++ plugins.v2/mediaservermsg/__init__.py | 295 ++ plugins.v2/mediaserverrefresh/__init__.py | 170 + plugins.v2/mediasyncdel/__init__.py | 1589 ++++++++ plugins.v2/messageforward/__init__.py | 430 +++ plugins.v2/personmeta/__init__.py | 1026 ++++++ plugins.v2/qbcommand/__init__.py | 1171 ++++++ plugins.v2/rsssubscribe/__init__.py | 775 ++++ plugins.v2/speedlimiter/__init__.py | 660 ++++ plugins.v2/syncdownloadfiles/__init__.py | 579 +++ plugins.v2/torrentremover/__init__.py | 816 +++++ plugins.v2/torrenttransfer/__init__.py | 932 +++++ plugins.v2/trackereditor/__init__.py | 454 +++ plugins.v2/trcommand/__init__.py | 732 ++++ plugins.v2/vcbanimemonitor/__init__.py | 1124 ++++++ plugins.v2/vcbanimemonitor/remeta.py | 284 ++ plugins/removelink/__init__.py | 3 +- 32 files changed, 24079 insertions(+), 32 deletions(-) create mode 100644 plugins.v2/autoclean/__init__.py create mode 100644 plugins.v2/bestfilmversion/__init__.py create mode 100644 plugins.v2/brushflow/__init__.py create mode 100644 plugins.v2/cleaninvalidseed/__init__.py create mode 100644 plugins.v2/clouddiskdel/__init__.py create mode 100644 plugins.v2/configcenter/__init__.py create mode 100644 plugins.v2/crossseed/__init__.py create mode 100644 plugins.v2/diagparamadjust/__init__.py create mode 100644 plugins.v2/downloadsitetag/__init__.py create mode 100644 plugins.v2/episodegroupmeta/__init__.py create mode 100644 plugins.v2/ffmpegthumb/__init__.py create mode 100644 plugins.v2/ffmpegthumb/ffmpeg_helper.py create mode 100644 plugins.v2/iyuuautoseed/__init__.py create mode 100644 plugins.v2/iyuuautoseed/iyuu_helper.py create mode 100644 plugins.v2/libraryscraper/__init__.py create mode 100644 plugins.v2/mediaservermsg/__init__.py create mode 100644 plugins.v2/mediaserverrefresh/__init__.py create mode 100644 plugins.v2/mediasyncdel/__init__.py create mode 100644 plugins.v2/messageforward/__init__.py create mode 100644 plugins.v2/personmeta/__init__.py create mode 100644 plugins.v2/qbcommand/__init__.py create mode 100644 plugins.v2/rsssubscribe/__init__.py create mode 100644 plugins.v2/speedlimiter/__init__.py create mode 100644 plugins.v2/syncdownloadfiles/__init__.py create mode 100644 plugins.v2/torrentremover/__init__.py create mode 100644 plugins.v2/torrenttransfer/__init__.py create mode 100644 plugins.v2/trackereditor/__init__.py create mode 100644 plugins.v2/trcommand/__init__.py create mode 100644 plugins.v2/vcbanimemonitor/__init__.py create mode 100644 plugins.v2/vcbanimemonitor/remeta.py diff --git a/package.json b/package.json index edef083..5534a0a 100644 --- a/package.json +++ b/package.json @@ -137,7 +137,6 @@ "icon": "scraper.png", "author": "jxxghp", "level": 1, - "v2": true, "history": { "v1.5": "修复未获取fanart图片的问题", "v1.4.1": "修复nfo文件读取失败时任务中断问题" @@ -150,8 +149,7 @@ "version": "1.2.2", "icon": "delete.jpg", "author": "jxxghp", - "level": 2, - "v2": true + "level": 2 }, "MediaSyncDel": { "name": "媒体文件同步删除", @@ -161,7 +159,6 @@ "icon": "mediasyncdel.png", "author": "thsrite", "level": 1, - "v2": true, "history": { "v1.7": "修复重新整理被一并删除问题", "v1.6": "修复删除辅种", @@ -190,7 +187,6 @@ "icon": "Librespeed_A.png", "author": "Shurelol", "level": 1, - "v2": true, "history": { "v1.2": "增加不限速路径配置,以应对网盘直链播放的情况" } @@ -218,7 +214,6 @@ "icon": "like.jpg", "author": "wlj", "level": 2, - "v2": true, "history": { "v2.3": "修复定时任务运行问题,Jellyfin的Webhook需要主程序大于1.8.7才能正常订阅。", "v2.2": "修复运行报错问题" @@ -232,7 +227,6 @@ "icon": "mediaplay.png", "author": "jxxghp", "level": 1, - "v2": true, "history": { "v1.3": "兼容处理Emby部分客户端暂停重复推送停止播放webhook的场景", "v1.2": "播放通知增加超链接跳转(需要v1.9.4+)" @@ -245,8 +239,7 @@ "version": "1.2", "icon": "refresh2.png", "author": "jxxghp", - "level": 1, - "v2": true + "level": 1 }, "WebHook": { "name": "Webhook", @@ -306,7 +299,6 @@ "icon": "IYUU.png", "author": "jxxghp", "level": 2, - "v2": true, "history": { "v1.9.5": "Revert qBittorrent跳检之后自动开始", "v1.9.4": "修复qBittorrent辅种后不会自动开始做种", @@ -330,7 +322,6 @@ "icon": "qingwa.png", "author": "233@qingwa", "level": 2, - "v2": true, "history": { "v2.2": "站点停用后会同步暂停对该站点的辅种", "v2.3": "站点辅种支持代理" @@ -344,7 +335,6 @@ "icon": "vcbmonitor.png", "author": "pixel@qingwa", "level": 2, - "v2": true, "history": { "v1.8.2.1": "修复日志输出&同步目录监控插件功能", "v1.8.2": "提高识别率", @@ -361,7 +351,6 @@ "icon": "seed.png", "author": "jxxghp", "level": 2, - "v2": true, "history": { "v1.5": "修复在转移时只保留了第一个tracker,导致红种问题。此修复确保保留所有的tracker,以提高在不同网络条件下的可达性。", "v1.4": "支持自动删除源下载器在目的下载器中存在的种子" @@ -375,7 +364,6 @@ "icon": "rss.png", "author": "jxxghp", "level": 2, - "v2": true, "history": { "v1.5": "支持按种子大小过滤种子", "v1.4": "修复剧集本地是否存在的判断错误问题", @@ -390,7 +378,6 @@ "icon": "Youtube-dl_A.png", "author": "thsrite", "level": 1, - "v2": true, "history": { "v1.1.1": "修复时区问题导致的上次同步后8h内的种子不同步的问题" } @@ -403,7 +390,6 @@ "icon": "brush.jpg", "author": "jxxghp,InfinityPacer", "level": 2, - "v2": true, "history": { "v3.8": "添加自动归档记录天数配置项,支持定时归档已删除数据", "v3.7": "下载数量调整为仅获取刷流标签种子并修复了一些细节问题", @@ -438,8 +424,7 @@ "version": "1.1", "icon": "clean.png", "author": "thsrite", - "level": 2, - "v2": true + "level": 2 }, "InvitesSignin": { "name": "药丸签到", @@ -462,7 +447,6 @@ "icon": "actor.png", "author": "jxxghp", "level": 1, - "v2": true, "history": { "v1.4": "人物图片调整为优先从TMDB获取,避免douban图片CDN加载过慢的问题", "v1.3": "修复v1.8.5版本后刮削报错问题" @@ -489,8 +473,7 @@ "version": "1.3", "icon": "clouddisk.png", "author": "thsrite", - "level": 1, - "v2": true + "level": 1 }, "BarkMsg": { "name": "Bark消息推送", @@ -530,7 +513,6 @@ "icon": "setting.png", "author": "jxxghp", "level": 1, - "v2": true, "history": { "v2.6": "支持DOH相关配置项", "v2.5": "增加Github加速服务器设置项" @@ -553,8 +535,7 @@ "version": "1.1", "icon": "Element_A.png", "author": "叮叮当", - "level": 1, - "v2": true + "level": 1 }, "CustomIndexer": { "name": "自定义索引站点", @@ -573,8 +554,7 @@ "version": "1.2", "icon": "ffmpeg.png", "author": "jxxghp", - "level": 1, - "v2": true + "level": 1 }, "PushPlusMsg": { "name": "PushPlus消息推送", @@ -594,7 +574,6 @@ "icon": "Youtube-dl_B.png", "author": "叮叮当", "level": 1, - "v2": true, "history": { "v2.1": "修复错误的TmdbHelper模块引用" } @@ -684,8 +663,7 @@ "version": "1.3", "icon": "Gatus_A.png", "author": "jeblove", - "level": 1, - "v2": true + "level": 1 }, "QbCommand": { "name": "QB远程操作", @@ -740,7 +718,8 @@ "v1.4": "支持仪表板组件显示", "v1.3": "修复观众做种数据异常问题", "v1.2": "修复契约检查无数据返回的问题" - } + }, + "v2": true }, "FeiShuMsg": { "name": "飞书机器人消息通知", diff --git a/plugins.v2/autoclean/__init__.py b/plugins.v2/autoclean/__init__.py new file mode 100644 index 0000000..d7ba6c5 --- /dev/null +++ b/plugins.v2/autoclean/__init__.py @@ -0,0 +1,603 @@ +import time +from collections import defaultdict +from datetime import datetime, timedelta +from pathlib import Path + +import pytz +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from app.chain.transfer import TransferChain +from app.core.config import settings +from app.core.event import eventmanager +from app.db.downloadhistory_oper import DownloadHistoryOper +from app.db.transferhistory_oper import TransferHistoryOper +from app.plugins import _PluginBase +from typing import Any, List, Dict, Tuple, Optional +from app.log import logger +from app.schemas import NotificationType, DownloadHistory +from app.schemas.types import EventType + + +class AutoClean(_PluginBase): + # 插件名称 + plugin_name = "定时清理媒体库" + # 插件描述 + plugin_desc = "定时清理用户下载的种子、源文件、媒体库文件。" + # 插件图标 + plugin_icon = "clean.png" + # 插件版本 + plugin_version = "1.1" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "autoclean_" + # 加载顺序 + plugin_order = 23 + # 可使用的用户级别 + auth_level = 2 + + # 私有属性 + _enabled = False + # 任务执行间隔 + _cron = None + _type = None + _onlyonce = False + _notify = False + _cleantype = None + _cleandate = None + _cleanuser = None + _downloadhis = None + _transferhis = None + + # 定时器 + _scheduler: Optional[BackgroundScheduler] = None + + def init_plugin(self, config: dict = None): + # 停止现有任务 + self.stop_service() + + if config: + self._enabled = config.get("enabled") + self._cron = config.get("cron") + self._onlyonce = config.get("onlyonce") + self._notify = config.get("notify") + self._cleantype = config.get("cleantype") + self._cleandate = config.get("cleandate") + self._cleanuser = config.get("cleanuser") + + # 加载模块 + if self._enabled: + self._downloadhis = DownloadHistoryOper() + self._transferhis = TransferHistoryOper() + + if self._onlyonce: + # 定时服务 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + logger.info(f"定时清理媒体库服务启动,立即运行一次") + self._scheduler.add_job(func=self.__clean, trigger='date', + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="定时清理媒体库") + # 关闭一次性开关 + self._onlyonce = False + self.update_config({ + "onlyonce": False, + "cron": self._cron, + "cleantype": self._cleantype, + "cleandate": self._cleandate, + "enabled": self._enabled, + "cleanuser": self._cleanuser, + "notify": self._notify, + }) + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def __get_clean_date(self, deltatime: str = None): + # 清理日期 + current_time = datetime.now() + if deltatime: + days_ago = current_time - timedelta(days=int(deltatime)) + else: + days_ago = current_time - timedelta(days=int(self._cleandate)) + return days_ago.strftime("%Y-%m-%d") + + def __clean(self): + """ + 定时清理媒体库 + """ + if not self._cleandate: + logger.error("未配置媒体库全局清理时间,停止运行") + return + + # 查询用户清理日期之前的下载历史,不填默认清理全部用户的下载 + if not self._cleanuser: + clean_date = self.__get_clean_date() + downloadhis_list = self._downloadhis.list_by_user_date(date=clean_date) + logger.info(f'获取到日期 {clean_date} 之前的下载历史 {len(downloadhis_list)} 条') + self.__clean_history(date=clean_date, clean_type=self._cleantype, downloadhis_list=downloadhis_list) + + # 根据填写的信息判断怎么清理 + else: + # username:days#cleantype + clean_type = self._cleantype + clean_date = self._cleandate + + # 1.3.7版本及之前处理多位用户 + if str(self._cleanuser).count(','): + for username in str(self._cleanuser).split(","): + downloadhis_list = self._downloadhis.list_by_user_date(date=clean_date, + username=username) + logger.info( + f'获取到用户 {username} 日期 {clean_date} 之前的下载历史 {len(downloadhis_list)} 条') + self.__clean_history(date=clean_date, clean_type=self._cleantype, downloadhis_list=downloadhis_list) + return + + for userinfo in str(self._cleanuser).split("\n"): + if userinfo.count('#'): + clean_type = userinfo.split('#')[1] + username_and_days = userinfo.split('#')[0] + else: + username_and_days = userinfo + if username_and_days.count(':'): + clean_date = username_and_days.split(':')[1] + username = username_and_days.split(':')[0] + else: + username = userinfo + + # 转strftime + clean_date = self.__get_clean_date(clean_date) + logger.info(f'{username} 使用 {clean_type} 清理方式,清理 {clean_date} 之前的下载历史') + downloadhis_list = self._downloadhis.list_by_user_date(date=clean_date, + username=username) + logger.info( + f'获取到用户 {username} 日期 {clean_date} 之前的下载历史 {len(downloadhis_list)} 条') + self.__clean_history(date=clean_date, clean_type=clean_type, + downloadhis_list=downloadhis_list) + + def __clean_history(self, date: str, clean_type: str, downloadhis_list: List[DownloadHistory]): + """ + 清理下载历史、转移记录 + """ + if not downloadhis_list: + logger.warn(f"未获取到日期 {date} 之前的下载记录,停止运行") + return + + # 读取历史记录 + pulgin_history = self.get_data('history') or [] + + # 创建一个字典来保存分组结果 + downloadhis_grouped_dict: Dict[tuple, List[DownloadHistory]] = defaultdict(list) + # 遍历DownloadHistory对象列表 + for downloadhis in downloadhis_list: + # 获取type和tmdbid的值 + dtype = downloadhis.type + tmdbid = downloadhis.tmdbid + + # 将DownloadHistory对象添加到对应分组的列表中 + downloadhis_grouped_dict[(dtype, tmdbid)].append(downloadhis) + + # 输出分组结果 + for key, downloadhis_list in downloadhis_grouped_dict.items(): + logger.info(f"开始清理 {key}") + del_transferhis_cnt = 0 + del_media_name = downloadhis_list[0].title + del_media_user = downloadhis_list[0].username + del_media_type = downloadhis_list[0].type + del_media_year = downloadhis_list[0].year + del_media_season = downloadhis_list[0].seasons + del_media_episode = downloadhis_list[0].episodes + del_image = downloadhis_list[0].image + for downloadhis in downloadhis_list: + if not downloadhis.download_hash: + logger.debug(f'下载历史 {downloadhis.id} {downloadhis.title} 未获取到download_hash,跳过处理') + continue + # 根据hash获取转移记录 + transferhis_list = self._transferhis.list_by_hash(download_hash=downloadhis.download_hash) + if not transferhis_list: + logger.warn(f"下载历史 {downloadhis.download_hash} 未查询到转移记录,跳过处理") + continue + + for history in transferhis_list: + # 册除媒体库文件 + if clean_type in ["dest", "all"]: + TransferChain().delete_files(Path(history.dest)) + # 删除记录 + self._transferhis.delete(history.id) + # 删除源文件 + if clean_type in ["src", "all"]: + TransferChain().delete_files(Path(history.src)) + # 发送事件 + eventmanager.send_event( + EventType.DownloadFileDeleted, + { + "src": history.src + } + ) + + # 累加删除数量 + del_transferhis_cnt += len(transferhis_list) + + if del_transferhis_cnt: + # 发送消息 + if self._notify: + self.post_message( + mtype=NotificationType.MediaServer, + title="【定时清理媒体库任务完成】", + text=f"清理媒体名称 {del_media_name}\n" + f"下载媒体用户 {del_media_user}\n" + f"删除历史记录 {del_transferhis_cnt}") + + pulgin_history.append({ + "type": del_media_type, + "title": del_media_name, + "year": del_media_year, + "season": del_media_season, + "episode": del_media_episode, + "image": del_image, + "del_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())) + }) + + # 保存历史 + self.save_data("history", pulgin_history) + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + 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 and self._cron: + return [ + { + "id": "AutoClean", + "name": "清理媒体库定时服务", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self.__clean, + "kwargs": {} + } + ] + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '开启通知', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '执行周期', + 'placeholder': '0 0 ? ? ?' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'cleantype', + 'label': '全局清理方式', + 'items': [ + {'title': '媒体库文件', 'value': 'dest'}, + {'title': '源文件', 'value': 'src'}, + {'title': '所有文件', 'value': 'all'}, + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cleandate', + 'label': '全局清理日期', + 'placeholder': '清理多少天之前的下载记录(天)' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'cleanuser', + 'label': '清理配置', + 'rows': 6, + 'placeholder': '每一行一个配置,支持以下几种配置方式,清理方式支持 src、desc、all 分别对应源文件,媒体库文件,所有文件\n' + '用户名缺省默认清理所有用户(慎重留空),清理天数缺省默认使用全局清理天数,清理方式缺省默认使用全局清理方式\n' + '用户名/插件名(豆瓣想看、豆瓣榜单、RSS订阅)\n' + '用户名#清理方式\n' + '用户名:清理天数\n' + '用户名:清理天数#清理方式', + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "onlyonce": False, + "notify": False, + "cleantype": "dest", + "cron": "", + "cleanuser": "", + "cleandate": 30 + } + + def get_page(self) -> List[dict]: + """ + 拼装插件详情页面,需要返回页面配置,同时附带数据 + """ + # 查询同步详情 + historys = self.get_data('history') + if not historys: + return [ + { + 'component': 'div', + 'text': '暂无数据', + 'props': { + 'class': 'text-center', + } + } + ] + # 数据按时间降序排序 + historys = sorted(historys, key=lambda x: x.get('del_time'), reverse=True) + # 拼装页面 + contents = [] + for history in historys: + htype = history.get("type") + title = history.get("title") + year = history.get("year") + season = history.get("season") + episode = history.get("episode") + image = history.get("image") + del_time = history.get("del_time") + + if season: + sub_contents = [ + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'类型:{htype}' + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'标题:{title}' + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'年份:{year}' + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'季:{season}' + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'集:{episode}' + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'时间:{del_time}' + } + ] + else: + sub_contents = [ + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'类型:{htype}' + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'标题:{title}' + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'年份:{year}' + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'时间:{del_time}' + } + ] + + contents.append( + { + 'component': 'VCard', + 'content': [ + { + 'component': 'div', + 'props': { + 'class': 'd-flex justify-space-start flex-nowrap flex-row', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'VImg', + 'props': { + 'src': image, + 'height': 120, + 'width': 80, + 'aspect-ratio': '2/3', + 'class': 'object-cover shadow ring-gray-500', + 'cover': True + } + } + ] + }, + { + 'component': 'div', + 'content': sub_contents + } + ] + } + ] + } + ) + + return [ + { + 'component': 'div', + 'props': { + 'class': 'grid gap-3 grid-info-card', + }, + 'content': contents + } + ] + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) diff --git a/plugins.v2/bestfilmversion/__init__.py b/plugins.v2/bestfilmversion/__init__.py new file mode 100644 index 0000000..ce0b5f8 --- /dev/null +++ b/plugins.v2/bestfilmversion/__init__.py @@ -0,0 +1,708 @@ +from datetime import datetime, timedelta +from functools import reduce +from pathlib import Path +from threading import RLock +from typing import Optional, Any, List, Dict, Tuple +from xml.dom.minidom import parseString + +import pytz +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from requests import Response + +from app.chain.subscribe import SubscribeChain +from app.core.config import settings +from app.core.context import MediaInfo +from app.core.event import eventmanager +from app.log import logger +from app.modules.emby import Emby +from app.modules.jellyfin import Jellyfin +from app.modules.plex import Plex +from app.plugins import _PluginBase +from app.schemas import WebhookEventInfo +from app.schemas.types import MediaType, EventType +from app.utils.http import RequestUtils + +lock = RLock() + + +class BestFilmVersion(_PluginBase): + # 插件名称 + plugin_name = "收藏洗版" + # 插件描述 + plugin_desc = "Jellyfin/Emby/Plex点击收藏电影后,自动订阅洗版。" + # 插件图标 + plugin_icon = "like.jpg" + # 插件版本 + plugin_version = "2.3" + # 插件作者 + plugin_author = "wlj" + # 作者主页 + author_url = "https://github.com/developer-wlj" + # 插件配置项ID前缀 + plugin_config_prefix = "bestfilmversion_" + # 加载顺序 + plugin_order = 13 + # 可使用的用户级别 + auth_level = 2 + + # 私有变量 + _scheduler: Optional[BackgroundScheduler] = None + _cache_path: Optional[Path] = None + subscribechain = None + + # 配置属性 + _enabled: bool = False + _cron: str = "" + _notify: bool = False + _webhook_enabled: bool = False + _only_once: bool = False + + def init_plugin(self, config: dict = None): + self._cache_path = settings.TEMP_PATH / "__best_film_version_cache__" + self.subscribechain = SubscribeChain() + + # 停止现有任务 + self.stop_service() + + # 配置 + if config: + self._enabled = config.get("enabled") + self._cron = config.get("cron") + self._notify = config.get("notify") + self._webhook_enabled = config.get("webhook_enabled") + self._only_once = config.get("only_once") + + if self._only_once: + self._only_once = False + self.update_config({ + "enabled": self._enabled, + "cron": self._cron, + "notify": self._notify, + "webhook_enabled": self._webhook_enabled, + "only_once": self._only_once + }) + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + self._scheduler.add_job(self.sync, 'date', + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="立即运行收藏洗版") + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + """ + 获取插件API + [{ + "path": "/xx", + "endpoint": self.xxx, + "methods": ["GET", "POST"], + "summary": "API说明" + }] + """ + 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 and not self._webhook_enabled: + if self._cron: + return [{ + "id": "BestFilmVersion", + "name": "收藏洗版定时服务", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self.sync, + "kwargs": {} + }] + return [ + { + "id": "BestFilmVersion", + "name": "收藏洗版定时服务", + "trigger": "interval", + "func": self.sync, + "kwargs": { + "minutes": 30 + } + } + ] + return [] + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '发送通知', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'only_once', + 'label': '立即运行一次', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'webhook_enabled', + 'label': 'Webhook', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '执行周期', + 'placeholder': '5位cron表达式,留空自动' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '支持主动定时获取媒体库数据和Webhook实时触发两种方式,两者只能选其一,' + 'Webhook需要在媒体服务器设置发送Webhook报文。' + 'Plex使用主动获取时,建议执行周期设置大于1小时,' + '收藏Api调用Plex官网接口,有频率限制。' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "notify": False, + "cron": "*/30 * * * *", + "webhook_enabled": False, + "only_once": False + } + + def get_page(self) -> List[dict]: + """ + 拼装插件详情页面,需要返回页面配置,同时附带数据 + """ + # 查询同步详情 + historys = self.get_data('history') + if not historys: + return [ + { + 'component': 'div', + 'text': '暂无数据', + 'props': { + 'class': 'text-center', + } + } + ] + # 数据按时间降序排序 + historys = sorted(historys, key=lambda x: x.get('time'), reverse=True) + # 拼装页面 + contents = [] + for history in historys: + title = history.get("title") + poster = history.get("poster") + mtype = history.get("type") + time_str = history.get("time") + tmdbid = history.get("tmdbid") + contents.append( + { + 'component': 'VCard', + 'content': [ + { + 'component': 'div', + 'props': { + 'class': 'd-flex justify-space-start flex-nowrap flex-row', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'VImg', + 'props': { + 'src': poster, + 'height': 120, + 'width': 80, + 'aspect-ratio': '2/3', + 'class': 'object-cover shadow ring-gray-500', + 'cover': True + } + } + ] + }, + { + 'component': 'div', + 'content': [ + { + 'component': 'VCardTitle', + 'props': { + 'class': 'ps-1 pe-5 break-words whitespace-break-spaces' + }, + 'content': [ + { + 'component': 'a', + 'props': { + 'href': f"https://www.themoviedb.org/movie/{tmdbid}", + 'target': '_blank' + }, + 'text': title + } + ] + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'类型:{mtype}' + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'时间:{time_str}' + } + ] + } + ] + } + ] + } + ) + + return [ + { + 'component': 'div', + 'props': { + 'class': 'grid gap-3 grid-info-card', + }, + 'content': contents + } + ] + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) + + def sync(self): + """ + 通过流媒体管理工具收藏,自动洗版 + """ + # 获取锁 + _is_lock: bool = lock.acquire(timeout=60) + if not _is_lock: + return + try: + # 读取缓存 + caches = self._cache_path.read_text().split("\n") if self._cache_path.exists() else [] + # 读取历史记录 + history = self.get_data('history') or [] + + # 媒体服务器类型,多个以,分隔 + if not settings.MEDIASERVER: + return + media_servers = settings.MEDIASERVER.split(',') + + # 读取收藏 + all_items = {} + for media_server in media_servers: + if media_server == 'jellyfin': + all_items['jellyfin'] = self.jellyfin_get_items() + elif media_server == 'emby': + all_items['emby'] = self.emby_get_items() + else: + all_items['plex'] = self.plex_get_watchlist() + + def function(y, x): + return y if (x['Name'] in [i['Name'] for i in y]) else (lambda z, u: (z.append(u), z))(y, x)[1] + + # 处理所有结果 + for server, all_item in all_items.items(): + # all_item 根据电影名去重 + result = reduce(function, all_item, []) + for data in result: + # 检查缓存 + if data.get('Name') in caches: + continue + + # 获取详情 + if server == 'jellyfin': + item_info_resp = Jellyfin().get_iteminfo(itemid=data.get('Id')) + elif server == 'emby': + item_info_resp = Emby().get_iteminfo(itemid=data.get('Id')) + else: + item_info_resp = self.plex_get_iteminfo(itemid=data.get('Id')) + logger.debug(f'BestFilmVersion插件 item打印 {item_info_resp}') + if not item_info_resp: + continue + + # 只接受Movie类型 + if data.get('Type') != 'Movie': + continue + + # 获取tmdb_id + tmdb_id = item_info_resp.get("tmdbid") if server == 'plex' else item_info_resp.tmdbid + if not tmdb_id: + continue + # 识别媒体信息 + mediainfo: MediaInfo = self.chain.recognize_media(tmdbid=tmdb_id, mtype=MediaType.MOVIE) + if not mediainfo: + logger.warn(f'未识别到媒体信息,标题:{data.get("Name")},tmdbid:{tmdb_id}') + continue + # 添加订阅 + self.subscribechain.add(mtype=MediaType.MOVIE, + title=mediainfo.title, + year=mediainfo.year, + tmdbid=mediainfo.tmdb_id, + best_version=True, + username="收藏洗版", + exist_ok=True) + # 加入缓存 + caches.append(data.get('Name')) + # 存储历史记录 + if mediainfo.tmdb_id not in [h.get("tmdbid") for h in history]: + history.append({ + "title": mediainfo.title, + "type": mediainfo.type.value, + "year": mediainfo.year, + "poster": mediainfo.get_poster_image(), + "overview": mediainfo.overview, + "tmdbid": mediainfo.tmdb_id, + "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + }) + # 保存历史记录 + self.save_data('history', history) + # 保存缓存 + self._cache_path.write_text("\n".join(caches)) + finally: + lock.release() + + def jellyfin_get_items(self) -> List[dict]: + # 获取所有user + users_url = "[HOST]Users?&apikey=[APIKEY]" + users = self.get_users(Jellyfin().get_data(users_url)) + if not users: + logger.info(f"bestfilmversion/users_url: {users_url}") + return [] + all_items = [] + for user in users: + # 根据加入日期 降序排序 + url = "[HOST]Users/" + user + "/Items?SortBy=DateCreated%2CSortName" \ + "&SortOrder=Descending" \ + "&Filters=IsFavorite" \ + "&Recursive=true" \ + "&Fields=PrimaryImageAspectRatio%2CBasicSyncInfo" \ + "&CollapseBoxSetItems=false" \ + "&ExcludeLocationTypes=Virtual" \ + "&EnableTotalRecordCount=false" \ + "&Limit=20" \ + "&apikey=[APIKEY]" + resp = self.get_items(Jellyfin().get_data(url)) + if not resp: + continue + all_items.extend(resp) + return all_items + + def emby_get_items(self) -> List[dict]: + # 获取所有user + get_users_url = "[HOST]Users?&api_key=[APIKEY]" + users = self.get_users(Emby().get_data(get_users_url)) + if not users: + return [] + all_items = [] + for user in users: + # 根据加入日期 降序排序 + url = "[HOST]emby/Users/" + user + "/Items?SortBy=DateCreated%2CSortName" \ + "&SortOrder=Descending" \ + "&Filters=IsFavorite" \ + "&Recursive=true" \ + "&Fields=PrimaryImageAspectRatio%2CBasicSyncInfo" \ + "&CollapseBoxSetItems=false" \ + "&ExcludeLocationTypes=Virtual" \ + "&EnableTotalRecordCount=false" \ + "&Limit=20&api_key=[APIKEY]" + resp = self.get_items(Emby().get_data(url)) + if not resp: + continue + all_items.extend(resp) + return all_items + + @staticmethod + def get_items(resp: Response): + try: + if resp: + return resp.json().get("Items") or [] + else: + return [] + except Exception as e: + print(str(e)) + return [] + + @staticmethod + def get_users(resp: Response): + try: + if resp: + return [data['Id'] for data in resp.json()] + else: + logger.error(f"BestFilmVersion/Users 未获取到返回数据") + return [] + except Exception as e: + logger.error(f"连接BestFilmVersion/Users 出错:" + str(e)) + return [] + + @staticmethod + def plex_get_watchlist() -> List[dict]: + # 根据加入日期 降序排序 + url = f"https://metadata.provider.plex.tv/library/sections/watchlist/all?type=1&sort=addedAt%3Adesc" \ + f"&X-Plex-Container-Start=0&X-Plex-Container-Size=50" \ + f"&X-Plex-Token={settings.PLEX_TOKEN}" + res = [] + try: + resp = RequestUtils().get_res(url=url) + if resp: + dom = parseString(resp.text) + # 获取文档元素对象 + elem = dom.documentElement + # 获取 指定元素 + eles = elem.getElementsByTagName('Video') + if not eles: + return [] + for ele in eles: + data = {} + # 获取标签中内容 + ele_id = ele.attributes['ratingKey'].nodeValue + ele_title = ele.attributes['title'].nodeValue + ele_type = ele.attributes['type'].nodeValue + _type = "Movie" if ele_type == "movie" else "" + data['Id'] = ele_id + data['Name'] = ele_title + data['Type'] = _type + res.append(data) + return res + else: + logger.error(f"Plex/Watchlist 未获取到返回数据") + return [] + except Exception as e: + logger.error(f"连接Plex/Watchlist 出错:" + str(e)) + return [] + + @staticmethod + def plex_get_iteminfo(itemid) -> dict: + url = f"https://metadata.provider.plex.tv/library/metadata/{itemid}" \ + f"?X-Plex-Token={settings.PLEX_TOKEN}" + try: + resp = RequestUtils(accept_type="application/json, text/plain, */*").get_res(url=url) + if resp: + metadata = resp.json().get('MediaContainer').get('Metadata') + for item in metadata: + _guid = item.get('Guid') + if not _guid: + continue + + id_list = [h.get('id') for h in _guid if h.get('id').__contains__("tmdb")] + if not id_list: + continue + + return {'tmdbid': id_list[0].split("/")[-1]} + + return {} + else: + logger.error(f"Plex/Items 未获取到返回数据") + return {} + except Exception as e: + logger.error(f"连接Plex/Items 出错:" + str(e)) + return {} + + @eventmanager.register(EventType.WebhookMessage) + def webhook_message_action(self, event): + + if not self._enabled: + return + if not self._webhook_enabled: + return + + data: WebhookEventInfo = event.event_data + # 排除不是收藏调用 + if data.channel not in ['jellyfin', 'emby', 'plex']: + return + if data.channel in ['emby', 'plex'] and data.event != 'item.rate': + return + if data.channel == 'jellyfin' and data.save_reason != 'UpdateUserRating': + return + logger.info(f'BestFilmVersion/webhook_message_action WebhookEventInfo打印:{data}') + + # 获取锁 + _is_lock: bool = lock.acquire(timeout=60) + if not _is_lock: + return + try: + if not data.tmdb_id: + info = None + if (data.channel == 'jellyfin' + and data.save_reason == 'UpdateUserRating' + and data.item_favorite): + info = Jellyfin().get_iteminfo(itemid=data.item_id) + elif data.channel == 'emby' and data.event == 'item.rate': + info = Emby().get_iteminfo(itemid=data.item_id) + elif data.channel == 'plex' and data.event == 'item.rate': + info = Plex().get_iteminfo(itemid=data.item_id) + logger.debug(f'BestFilmVersion/webhook_message_action item打印:{info}') + if not info: + return + if info.item_type not in ['Movie', 'MOV', 'movie']: + return + # 获取tmdb_id + tmdb_id = info.tmdbid + else: + tmdb_id = data.tmdb_id + if (data.channel == 'jellyfin' + and (data.save_reason != 'UpdateUserRating' or not data.item_favorite)): + return + if data.item_type not in ['Movie', 'MOV', 'movie']: + return + # 识别媒体信息 + mediainfo = self.chain.recognize_media(tmdbid=tmdb_id, mtype=MediaType.MOVIE) + if not mediainfo: + logger.warn(f'未识别到媒体信息,标题:{data.item_name},tmdbID:{tmdb_id}') + return + # 读取缓存 + caches = self._cache_path.read_text().split("\n") if self._cache_path.exists() else [] + # 检查缓存 + if data.item_name in caches: + return + # 读取历史记录 + history = self.get_data('history') or [] + # 添加订阅 + self.subscribechain.add(mtype=MediaType.MOVIE, + title=mediainfo.title, + year=mediainfo.year, + tmdbid=mediainfo.tmdb_id, + best_version=True, + username="收藏洗版", + exist_ok=True) + # 加入缓存 + caches.append(data.item_name) + # 存储历史记录 + if mediainfo.tmdb_id not in [h.get("tmdbid") for h in history]: + history.append({ + "title": mediainfo.title, + "type": mediainfo.type.value, + "year": mediainfo.year, + "poster": mediainfo.get_poster_image(), + "overview": mediainfo.overview, + "tmdbid": mediainfo.tmdb_id, + "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + }) + # 保存历史记录 + self.save_data('history', history) + # 保存缓存 + self._cache_path.write_text("\n".join(caches)) + finally: + lock.release() diff --git a/plugins.v2/brushflow/__init__.py b/plugins.v2/brushflow/__init__.py new file mode 100644 index 0000000..faa6c05 --- /dev/null +++ b/plugins.v2/brushflow/__init__.py @@ -0,0 +1,4054 @@ +import base64 +import json +import random +import re +import threading +import time +from datetime import datetime, timedelta +from threading import Event +from typing import Any, List, Dict, Tuple, Optional, Union, Set +from urllib.parse import urlparse, parse_qs, unquote + +import pytz +from app.helper.sites import SitesHelper +from apscheduler.schedulers.background import BackgroundScheduler + +from app import schemas +from app.chain.torrents import TorrentsChain +from app.core.config import settings +from app.core.context import MediaInfo +from app.core.metainfo import MetaInfo +from app.db.site_oper import SiteOper +from app.db.subscribe_oper import SubscribeOper +from app.log import logger +from app.modules.qbittorrent import Qbittorrent +from app.modules.transmission import Transmission +from app.plugins import _PluginBase +from app.schemas import NotificationType, TorrentInfo, MediaType +from app.schemas.types import EventType +from app.utils.http import RequestUtils +from app.utils.string import StringUtils + +lock = threading.Lock() + + +class BrushConfig: + """ + 刷流配置 + """ + + def __init__(self, config: dict, process_site_config=True): + self.enabled = config.get("enabled", False) + self.notify = config.get("notify", True) + self.onlyonce = config.get("onlyonce", False) + self.brushsites = config.get("brushsites", []) + self.downloader = config.get("downloader", "qbittorrent") + self.disksize = self.__parse_number(config.get("disksize")) + self.freeleech = config.get("freeleech", "free") + self.hr = config.get("hr", "no") + self.maxupspeed = self.__parse_number(config.get("maxupspeed")) + self.maxdlspeed = self.__parse_number(config.get("maxdlspeed")) + self.maxdlcount = self.__parse_number(config.get("maxdlcount")) + self.include = config.get("include") + self.exclude = config.get("exclude") + self.size = config.get("size") + self.seeder = config.get("seeder") + self.pubtime = config.get("pubtime") + self.seed_time = self.__parse_number(config.get("seed_time")) + self.hr_seed_time = self.__parse_number(config.get("hr_seed_time")) + self.seed_ratio = self.__parse_number(config.get("seed_ratio")) + self.seed_size = self.__parse_number(config.get("seed_size")) + self.download_time = self.__parse_number(config.get("download_time")) + self.seed_avgspeed = self.__parse_number(config.get("seed_avgspeed")) + self.seed_inactivetime = self.__parse_number(config.get("seed_inactivetime")) + self.delete_size_range = config.get("delete_size_range") + self.up_speed = self.__parse_number(config.get("up_speed")) + self.dl_speed = self.__parse_number(config.get("dl_speed")) + self.auto_archive_days = self.__parse_number(config.get("auto_archive_days")) + self.save_path = config.get("save_path") + self.clear_task = config.get("clear_task", False) + self.archive_task = config.get("archive_task", False) + self.delete_except_tags = config.get("delete_except_tags") + self.except_subscribe = config.get("except_subscribe", True) + self.brush_sequential = config.get("brush_sequential", False) + self.proxy_download = config.get("proxy_download", False) + self.proxy_delete = config.get("proxy_delete", False) + self.active_time_range = config.get("active_time_range") + self.downloader_monitor = config.get("downloader_monitor") + self.qb_category = config.get("qb_category") + self.auto_qb_category = config.get("auto_qb_category", False) + self.qb_first_last_piece = config.get("qb_first_last_piece", False) + self.site_hr_active = config.get("site_hr_active", False) + + self.brush_tag = "刷流" + # 站点独立配置 + self.enable_site_config = config.get("enable_site_config", False) + self.site_config = config.get("site_config", "[]") + self.group_site_configs = {} + + # 如果开启了独立站点配置,那么则初始化,否则判断配置是否为空,如果为空,则恢复默认配置 + if process_site_config: + if self.enable_site_config: + self.__initialize_site_config() + elif not self.site_config: + self.site_config = self.get_demo_site_config() + + 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 = { + "freeleech", + "hr", + "include", + "exclude", + "size", + "seeder", + "pubtime", + "seed_time", + "hr_seed_time", + "seed_ratio", + "seed_size", + "download_time", + "seed_avgspeed", + "seed_inactivetime", + "save_path", + "proxy_download", + "proxy_delete", + "qb_category", + "auto_qb_category", + "qb_first_last_piece", + "site_hr_active" + # 当新增支持字段时,仅在此处添加字段名 + } + try: + # site_config中去掉以//开始的行 + site_config = re.sub(r'//.*?\n', '', self.site_config).strip() + site_configs = json.loads(site_config) + self.group_site_configs = {} + for config in site_configs: + sitename = config.get("sitename") + if not sitename: + continue + + # 只从站点特定配置中获取允许的字段 + site_specific_config = {key: config[key] for key in allowed_fields & set(config.keys())} + + full_config = {key: getattr(self, key) for key in vars(self) if + key not in ['group_site_configs', 'site_config']} + full_config.update(site_specific_config) + + self.group_site_configs[sitename] = BrushConfig(config=full_config, process_site_config=False) + except Exception as e: + logger.error(f"解析站点配置失败,已停用插件并关闭站点独立配置,请检查配置项,错误详情: {e}") + self.group_site_configs = {} + self.enable_site_config = False + self.enabled = False + + @staticmethod + def get_demo_site_config() -> str: + desc = ( + "// 以下为配置示例,请参考:https://github.com/InfinityPacer/MoviePilot-Plugins/blob/main/plugins/brushflowlowfreq/README.md 进行配置\n" + "// 如与全局保持一致的配置项,请勿在站点配置中配置\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, + "qb_category": "刷流", + "auto_qb_category": true, + "qb_first_last_piece": true, + "site_hr_active": true +}]""" + return desc + config + + def get_site_config(self, sitename): + """ + 根据站点名称获取特定的BrushConfig实例。如果没有找到站点特定的配置,则返回全局的BrushConfig实例。 + """ + if not self.enable_site_config: + return self + return self if not sitename else self.group_site_configs.get(sitename, self) + + @staticmethod + def __parse_number(value): + if value is None or value == '': # 更精确地检查None或空字符串 + return value + elif isinstance(value, int): # 直接判断是否为int + return value + elif isinstance(value, float): # 直接判断是否为float + return value + else: + try: + number = float(value) + # 检查number是否等于其整数形式 + if number == int(number): + return int(number) + else: + return number + except (ValueError, TypeError): + return 0 + + def __format_value(self, v): + """ + Format the value to mimic JSON serialization. This is now an instance method. + """ + if isinstance(v, str): + return f'"{v}"' + elif isinstance(v, (int, float, bool)): + return str(v).lower() if isinstance(v, bool) else str(v) + elif isinstance(v, list): + return '[' + ', '.join(self.__format_value(i) for i in v) + ']' + elif isinstance(v, dict): + return '{' + ', '.join(f'"{k}": {self.__format_value(val)}' for k, val in v.items()) + '}' + else: + return str(v) + + def __str__(self): + attrs = vars(self) + # Note the use of self.format_value(v) here to call the instance method + attrs_str = ', '.join(f'"{k}": {self.__format_value(v)}' for k, v in attrs.items()) + return f'{{ {attrs_str} }}' + + def __repr__(self): + return self.__str__() + + +class BrushFlow(_PluginBase): + # region 全局定义 + + # 插件名称 + plugin_name = "站点刷流" + # 插件描述 + plugin_desc = "自动托管刷流,将会提高对应站点的访问频率。" + # 插件图标 + plugin_icon = "brush.jpg" + # 插件版本 + plugin_version = "3.8" + # 插件作者 + plugin_author = "jxxghp,InfinityPacer" + # 作者主页 + author_url = "https://github.com/InfinityPacer" + # 插件配置项ID前缀 + plugin_config_prefix = "brushflow_" + # 加载顺序 + plugin_order = 21 + # 可使用的用户级别 + auth_level = 2 + + # 私有属性 + siteshelper = None + siteoper = None + torrents = None + subscribeoper = None + qb = None + tr = None + # 刷流配置 + _brush_config = None + # Brush任务是否启动 + _task_brush_enable = False + # 订阅缓存信息 + _subscribe_infos = None + # Brush定时 + _brush_interval = 10 + # Check定时 + _check_interval = 5 + # 退出事件 + _event = Event() + _scheduler = None + # tabs + _tabs = None + + # endregion + + def init_plugin(self, config: dict = None): + self.siteshelper = SitesHelper() + self.siteoper = SiteOper() + self.torrents = TorrentsChain() + self.subscribeoper = SubscribeOper() + self._task_brush_enable = False + + if not config: + logger.info("站点刷流任务出错,无法获取插件配置") + return False + + self._tabs = config.get("_tabs", None) + + # 如果配置校验没有通过,那么这里修改配置文件后退出 + if not self.__validate_and_fix_config(config=config): + self._brush_config = BrushConfig(config=config) + self._brush_config.enabled = False + self.__update_config() + return + + self._brush_config = BrushConfig(config=config) + + brush_config = self._brush_config + + # 这里先过滤掉已删除的站点并保存,特别注意的是,这里保留了界面选择站点时的顺序,以便后续站点随机刷流或顺序刷流 + if brush_config.brushsites: + site_id_to_public_status = {site.get("id"): site.get("public") for site in self.siteshelper.get_indexers()} + brush_config.brushsites = [ + site_id for site_id in brush_config.brushsites + if site_id in site_id_to_public_status and not site_id_to_public_status[site_id] + ] + + self.__update_config() + + if brush_config.clear_task: + self.__clear_tasks() + brush_config.clear_task = False + brush_config.archive_task = False + self.__update_config() + + elif brush_config.archive_task: + self.__archive_tasks() + brush_config.archive_task = False + self.__update_config() + + if brush_config.enable_site_config: + logger.debug(f"已开启站点独立配置,配置信息:{brush_config}") + else: + logger.debug(f"没有开启站点独立配置,配置信息:{brush_config}") + + # 停止现有任务 + self.stop_service() + + if not self.__setup_downloader(): + return + + # 如果下载器都没有配置,那么这里也不需要继续 + if not brush_config.downloader: + brush_config.enabled = False + self.__update_config() + logger.info(f"站点刷流服务停止,没有配置下载器") + return + + # 如果站点都没有配置,则不开启定时刷流服务 + if not brush_config.brushsites: + logger.info(f"站点刷流Brush定时服务停止,没有配置站点") + + # 如果开启&存在站点时,才需要启用后台任务 + self._task_brush_enable = brush_config.enabled and brush_config.brushsites + + # 检查是否启用了一次性任务 + if brush_config.onlyonce: + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + logger.info(f"站点刷流Brush服务启动,立即运行一次") + self._scheduler.add_job(self.brush, 'date', + run_date=datetime.now( + tz=pytz.timezone(settings.TZ) + ) + timedelta(seconds=3), + name="站点刷流Brush服务") + + logger.info(f"站点刷流Check服务启动,立即运行一次") + self._scheduler.add_job(self.check, 'date', + run_date=datetime.now( + tz=pytz.timezone(settings.TZ) + ) + timedelta(seconds=3), + name="站点刷流Check服务") + + # 关闭一次性开关 + brush_config.onlyonce = False + self.__update_config() + + # 存在任务则启动任务 + if self._scheduler.get_jobs(): + # 启动服务 + self._scheduler.print_jobs() + self._scheduler.start() + + def get_state(self) -> bool: + brush_config = self.__get_brush_config() + return True if brush_config and brush_config.enabled else False + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + 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": {} # 定时器参数 + }] + """ + services = [] + + brush_config = self.__get_brush_config() + if not brush_config: + return services + + if self._task_brush_enable: + logger.info(f"站点刷流Brush定时服务启动,时间间隔 {self._brush_interval} 分钟") + services.append({ + "id": "BrushFlow", + "name": "站点刷流Brush服务", + "trigger": "interval", + "func": self.brush, + "kwargs": {"minutes": self._brush_interval} + }) + + if brush_config.enabled: + logger.info(f"站点刷流Check定时服务启动,时间间隔 {self._check_interval} 分钟") + services.append({ + "id": "BrushFlowCheck", + "name": "站点刷流Check服务", + "trigger": "interval", + "func": self.check, + "kwargs": {"minutes": self._check_interval} + }) + + if not services: + logger.info("站点刷流服务未开启") + + return services + + def __get_total_elements(self) -> List[dict]: + """ + 组装汇总元素 + """ + # 统计数据 + statistic_info = self.__get_statistic_info() + # 总上传量 + total_uploaded = StringUtils.str_filesize(statistic_info.get("uploaded") or 0) + # 总下载量 + total_downloaded = StringUtils.str_filesize(statistic_info.get("downloaded") or 0) + # 下载种子数 + total_count = statistic_info.get("count") or 0 + # 删除种子数 + total_deleted = statistic_info.get("deleted") or 0 + # 待归档种子数 + total_unarchived = statistic_info.get("unarchived") or 0 + # 活跃种子数 + total_active = statistic_info.get("active") or 0 + # 活跃上传量 + total_active_uploaded = StringUtils.str_filesize(statistic_info.get("active_uploaded") or 0) + # 活跃下载量 + total_active_downloaded = StringUtils.str_filesize(statistic_info.get("active_downloaded") or 0) + + return [ + # 总上传量 + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + 'sm': 6 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'VAvatar', + 'props': { + 'rounded': True, + 'variant': 'text', + 'class': 'me-3' + }, + 'content': [ + { + 'component': 'VImg', + 'props': { + 'src': '/plugin_icon/upload.png' + } + } + ] + }, + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '总上传量 / 活跃' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': f"{total_uploaded} / {total_active_uploaded}" + } + ] + } + ] + } + ] + } + ] + }, + ] + }, + # 总下载量 + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + 'sm': 6 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'VAvatar', + 'props': { + 'rounded': True, + 'variant': 'text', + 'class': 'me-3' + }, + 'content': [ + { + 'component': 'VImg', + 'props': { + 'src': '/plugin_icon/download.png' + } + } + ] + }, + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '总下载量 / 活跃' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': f"{total_downloaded} / {total_active_downloaded}" + } + ] + } + ] + } + ] + } + ] + }, + ] + }, + # 下载种子数 + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + 'sm': 6 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'VAvatar', + 'props': { + 'rounded': True, + 'variant': 'text', + 'class': 'me-3' + }, + 'content': [ + { + 'component': 'VImg', + 'props': { + 'src': '/plugin_icon/seed.png' + } + } + ] + }, + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '下载种子数 / 活跃' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': f"{total_count} / {total_active}" + } + ] + } + ] + } + ] + } + ] + }, + ] + }, + # 删除种子数 + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + 'sm': 6 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'VAvatar', + 'props': { + 'rounded': True, + 'variant': 'text', + 'class': 'me-3' + }, + 'content': [ + { + 'component': 'VImg', + 'props': { + 'src': '/plugin_icon/delete.png' + } + } + ] + }, + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '删除种子数 / 待归档' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': f"{total_deleted} / {total_unarchived}" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + ] + + def get_dashboard(self) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], List[dict]]]: + """ + 获取插件仪表盘页面,需要返回:1、仪表板col配置字典;2、全局配置(自动刷新等);3、仪表板页面元素配置json(含数据) + 1、col配置参考: + { + "cols": 12, "md": 6 + } + 2、全局配置参考: + { + "refresh": 10 // 自动刷新时间,单位秒 + } + 3、页面配置使用Vuetify组件拼装,参考:https://vuetifyjs.com/ + """ + # 列配置 + cols = { + "cols": 12 + } + # 全局配置 + attrs = {} + # 拼装页面元素 + elements = [ + { + 'component': 'VRow', + 'content': self.__get_total_elements() + } + ] + return cols, attrs, elements + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + + # 站点的可选项 + site_options = [{"title": site.get("name"), "value": site.get("id")} + for site in self.siteshelper.get_indexers()] + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '发送通知', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': True, + 'chips': True, + 'clearable': True, + 'model': 'brushsites', + 'label': '刷流站点', + 'items': site_options + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'downloader', + 'label': '下载器', + 'items': [ + {'title': 'Qbittorrent', 'value': 'qbittorrent'}, + {'title': 'Transmission', 'value': 'transmission'} + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'active_time_range', + 'label': '开启时间段', + 'placeholder': '如:00:00-08:00' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'delete_size_range', + 'label': '动态删种阈值(GB)', + 'placeholder': '如:500 或 500-1000,达到后删除任务' + } + } + ] + } + ] + }, + { + 'component': 'VTabs', + 'props': { + 'model': '_tabs', + 'style': { + 'margin-top': '8px', + 'margin-bottom': '16px' + }, + 'stacked': True, + 'fixed-tabs': True + }, + 'content': [ + { + 'component': 'VTab', + 'props': { + 'value': 'base_tab' + }, + 'text': '基本配置' + }, { + 'component': 'VTab', + 'props': { + 'value': 'download_tab' + }, + 'text': '选种规则' + }, { + 'component': 'VTab', + 'props': { + 'value': 'delete_tab' + }, + 'text': '删除规则' + }, { + 'component': 'VTab', + 'props': { + 'value': 'other_tab' + }, + 'text': '更多配置' + } + ] + }, + { + 'component': 'VWindow', + 'props': { + 'model': '_tabs' + }, + 'content': [ + { + 'component': 'VWindowItem', + 'props': { + 'value': 'base_tab' + }, + 'content': [ + { + 'component': 'VRow', + 'props': { + 'style': { + 'margin-top': '0px' + } + }, + 'content': [ + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'maxdlcount', + 'label': '同时下载任务数', + 'placeholder': '达到后停止新增任务' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'disksize', + 'label': '保种体积(GB)', + 'placeholder': '如:500,达到后停止新增任务' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'qb_category', + 'label': '种子分类', + 'placeholder': '仅支持qBittorrent,需提前创建' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'maxupspeed', + 'label': '总上传带宽(KB/s)', + 'placeholder': '达到后停止新增任务' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'maxdlspeed', + 'label': '总下载带宽(KB/s)', + 'placeholder': '达到后停止新增任务' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'save_path', + 'label': '保存目录', + 'placeholder': '留空自动' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'up_speed', + 'label': '单任务上传限速(KB/s)', + 'placeholder': '种子上传限速' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'dl_speed', + 'label': '单任务下载限速(KB/s)', + 'placeholder': '种子下载限速' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'auto_archive_days', + 'label': '自动归档记录天数', + 'placeholder': '超过此天数后自动归档', + 'type': 'number', + "min": "0" + } + } + ] + } + ] + } + ] + }, + { + 'component': 'VWindowItem', + 'props': { + 'value': 'download_tab' + }, + 'content': [ + { + 'component': 'VRow', + 'props': { + 'style': { + 'margin-top': '0px' + } + }, + 'content': [ + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'hr', + 'label': '排除H&R', + 'items': [ + {'title': '是', 'value': 'yes'}, + {'title': '否', 'value': 'no'}, + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'freeleech', + 'label': '促销', + 'items': [ + {'title': '全部(包括普通)', 'value': ''}, + {'title': '免费', 'value': 'free'}, + {'title': '2X免费', 'value': '2xfree'}, + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'pubtime', + 'label': '发布时间(分钟)', + 'placeholder': '如:5 或 5-10' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'size', + 'label': '种子大小(GB)', + 'placeholder': '如:5 或 5-10' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'seeder', + 'label': '做种人数', + 'placeholder': '如:5 或 5-10' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'include', + 'label': '包含规则', + 'placeholder': '支持正式表达式' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'exclude', + 'label': '排除规则', + 'placeholder': '支持正式表达式' + } + } + ] + } + ] + } + ] + }, + { + 'component': 'VWindowItem', + 'props': { + 'value': 'delete_tab' + }, + 'content': [ + { + 'component': 'VRow', + 'props': { + 'style': { + 'margin-top': '0px' + } + }, + 'content': [ + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'seed_time', + 'label': '做种时间(小时)', + 'placeholder': '达到后删除任务' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'hr_seed_time', + 'label': 'H&R做种时间(小时)', + 'placeholder': '达到后删除任务' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'seed_ratio', + 'label': '分享率', + 'placeholder': '达到后删除任务' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'seed_size', + 'label': '上传量(GB)', + 'placeholder': '达到后删除任务' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'seed_avgspeed', + 'label': '平均上传速度(KB/s)', + 'placeholder': '低于时删除任务' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'download_time', + 'label': '下载超时时间(小时)', + 'placeholder': '达到后删除任务' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'seed_inactivetime', + 'label': '未活动时间(分钟)', + 'placeholder': '超过时删除任务' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'delete_except_tags', + 'label': '删除排除标签', + 'placeholder': '如:MOVIEPILOT,H&R' + } + } + ] + } + ] + } + ] + }, + { + 'component': 'VWindowItem', + 'props': { + 'value': 'other_tab' + }, + 'content': [ + { + 'component': 'VRow', + 'props': { + 'style': { + 'margin-top': '-16px' + } + }, + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'brush_sequential', + 'label': '站点顺序刷流', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'except_subscribe', + 'label': '排除订阅(实验性功能)', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'qb_first_last_piece', + 'label': '优先下载首尾文件块', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'clear_task', + 'label': '清除统计数据', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'archive_task', + 'label': '归档已删除种子', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'proxy_delete', + 'label': '动态删除种子(实验性功能)', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + "content": [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enable_site_config', + 'label': '站点独立配置', + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 4 + }, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "dialog_closed", + "label": "打开站点配置窗口" + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'proxy_download', + 'label': '代理下载种子', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + "content": [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'downloader_monitor', + 'label': '下载器监控', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'auto_qb_category', + 'label': '自动分类管理', + } + } + ] + } + ] + } + ] + } + ] + }, + { + 'component': 'VRow', + 'props': { + 'style': { + 'margin-top': '12px' + }, + }, + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'success', + 'variant': 'tonal' + }, + 'content': [ + { + 'component': 'span', + 'text': '注意:详细配置说明以及刷流规则请参考:' + }, + { + 'component': 'a', + 'props': { + 'href': 'https://github.com/InfinityPacer/MoviePilot-Plugins/blob/main/plugins/brushflowlowfreq/README.md', + 'target': '_blank' + }, + 'content': [ + { + 'component': 'u', + 'text': 'README' + } + ] + } + ] + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'error', + 'variant': 'tonal', + 'text': '注意:排除H&R并不保证能完全适配所有站点(部分站点在列表页不显示H&R标志,但实际上是有H&R的),请注意核对使用' + } + } + ] + } + ] + }, + { + "component": "VDialog", + "props": { + "model": "dialog_closed", + "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" + }, + "content": [ + { + "component": "VCard", + "props": { + "title": "设置站点配置" + }, + "content": [ + { + "component": "VDialogCloseBtn", + "props": { + "model": "dialog_closed" + } + }, + { + "component": "VCardText", + "props": {}, + "content": [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAceEditor', + 'props': { + 'modelvalue': 'site_config', + 'lang': 'json', + 'theme': 'monokai', + 'style': 'height: 30rem', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal' + }, + 'content': [ + { + 'component': 'span', + 'text': '注意:只有启用站点独立配置时,该配置项才会生效,详细配置参考:' + }, + { + 'component': 'a', + 'props': { + 'href': 'https://github.com/InfinityPacer/MoviePilot-Plugins/blob/main/plugins/brushflowlowfreq/README.md', + 'target': '_blank' + }, + 'content': [ + { + 'component': 'u', + 'text': 'README' + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "notify": True, + "onlyonce": False, + "clear_task": False, + "archive_task": False, + "delete_except_tags": f"{settings.TORRENT_TAG},H&R" if settings.TORRENT_TAG else "H&R", + "except_subscribe": True, + "brush_sequential": False, + "proxy_download": False, + "proxy_delete": False, + "freeleech": "free", + "hr": "yes", + "enable_site_config": False, + "downloader_monitor": False, + "auto_qb_category": False, + "qb_first_last_piece": False, + "site_config": BrushConfig.get_demo_site_config() + } + + def get_page(self) -> List[dict]: + # 种子明细 + torrents = self.get_data("torrents") or {} + + if not torrents: + return [ + { + 'component': 'div', + 'text': '暂无数据', + 'props': { + 'class': 'text-center', + } + } + ] + else: + data_list = torrents.values() + # 按time倒序排序 + data_list = sorted(data_list, key=lambda x: x.get("time") or 0, reverse=True) + + # 表格标题 + headers = [ + {'title': '站点', 'key': 'site', 'sortable': True}, + {'title': '标题', 'key': 'title', 'sortable': True}, + {'title': '大小', 'key': 'size', 'sortable': True}, + {'title': '上传量', 'key': 'uploaded', 'sortable': True}, + {'title': '下载量', 'key': 'downloaded', 'sortable': True}, + {'title': '分享率', 'key': 'ratio', 'sortable': True}, + {'title': '状态', 'key': 'status', 'sortable': True}, + ] + # 种子数据明细 + items = [ + { + 'site': data.get("site_name"), + 'title': data.get("title"), + 'size': StringUtils.str_filesize(data.get("size")), + 'uploaded': StringUtils.str_filesize(data.get("uploaded") or 0), + 'downloaded': StringUtils.str_filesize(data.get("downloaded") or 0), + 'ratio': round(data.get('ratio') or 0, 2), + 'status': "已删除" if data.get("deleted") else "正常" + } for data in data_list + ] + + # 拼装页面 + return [ + { + 'component': 'VRow', + 'props': { + 'style': { + 'overflow': 'hidden', + } + }, + 'content': self.__get_total_elements() + [ + # 种子明细 + { + 'component': 'VRow', + 'props': { + 'class': 'd-none d-sm-block', + }, + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VDataTableVirtual', + 'props': { + 'class': 'text-sm', + 'headers': headers, + 'items': items, + 'height': '30rem', + 'density': 'compact', + 'fixed-header': True, + 'hide-no-data': True, + 'hover': True + } + } + ] + } + ] + } + ] + } + ] + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._event.set() + self._scheduler.shutdown() + self._event.clear() + self._scheduler = None + except Exception as e: + print(str(e)) + + # region Brush + + def brush(self): + """ + 定时刷流,添加下载任务 + """ + brush_config = self.__get_brush_config() + + if not brush_config.brushsites or not brush_config.downloader: + return + + if not self.__is_current_time_in_range(): + logger.info(f"当前不在指定的刷流时间区间内,刷流操作将暂时暂停") + return + + with lock: + logger.info(f"开始执行刷流任务 ...") + + 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: + 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() + + # 获取所有站点的信息,并过滤掉不存在的站点 + site_infos = [] + for siteid in brush_config.brushsites: + siteinfo = self.siteoper.get(siteid) + if siteinfo: + site_infos.append(siteinfo) + + # 根据是否开启顺序刷流来决定是否需要打乱顺序 + if not brush_config.brush_sequential: + random.shuffle(site_infos) + + logger.info(f"即将针对站点 {', '.join(site.name for site in site_infos)} 开始刷流") + + # 获取订阅标题 + subscribe_titles = self.__get_subscribe_titles() + + # 处理所有站点 + for site in site_infos: + # 如果站点刷流没有正确响应,说明没有通过前置条件,其他站点也不需要继续刷流了 + if not self.__brush_site_torrents(siteid=site.id, torrent_tasks=torrent_tasks, + statistic_info=statistic_info, + subscribe_titles=subscribe_titles): + logger.info(f"站点 {site.name} 刷流中途结束,停止后续刷流") + break + else: + logger.info(f"站点 {site.name} 刷流完成") + + # 保存数据 + self.save_data("torrents", torrent_tasks) + # 保存统计数据 + self.save_data("statistic", statistic_info) + logger.info(f"刷流任务执行完成") + + def __brush_site_torrents(self, siteid, torrent_tasks: Dict[str, dict], statistic_info: Dict[str, int], + subscribe_titles: Set[str]) -> bool: + """ + 针对站点进行刷流 + """ + siteinfo = self.siteoper.get(siteid) + if not siteinfo: + logger.warn(f"站点不存在:{siteid}") + return True + + logger.info(f"开始获取站点 {siteinfo.name} 的新种子 ...") + torrents = self.torrents.browse(domain=siteinfo.domain) + if not torrents: + logger.info(f"站点 {siteinfo.name} 没有获取到种子") + return True + + brush_config = self.__get_brush_config(sitename=siteinfo.name) + + if brush_config.site_hr_active: + logger.info(f"站点 {siteinfo.name} 已开启全站H&R选项,所有种子设置为H&R种子") + + # 排除包含订阅的种子 + if brush_config.except_subscribe: + torrents = self.__filter_torrents_contains_subscribe(torrents=torrents, subscribe_titles=subscribe_titles) + + # 按发布日期降序排列 + torrents.sort(key=lambda x: x.pubdate or '', reverse=True) + + torrents_size = self.__calculate_seeding_torrents_size(torrent_tasks=torrent_tasks) + + logger.info(f"正在准备种子刷流,数量 {len(torrents)}") + + # 过滤种子 + 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) + if not pre_condition_passed: + return False + + logger.debug(f"种子详情:{torrent}") + + # 判断能否通过保种体积刷流条件 + size_condition_passed, reason = self.__evaluate_size_condition_for_brush(torrents_size=torrents_size, + add_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: + continue + + # 添加下载任务 + hash_string = self.__download(torrent=torrent) + if not hash_string: + logger.warn(f"{torrent.title} 添加刷流任务失败!") + continue + + # 触发刷流下载时间并保存任务信息 + torrent_task = { + "site": siteinfo.id, + "site_name": siteinfo.name, + "title": torrent.title, + "size": torrent.size, + "pubdate": torrent.pubdate, + # "site_cookie": torrent.site_cookie, + # "site_ua": torrent.site_ua, + # "site_proxy": torrent.site_proxy, + # "site_order": torrent.site_order, + "description": torrent.description, + "imdbid": torrent.imdbid, + # "enclosure": torrent.enclosure, + "page_url": torrent.page_url, + # "seeders": torrent.seeders, + # "peers": torrent.peers, + # "grabs": torrent.grabs, + "date_elapsed": torrent.date_elapsed, + "freedate": torrent.freedate, + "uploadvolumefactor": torrent.uploadvolumefactor, + "downloadvolumefactor": torrent.downloadvolumefactor, + "hit_and_run": torrent.hit_and_run or brush_config.site_hr_active, + "volume_factor": torrent.volume_factor, + "freedate_diff": torrent.freedate_diff, + # "labels": torrent.labels, + # "pri_order": torrent.pri_order, + # "category": torrent.category, + "ratio": 0, + "downloaded": 0, + "uploaded": 0, + "seeding_time": 0, + "deleted": False, + "time": time.time() + } + + self.eventmanager.send_event(etype=EventType.PluginAction, data={ + "action": "brushflow_download_added", + "hash": hash_string, + "data": torrent_task + }) + torrent_tasks[hash_string] = torrent_task + + # 统计数据 + torrents_size += torrent.size + statistic_info["count"] += 1 + logger.info(f"站点 {siteinfo.name},新增刷流种子下载:{torrent.title}|{torrent.description}") + self.__send_add_message(torrent) + + return True + + def __evaluate_size_condition_for_brush(self, torrents_size: float, + add_torrent_size: float = 0.0) -> Tuple[bool, Optional[str]]: + """ + 过滤体积不符合条件的种子 + """ + brush_config = self.__get_brush_config() + + # 如果没有明确指定增加的种子大小,则检查配置中是否有种子大小下限,如果有,使用这个大小作为增加的种子大小 + preset_condition = False + if not add_torrent_size and brush_config.size: + size_limits = [float(size) * 1024 ** 3 for size in brush_config.size.split("-")] + add_torrent_size = size_limits[0] # 使用配置的种子大小下限 + preset_condition = True + + total_size = self.__bytes_to_gb(torrents_size + add_torrent_size) # 预计总做种体积 + + def generate_message(config): + if add_torrent_size: + if preset_condition: + return (f"当前做种体积 {self.__bytes_to_gb(torrents_size):.1f} GB," + f"刷流种子下限 {self.__bytes_to_gb(add_torrent_size):.1f} GB," + f"预计做种体积 {total_size:.1f} GB," + f"超过设定的保种体积 {config} GB,暂时停止新增任务") + else: + return (f"当前做种体积 {self.__bytes_to_gb(torrents_size):.1f} GB," + f"刷流种子大小 {self.__bytes_to_gb(add_torrent_size):.1f} GB," + f"预计做种体积 {total_size:.1f} GB," + f"超过设定的保种体积 {config} GB") + else: + return (f"当前做种体积 {self.__bytes_to_gb(torrents_size):.1f} GB," + f"超过设定的保种体积 {config} GB,暂时停止新增任务") + + reasons = [ + ("disksize", + lambda config: torrents_size + add_torrent_size > float(config) * 1024 ** 3, generate_message) + ] + + 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},暂时停止新增任务") + ] + + if include_network_conditions: + downloader_info = self.__get_downloader_info() + if downloader_info: + current_upload_speed = downloader_info.upload_speed or 0 + current_download_speed = downloader_info.download_speed or 0 + reasons.extend([ + ("maxupspeed", lambda config: current_upload_speed >= float(config) * 1024, + lambda config: f"当前总上传带宽 {StringUtils.str_filesize(current_upload_speed)}," + f"已达到最大值 {config} KB/s,暂时停止新增任务"), + ("maxdlspeed", lambda config: current_download_speed >= float(config) * 1024, + lambda config: f"当前总下载带宽 {StringUtils.str_filesize(current_download_speed)}," + f"已达到最大值 {config} KB/s,暂时停止新增任务"), + ]) + + 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_conditions_for_brush(self, torrent, torrent_tasks) -> Tuple[bool, Optional[str]]: + """ + 过滤不符合条件的种子 + """ + 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 torrent.title: + if any(torrent.site_name != f"{task.get('site_name')}" and torrent.title == f"{task.get('title')}" + and not task.get("seed_time") for task in torrent_tasks.values()): + return False, "其他站点存在尚未下载完成的相同种子" + + # 促销条件 + if brush_config.freeleech and torrent.downloadvolumefactor != 0: + return False, "非免费种子" + if brush_config.freeleech == "2xfree" and torrent.uploadvolumefactor != 2: + return False, "非双倍上传种子" + + # H&R + if brush_config.hr == "yes" and torrent.hit_and_run: + return False, "存在H&R" + + # 包含规则 + if brush_config.include and not ( + re.search(brush_config.include, torrent.title, re.I) or re.search(brush_config.include, + torrent.description, re.I)): + return False, "不符合包含规则" + + # 排除规则 + if brush_config.exclude and ( + re.search(brush_config.exclude, torrent.title, re.I) or re.search(brush_config.exclude, + torrent.description, re.I)): + return False, "符合排除规则" + + # 种子大小(GB) + if brush_config.size: + sizes = [float(size) * 1024 ** 3 for size in brush_config.size.split("-")] + if len(sizes) == 1 and torrent.size < sizes[0]: + return False, f"种子大小 {self.__bytes_to_gb(torrent.size):.1f} GB,不符合条件" + elif len(sizes) > 1 and not sizes[0] <= torrent.size <= sizes[1]: + return False, f"种子大小 {self.__bytes_to_gb(torrent.size):.1f} GB,不在指定范围内" + + # 做种人数 + if brush_config.seeder: + seeders_range = [float(n) for n in brush_config.seeder.split("-")] + # 检查是否仅指定了一个数字,即做种人数需要小于等于该数字 + if len(seeders_range) == 1: + # 当做种人数大于该数字时,不符合条件 + if torrent.seeders > seeders_range[0]: + return False, f"做种人数 {torrent.seeders},超过单个指定值" + # 如果指定了一个范围 + elif len(seeders_range) > 1: + # 检查做种人数是否在指定的范围内(包括边界) + if not (seeders_range[0] <= torrent.seeders <= seeders_range[1]): + return False, f"做种人数 {torrent.seeders},不在指定范围内" + + # 发布时间 + pubdate_minutes = self.__get_pubminutes(torrent.pubdate) + # 已支持独立站点配置,取消单独适配站点时区逻辑,可通过配置项「pubtime」自行适配 + # pubdate_minutes = self.__adjust_site_pubminutes(pubdate_minutes, torrent) + if brush_config.pubtime: + pubtimes = [float(n) for n in brush_config.pubtime.split("-")] + if len(pubtimes) == 1: + # 单个值:选择发布时间小于等于该值的种子 + if pubdate_minutes > pubtimes[0]: + return False, f"发布时间 {torrent.pubdate},{pubdate_minutes:.0f} 分钟前,不符合条件" + else: + # 范围值:选择发布时间在范围内的种子 + if not (pubtimes[0] <= pubdate_minutes <= pubtimes[1]): + return False, f"发布时间 {torrent.pubdate},{pubdate_minutes:.0f} 分钟前,不在指定范围内" + + return True, None + + @staticmethod + def __log_brush_conditions(passed: bool, reason: str, torrent: Any = None): + """ + 记录刷流日志 + """ + if not passed: + if not torrent: + logger.warn(f"没有通过前置刷流条件校验,原因:{reason}") + else: + logger.debug(f"种子没有通过刷流条件校验,原因:{reason} 种子:{torrent.title}|{torrent.description}") + + # endregion + + # region Check + + def check(self): + """ + 定时检查,删除下载任务 + """ + brush_config = self.__get_brush_config() + + if not brush_config.downloader: + return + + with lock: + logger.info("开始检查刷流下载任务 ...") + torrent_tasks: Dict[str, dict] = self.get_data("torrents") or {} + unmanaged_tasks: Dict[str, dict] = self.get_data("unmanaged") or {} + + downloader = self.__get_downloader(brush_config.downloader) + if not downloader: + logger.warn("无法获取下载器实例,将在下个时间周期重试") + return + + seeding_torrents, error = downloader.get_torrents() + if error: + logger.warn("连接下载器出错,将在下个时间周期重试") + return + + seeding_torrents_dict = {self.__get_hash(torrent): torrent for torrent in seeding_torrents} + + # 检查种子刷流标签变更情况 + self.__update_seeding_tasks_based_on_tags(torrent_tasks=torrent_tasks, unmanaged_tasks=unmanaged_tasks, + seeding_torrents_dict=seeding_torrents_dict) + + torrent_check_hashes = list(torrent_tasks.keys()) + if not torrent_tasks or not torrent_check_hashes: + logger.info("没有需要检查的刷流下载任务") + return + + logger.info(f"共有 {len(torrent_check_hashes)} 个任务正在刷流,开始检查任务状态") + + # 获取到当前所有做种数据中需要被检查的种子数据 + check_torrents = [seeding_torrents_dict[th] for th in torrent_check_hashes if th in seeding_torrents_dict] + + # 先更新刷流任务的最新状态,上下传,分享率 + self.__update_torrent_tasks_state(torrents=check_torrents, torrent_tasks=torrent_tasks) + + # 更新刷流任务列表中在下载器中删除的种子为删除状态 + self.__update_undeleted_torrents_missing_in_downloader(torrent_tasks, torrent_check_hashes, check_torrents) + + # 根据配置的标签进行种子排除 + if check_torrents: + logger.info(f"当前刷流任务共 {len(check_torrents)} 个有效种子,正在准备按设定的种子标签进行排除") + # 初始化一个空的列表来存储需要排除的标签 + tags_to_exclude = set() + # 如果 delete_except_tags 非空且不是纯空白,则添加到排除列表中 + if brush_config.delete_except_tags and brush_config.delete_except_tags.strip(): + tags_to_exclude.update(tag.strip() for tag in brush_config.delete_except_tags.split(',')) + # 将所有需要排除的标签组合成一个字符串,每个标签之间用逗号分隔 + combined_tags = ",".join(tags_to_exclude) + if combined_tags: # 确保有标签需要排除 + pre_filter_count = len(check_torrents) # 获取过滤前的任务数量 + check_torrents = self.__filter_torrents_by_tag(torrents=check_torrents, exclude_tag=combined_tags) + post_filter_count = len(check_torrents) # 获取过滤后的任务数量 + excluded_count = pre_filter_count - post_filter_count # 计算被排除的任务数量 + logger.info( + f"有效种子数 {pre_filter_count},排除标签 '{combined_tags}' 后," + f"剩余种子数 {post_filter_count},排除种子数 {excluded_count}") + else: + logger.info("没有配置有效的排除标签,所有种子均参与后续处理") + + # 种子删除检查 + if not check_torrents: + logger.info("没有需要检查的任务,跳过") + else: + need_delete_hashes = [] + + # 如果配置了动态删除以及删种阈值,则根据动态删种进行分组处理 + if brush_config.proxy_delete and brush_config.delete_size_range: + logger.info("已开启动态删种,按系统默认动态删种条件开始检查任务") + proxy_delete_hashes = self.__delete_torrent_for_proxy(torrents=check_torrents, + torrent_tasks=torrent_tasks) or [] + need_delete_hashes.extend(proxy_delete_hashes) + # 否则均认为是没有开启动态删种 + else: + logger.info("没有开启动态删种,按用户设置删种条件开始检查任务") + not_proxy_delete_hashes = self.__delete_torrent_for_evaluate_conditions(torrents=check_torrents, + torrent_tasks=torrent_tasks) or [] + need_delete_hashes.extend(not_proxy_delete_hashes) + + if need_delete_hashes: + # 如果是QB,则重新汇报Tracker + if brush_config.downloader == "qbittorrent": + self.__qb_torrents_reannounce(torrent_hashes=need_delete_hashes) + # 删除种子 + if downloader.delete_torrents(ids=need_delete_hashes, delete_file=True): + for torrent_hash in need_delete_hashes: + torrent_tasks[torrent_hash]["deleted"] = True + torrent_tasks[torrent_hash]["deleted_time"] = time.time() + + # 归档数据 + self.__auto_archive_tasks(torrent_tasks=torrent_tasks) + + self.__update_and_save_statistic_info(torrent_tasks) + + self.save_data("torrents", torrent_tasks) + + logger.info("刷流下载任务检查完成") + + def __update_torrent_tasks_state(self, torrents: List[Any], torrent_tasks: Dict[str, dict]): + """ + 更新刷流任务的最新状态,上下传,分享率 + """ + for torrent in torrents: + torrent_hash = self.__get_hash(torrent) + torrent_task = torrent_tasks.get(torrent_hash, None) + # 如果找不到种子任务,说明不在管理的种子范围内,直接跳过 + if not torrent_task: + continue + + torrent_info = self.__get_torrent_info(torrent) + + # 更新上传量、下载量 + torrent_task.update({ + "downloaded": torrent_info.get("downloaded"), + "uploaded": torrent_info.get("uploaded"), + "ratio": torrent_info.get("ratio"), + "seeding_time": torrent_info.get("seeding_time"), + }) + + def __update_seeding_tasks_based_on_tags(self, torrent_tasks: Dict[str, dict], unmanaged_tasks: Dict[str, dict], + seeding_torrents_dict: Dict[str, Any]): + brush_config = self.__get_brush_config() + + if brush_config.downloader_monitor: + logger.info("已开启下载器监控,开始同步种子刷流标签记录") + else: + logger.info("没有开启下载器监控,取消同步种子刷流标签记录") + return + + if not brush_config.downloader == "qbittorrent": + logger.info("同步种子刷流标签记录目前仅支持qbittorrent") + return + + # 初始化汇总信息 + added_tasks = [] + reset_tasks = [] + removed_tasks = [] + # 基于 seeding_torrents_dict 的信息更新或添加到 torrent_tasks + for torrent_hash, torrent in seeding_torrents_dict.items(): + tags = self.__get_label(torrent=torrent) + # 判断是否包含刷流标签 + if brush_config.brush_tag in tags: + # 如果包含刷流标签又不在刷流任务中,则需要加入管理 + if torrent_hash not in torrent_tasks: + # 检查该种子是否在 unmanaged_tasks 中 + if torrent_hash in unmanaged_tasks: + # 如果在 unmanaged_tasks 中,移除并转移到 torrent_tasks + torrent_task = unmanaged_tasks.pop(torrent_hash) + torrent_tasks[torrent_hash] = torrent_task + added_tasks.append(torrent_task) + logger.info(f"站点 {torrent_task.get('site_name')}," + f"刷流任务种子再次加入:{torrent_task.get('title')}|{torrent_task.get('description')}") + else: + # 否则,创建一个新的任务 + torrent_task = self.__convert_torrent_info_to_task(torrent) + torrent_tasks[torrent_hash] = torrent_task + added_tasks.append(torrent_task) + logger.info(f"站点 {torrent_task.get('site_name')}," + f"刷流任务种子加入:{torrent_task.get('title')}|{torrent_task.get('description')}") + # 包含刷流标签又在刷流任务中,这里额外处理一个特殊逻辑,就是种子在刷流任务中可能被标记删除但实际上又还在下载器中,这里进行重置 + else: + torrent_task = torrent_tasks[torrent_hash] + if torrent_task.get("deleted"): + torrent_task["deleted"] = False + reset_tasks.append(torrent_task) + logger.info( + f"站点 {torrent_task.get('site_name')},在下载器中找到已标记删除的刷流任务对应的种子信息," + f"更新刷流任务状态为正常:{torrent_task.get('title')}|{torrent_task.get('description')}") + else: + # 不包含刷流标签但又在刷流任务中,则移除管理 + if torrent_hash in torrent_tasks: + # 如果种子不符合刷流条件但在 torrent_tasks 中,移除并加入 unmanaged_tasks + torrent_task = torrent_tasks.pop(torrent_hash) + unmanaged_tasks[torrent_hash] = torrent_task + removed_tasks.append(torrent_task) + logger.info(f"站点 {torrent_task.get('site_name')}," + f"刷流任务种子移除:{torrent_task.get('title')}|{torrent_task.get('description')}") + + self.save_data("torrents", torrent_tasks) + self.save_data("unmanaged", unmanaged_tasks) + + # 发送汇总消息 + if added_tasks: + self.__log_and_send_torrent_task_update_message(title="【刷流任务种子加入】", status="纳入刷流管理", + reason="刷流标签添加", torrent_tasks=added_tasks) + if removed_tasks: + self.__log_and_send_torrent_task_update_message(title="【刷流任务种子移除】", status="移除刷流管理", + reason="刷流标签移除", torrent_tasks=removed_tasks) + if reset_tasks: + self.__log_and_send_torrent_task_update_message(title="【刷流任务状态更新】", status="更新刷流状态为正常", + reason="在下载器中找到已标记删除的刷流任务对应的种子信息", + torrent_tasks=reset_tasks) + + def __group_torrents_by_proxy_delete(self, torrents: List[Any], torrent_tasks: Dict[str, dict]): + """ + 根据是否启用动态删种进行分组 + """ + proxy_delete_torrents = [] + not_proxy_delete_torrents = [] + + for torrent in torrents: + torrent_hash = self.__get_hash(torrent) + torrent_task = torrent_tasks.get(torrent_hash, None) + + # 如果找不到种子任务,说明不在管理的种子范围内,直接跳过 + if not torrent_task: + continue + + site_name = torrent_task.get("site_name", "") + + brush_config = self.__get_brush_config(site_name) + if brush_config.proxy_delete: + proxy_delete_torrents.append(torrent) + else: + not_proxy_delete_torrents.append(torrent) + + return proxy_delete_torrents, not_proxy_delete_torrents + + def __evaluate_conditions_for_delete(self, site_name: str, torrent_info: dict, torrent_task: dict) \ + -> Tuple[bool, str]: + """ + 评估删除条件并返回是否应删除种子及其原因 + """ + brush_config = self.__get_brush_config(sitename=site_name) + + reason = "未能满足设置的删除条件" + + # 当配置了H&R做种时间/分享率时,则H&R种子只有达到预期行为时,才会进行删除,如果没有配置H&R做种时间/分享率,则普通种子的删除规则也适用于H&R种子 + # 判断是否为H&R种子并且是否配置了特定的H&R条件 + hit_and_run = torrent_task.get("hit_and_run", False) + hr_specific_conditions_configured = hit_and_run and (brush_config.hr_seed_time or brush_config.seed_ratio) + if hr_specific_conditions_configured: + if (brush_config.hr_seed_time and torrent_info.get("seeding_time") + >= float(brush_config.hr_seed_time) * 3600): + return True, (f"H&R种子,做种时间 {torrent_info.get('seeding_time') / 3600:.1f} 小时," + f"大于 {brush_config.hr_seed_time} 小时") + if brush_config.seed_ratio and torrent_info.get("ratio") >= float(brush_config.seed_ratio): + return True, f"H&R种子,分享率 {torrent_info.get('ratio'):.2f},大于 {brush_config.seed_ratio}" + return False, "H&R种子,未能满足设置的H&R删除条件" + + # 处理其他场景,1. 不是H&R种子;2. 是H&R种子但没有特定条件配置 + reason = reason if not hit_and_run else "H&R种子(未设置H&R条件),未能满足设置的删除条件" + if brush_config.seed_time and torrent_info.get("seeding_time") >= float(brush_config.seed_time) * 3600: + reason = f"做种时间 {torrent_info.get('seeding_time') / 3600:.1f} 小时,大于 {brush_config.seed_time} 小时" + elif brush_config.seed_ratio and torrent_info.get("ratio") >= float(brush_config.seed_ratio): + reason = f"分享率 {torrent_info.get('ratio'):.2f},大于 {brush_config.seed_ratio}" + elif brush_config.seed_size and torrent_info.get("uploaded") >= float(brush_config.seed_size) * 1024 ** 3: + reason = f"上传量 {torrent_info.get('uploaded') / 1024 ** 3:.1f} GB,大于 {brush_config.seed_size} GB" + elif 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} 小时" + elif brush_config.seed_avgspeed and torrent_info.get("avg_upspeed") <= float( + brush_config.seed_avgspeed) * 1024 and torrent_info.get("seeding_time") >= 30 * 60: + reason = f"平均上传速度 {torrent_info.get('avg_upspeed') / 1024:.1f} KB/s,低于 {brush_config.seed_avgspeed} KB/s" + elif brush_config.seed_inactivetime and torrent_info.get("iatime") >= float( + brush_config.seed_inactivetime) * 60: + reason = f"未活动时间 {torrent_info.get('iatime') / 60:.0f} 分钟,大于 {brush_config.seed_inactivetime} 分钟" + else: + return False, reason + + 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: + """ + 根据条件删除种子并获取已删除列表 + """ + brush_config = self.__get_brush_config() + delete_hashes = [] + + for torrent in torrents: + torrent_hash = self.__get_hash(torrent) + torrent_task = torrent_tasks.get(torrent_hash, None) + # 如果找不到种子任务,说明不在管理的种子范围内,直接跳过 + if not torrent_task: + 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_conditions_for_delete(site_name=site_name, + torrent_info=torrent_info, + torrent_task=torrent_task) + if should_delete: + delete_hashes.append(torrent_hash) + reason = "触发动态删除阈值," + reason if proxy_delete else reason + 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: + logger.debug(f"站点:{site_name},{reason},不删除种子:{torrent_title}|{torrent_desc}") + + return delete_hashes + + 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_hashes = [] + + 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_hashes.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: + logger.debug(f"站点:{site_name},{reason},不删除种子:{torrent_title}|{torrent_desc}") + + return delete_hashes + + def __delete_torrent_for_proxy(self, torrents: List[Any], torrent_tasks: Dict[str, dict]) -> List: + """ + 动态删除种子,删除规则如下; + - 不管做种体积是否超过设定的动态删除阈值,默认优先执行排除H&R种子后满足「下载超时时间」的种子 + - 上述规则执行完成后,当做种体积依旧超过设定的动态删除阈值时,继续执行下述种子删除规则 + - 优先删除满足用户设置删除规则的全部种子,即便在删除过程中已经低于了阈值下限,也会继续删除 + - 若删除后还没有达到阈值,则在已完成种子中排除H&R种子后按做种时间倒序进行删除 + - 动态删除阈值:100,当做种体积 > 100G 时,则开始删除种子,直至降低至 100G + - 动态删除阈值:50-100,当做种体积 > 100G 时,则开始删除种子,直至降至为 50G + """ + brush_config = self.__get_brush_config() + + # 如果没有启用动态删除或没有设置删除阈值,则不执行删除操作 + 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] # 触发删除操作的做种体积上限 + + # 判断是否为区间删除 + proxy_size_range = len(sizes) > 1 + + # 当总体积未超过最大阈值时,不需要执行删除操作 + 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 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,进一步触发动态删除") + + 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, + torrent_tasks=torrent_tasks) + logger.info(f"托管种子数 {len(proxy_delete_torrents)},未托管种子数 {len(not_proxy_delete_torrents)}") + if not_proxy_delete_torrents: + not_proxy_delete_hashes = self.__delete_torrent_for_evaluate_conditions(torrents=not_proxy_delete_torrents, + torrent_tasks=torrent_tasks) or [] + need_delete_hashes.extend(not_proxy_delete_hashes) + total_torrent_size -= sum( + 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, + proxy_delete=True) or [] + need_delete_hashes.extend(proxy_delete_hashes) + total_torrent_size -= sum( + 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种子后按做种时间正序进行删除 + if total_torrent_size > min_size: + # 重新计算当前的种子列表,排除已删除的种子 + remaining_hashes = list( + {self.__get_hash(torrent) for torrent in proxy_delete_torrents} - set(need_delete_hashes)) + # 这里根据排除后的种子列表,再次从下载器中找到已完成的任务 + downloader = self.__get_downloader(brush_config.downloader) + completed_torrents = downloader.get_completed_torrents(ids=remaining_hashes) + remaining_hashes = {self.__get_hash(torrent) for torrent in completed_torrents} + remaining_torrents = [(_hash, torrent_info_map[_hash]) for _hash in remaining_hashes] + + # 准备一个列表,用于存放满足条件的种子,即非HR种子且有明确做种时间 + filtered_torrents = [(_hash, info['seeding_time']) for _hash, info in remaining_torrents if + not torrent_tasks[_hash].get("hit_and_run", False)] + sorted_torrents = sorted(filtered_torrents, key=lambda x: x[1], reverse=True) + + # 进行额外的删除操作,直到满足最小阈值或没有更多种子可删除 + for torrent_hash, _ in sorted_torrents: + if total_torrent_size <= min_size: + break + torrent_task = torrent_tasks.get(torrent_hash, None) + torrent_info = torrent_info_map.get(torrent_hash, None) + if not torrent_task or not torrent_info: + continue + + need_delete_hashes.append(torrent_hash) + total_torrent_size -= torrent_info.get("total_size", 0) + + site_name = torrent_task.get("site_name", "") + torrent_title = torrent_task.get("title", "") + torrent_desc = torrent_task.get("description", "") + seeding_time = torrent_task.get("seeding_time", 0) + if seeding_time: + reason = (f"触发动态删除阈值,系统自动删除,做种时间 {seeding_time / 3600:.1f} 小时," + f"当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB") + # 如果是区间删除,一次性删除的数据过多,取消消息推送 + if not proxy_size_range: + 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}") + + 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) + + # 如果是区间删除,这里则进行统一推送 + if proxy_size_range: + self.__send_message(title="【刷流任务种子删除】", text=msg) + + # 返回所有需要删除的种子的哈希列表 + return need_delete_hashes + + def __update_undeleted_torrents_missing_in_downloader(self, torrent_tasks, torrent_check_hashes, torrents): + """ + 处理已经被删除,但是任务记录中还没有被标记删除的种子 + """ + brush_config = self.__get_brush_config() + + if brush_config.downloader_monitor: + logger.info("已开启下载器监控,开始同步刷流任务删除记录") + else: + logger.info("没有开启下载器监控,取消同步刷流任务删除记录") + return + + # 先通过获取的全量种子,判断已经被删除,但是任务记录中还没有被标记删除的种子 + torrent_all_hashes = self.__get_all_hashes(torrents) + missing_hashes = [hash_value for hash_value in torrent_check_hashes if hash_value not in torrent_all_hashes] + undeleted_hashes = [hash_value for hash_value in missing_hashes if not torrent_tasks[hash_value].get("deleted")] + + if not undeleted_hashes: + return + + # 初始化汇总信息 + delete_tasks = [] + for hash_value in undeleted_hashes: + # 获取对应的任务信息 + torrent_task = torrent_tasks[hash_value] + # 标记为已删除 + torrent_task["deleted"] = True + torrent_task["deleted_time"] = time.time() + # 处理日志相关内容 + delete_tasks.append(torrent_task) + site_name = torrent_task.get("site_name", "") + torrent_title = torrent_task.get("title", "") + torrent_desc = torrent_task.get("description", "") + logger.info( + f"站点:{site_name},无法在下载器中找到对应种子信息,更新刷流任务状态为已删除,种子:{torrent_title}|{torrent_desc}") + + self.__log_and_send_torrent_task_update_message(title="【刷流任务状态更新】", status="更新刷流状态为已删除", + reason="无法在下载器中找到对应的种子信息", + torrent_tasks=delete_tasks) + + def __convert_torrent_info_to_task(self, torrent: Any) -> dict: + """ + 根据torrent_info转换成torrent_task + """ + torrent_info = self.__get_torrent_info(torrent=torrent) + + site_id, site_name = self.__get_site_by_torrent(torrent=torrent) + + torrent_task = { + "site": site_id, + "site_name": site_name, + "title": torrent_info.get("title", ""), + "size": torrent_info.get("total_size", 0), # 假设total_size对应于size + "pubdate": None, + "description": None, + "imdbid": None, + "page_url": None, + "date_elapsed": None, + "freedate": None, + "uploadvolumefactor": None, + "downloadvolumefactor": None, + "hit_and_run": None, + "volume_factor": None, + "freedate_diff": None, # 假设无法从torrent_info直接获取 + "ratio": torrent_info.get("ratio", 0), + "downloaded": torrent_info.get("downloaded", 0), + "uploaded": torrent_info.get("uploaded", 0), + "deleted": False, + "time": torrent_info.get("add_on", time.time()) + } + return torrent_task + + # endregion + + def __update_and_save_statistic_info(self, torrent_tasks): + """ + 更新并保存统计信息 + """ + total_count, total_uploaded, total_downloaded, total_deleted = 0, 0, 0, 0 + active_uploaded, active_downloaded, active_count, total_unarchived = 0, 0, 0, 0 + + statistic_info = self.__get_statistic_info() + archived_tasks = self.get_data("archived") or {} + combined_tasks = {**torrent_tasks, **archived_tasks} + + for task in combined_tasks.values(): + if task.get("deleted", False): + total_deleted += 1 + total_downloaded += task.get("downloaded", 0) + total_uploaded += task.get("uploaded", 0) + + # 计算torrent_tasks中未标记为删除的活跃任务的统计信息,及待归档的任务数 + for task in torrent_tasks.values(): + if not task.get("deleted", False): + active_uploaded += task.get("uploaded", 0) + active_downloaded += task.get("downloaded", 0) + active_count += 1 + else: + total_unarchived += 1 + + # 更新统计信息 + total_count = len(combined_tasks) + statistic_info.update({ + "uploaded": total_uploaded, + "downloaded": total_downloaded, + "deleted": total_deleted, + "unarchived": total_unarchived, + "count": total_count, + "active": active_count, + "active_uploaded": active_uploaded, + "active_downloaded": active_downloaded + }) + + logger.info(f"刷流任务统计数据,总任务数:{total_count},活跃任务数:{active_count},已删除:{total_deleted}," + f"待归档:{total_unarchived}," + f"活跃上传量:{StringUtils.str_filesize(active_uploaded)}," + f"活跃下载量:{StringUtils.str_filesize(active_downloaded)}," + f"总上传量:{StringUtils.str_filesize(total_uploaded)}," + f"总下载量:{StringUtils.str_filesize(total_downloaded)}") + + self.save_data("statistic", statistic_info) + self.save_data("torrents", torrent_tasks) + + def __get_brush_config(self, sitename: str = None) -> BrushConfig: + """ + 获取BrushConfig + """ + return self._brush_config if not sitename else self._brush_config.get_site_config(sitename=sitename) + + def __validate_and_fix_config(self, config: dict = None) -> bool: + """ + 检查并修正配置值 + """ + if config is None: + logger.error("配置为None,无法验证和修正") + return False + + # 设置一个标志,用于跟踪是否发现校验错误 + found_error = False + + config_number_attr_to_desc = { + "disksize": "保种体积", + "maxupspeed": "总上传带宽", + "maxdlspeed": "总下载带宽", + "maxdlcount": "同时下载任务数", + "seed_time": "做种时间", + "hr_seed_time": "H&R做种时间", + "seed_ratio": "分享率", + "seed_size": "上传量", + "download_time": "下载超时时间", + "seed_avgspeed": "平均上传速度", + "seed_inactivetime": "未活动时间", + "up_speed": "单任务上传限速", + "dl_speed": "单任务下载限速", + "auto_archive_days": "自动清理记录天数" + } + + config_range_number_attr_to_desc = { + "pubtime": "发布时间", + "size": "种子大小", + "seeder": "做种人数", + "delete_size_range": "动态删种阈值" + } + + for attr, desc in config_number_attr_to_desc.items(): + value = config.get(attr) + if value and not self.__is_number(value): + self.__log_and_notify_error(f"站点刷流任务出错,{desc}设置错误:{value}") + config[attr] = None + found_error = True # 更新错误标志 + + for attr, desc in config_range_number_attr_to_desc.items(): + value = config.get(attr) + # 检查 value 是否存在且是否符合数字或数字-数字的模式 + if value and not self.__is_number_or_range(str(value)): + self.__log_and_notify_error(f"站点刷流任务出错,{desc}设置错误:{value}") + config[attr] = None + found_error = True # 更新错误标志 + + active_time_range = config.get("active_time_range") + if active_time_range and not self.__is_valid_time_range(time_range=active_time_range): + self.__log_and_notify_error(f"站点刷流任务出错,开启时间段设置错误:{active_time_range}") + config["active_time_range"] = None + found_error = True # 更新错误标志 + + # 如果发现任何错误,返回False;否则返回True + return not found_error + + def __update_config(self, brush_config: BrushConfig = None): + """ + 根据传入的BrushConfig实例更新配置 + """ + if brush_config is None: + brush_config = self._brush_config + + if brush_config is None: + return + + # 创建一个将配置属性名称映射到BrushConfig属性值的字典 + config_mapping = { + "onlyonce": brush_config.onlyonce, + "enabled": brush_config.enabled, + "notify": brush_config.notify, + "brushsites": brush_config.brushsites, + "downloader": brush_config.downloader, + "disksize": brush_config.disksize, + "freeleech": brush_config.freeleech, + "hr": brush_config.hr, + "maxupspeed": brush_config.maxupspeed, + "maxdlspeed": brush_config.maxdlspeed, + "maxdlcount": brush_config.maxdlcount, + "include": brush_config.include, + "exclude": brush_config.exclude, + "size": brush_config.size, + "seeder": brush_config.seeder, + "pubtime": brush_config.pubtime, + "seed_time": brush_config.seed_time, + "hr_seed_time": brush_config.hr_seed_time, + "seed_ratio": brush_config.seed_ratio, + "seed_size": brush_config.seed_size, + "download_time": brush_config.download_time, + "seed_avgspeed": brush_config.seed_avgspeed, + "seed_inactivetime": brush_config.seed_inactivetime, + "delete_size_range": brush_config.delete_size_range, + "up_speed": brush_config.up_speed, + "dl_speed": brush_config.dl_speed, + "auto_archive_days": brush_config.auto_archive_days, + "save_path": brush_config.save_path, + "clear_task": brush_config.clear_task, + "archive_task": brush_config.archive_task, + "delete_except_tags": brush_config.delete_except_tags, + "except_subscribe": brush_config.except_subscribe, + "brush_sequential": brush_config.brush_sequential, + "proxy_download": brush_config.proxy_download, + "proxy_delete": brush_config.proxy_delete, + "active_time_range": brush_config.active_time_range, + "downloader_monitor": brush_config.downloader_monitor, + "qb_category": brush_config.qb_category, + "auto_qb_category": brush_config.auto_qb_category, + "qb_first_last_piece": brush_config.qb_first_last_piece, + "enable_site_config": brush_config.enable_site_config, + "site_config": brush_config.site_config, + "_tabs": self._tabs + } + + # 使用update_config方法或其等效方法更新配置 + self.update_config(config_mapping) + + def __setup_downloader(self): + """ + 根据下载器类型初始化下载器实例 + """ + brush_config = self.__get_brush_config() + self.qb = Qbittorrent() + self.tr = Transmission() + + if brush_config.downloader == "qbittorrent": + if self.qb.is_inactive(): + self.__log_and_notify_error("站点刷流任务出错:Qbittorrent未连接") + return False + + elif brush_config.downloader == "transmission": + + if self.tr.is_inactive(): + self.__log_and_notify_error("站点刷流任务出错:Transmission未连接") + return False + + return True + + def __get_downloader(self, dtype: str) -> Optional[Union[Transmission, Qbittorrent]]: + """ + 根据类型返回下载器实例 + """ + if dtype == "qbittorrent": + return self.qb + elif dtype == "transmission": + return self.tr + else: + return None + + @staticmethod + def __get_redict_url(url: str, proxies: str = None, ua: str = None, cookie: str = None) -> Optional[str]: + """ + 获取下载链接, url格式:[base64]url + """ + # 获取[]中的内容 + m = re.search(r"\[(.*)](.*)", url) + if m: + # 参数 + base64_str = m.group(1) + # URL + url = m.group(2) + if not base64_str: + return url + # 解码参数 + req_str = base64.b64decode(base64_str.encode('utf-8')).decode('utf-8') + req_params: Dict[str, dict] = json.loads(req_str) + # 是否使用cookie + if not req_params.get('cookie'): + cookie = None + # 请求头 + if req_params.get('header'): + headers = req_params.get('header') + else: + headers = None + if req_params.get('method') == 'get': + # GET请求 + res = RequestUtils( + ua=ua, + proxies=proxies, + cookies=cookie, + headers=headers + ).get_res(url, params=req_params.get('params')) + else: + # POST请求 + res = RequestUtils( + ua=ua, + proxies=proxies, + cookies=cookie, + headers=headers + ).post_res(url, params=req_params.get('params')) + if not res: + return None + if not req_params.get('result'): + return res.text + else: + data = res.json() + for key in str(req_params.get('result')).split("."): + data = data.get(key) + if not data: + return None + logger.debug(f"获取到下载地址:{data}") + return data + return None + + def __download(self, torrent: TorrentInfo) -> Optional[str]: + """ + 添加下载任务 + """ + if not torrent.enclosure: + logger.error(f"获取下载链接失败:{torrent.title}") + return None + + brush_config = self.__get_brush_config(torrent.site_name) + + # 上传限速 + up_speed = int(brush_config.up_speed) if brush_config.up_speed else None + # 下载限速 + down_speed = int(brush_config.dl_speed) if brush_config.dl_speed else None + # 保存地址 + download_dir = brush_config.save_path or None + # 获取下载链接 + torrent_content = torrent.enclosure + # proxies + proxies = settings.PROXY if torrent.site_proxy else None + # cookie + cookies = torrent.site_cookie + if torrent_content.startswith("["): + torrent_content = self.__get_redict_url(url=torrent_content, + proxies=proxies, + ua=torrent.site_ua, + cookie=cookies) + # 目前馒头请求实际种子时,不能传入Cookie + cookies = None + if not torrent_content: + logger.error(f"获取下载链接失败:{torrent.title}") + return None + + if brush_config.downloader == "qbittorrent": + if not self.qb: + return None + # 限速值转为bytes + up_speed = up_speed * 1024 if up_speed else None + down_speed = down_speed * 1024 if down_speed else None + # 生成随机Tag + tag = StringUtils.generate_random_str(10) + # 如果开启代理下载以及种子地址不是磁力地址,则请求种子到内存再传入下载器 + if brush_config.proxy_download and not torrent_content.startswith("magnet"): + response = RequestUtils(cookies=cookies, + proxies=proxies, + ua=torrent.site_ua).get_res(url=torrent_content) + if response and response.ok: + torrent_content = response.content + else: + logger.error('尝试通过MP下载种子失败,继续尝试传递种子地址到下载器进行下载') + if torrent_content: + state = self.__qb_add_torrent(content=torrent_content, + download_dir=download_dir, + cookie=cookies, + tag=["已整理", brush_config.brush_tag, tag], + category=brush_config.qb_category, + is_auto=brush_config.auto_qb_category, + is_first_last_piece_priority=brush_config.qb_first_last_piece, + upload_limit=up_speed, + download_limit=down_speed) + if not state: + return None + else: + # 获取种子Hash + torrent_hash = self.qb.get_torrent_id_by_tag(tags=tag) + if not torrent_hash: + logger.error(f"{brush_config.downloader} 获取种子Hash失败,详细信息请查看 README") + return None + return torrent_hash + return None + + elif brush_config.downloader == "transmission": + if not self.tr: + return None + # 如果开启代理下载以及种子地址不是磁力地址,则请求种子到内存再传入下载器 + if brush_config.proxy_download and not torrent_content.startswith("magnet"): + response = RequestUtils(cookies=cookies, + proxies=proxies, + ua=torrent.site_ua).get_res(url=torrent_content) + if response and response.ok: + torrent_content = response.content + else: + logger.error('尝试通过MP下载种子失败,继续尝试传递种子地址到下载器进行下载') + if torrent_content: + torrent = self.tr.add_torrent(content=torrent_content, + download_dir=download_dir, + cookie=cookies, + labels=["已整理", brush_config.brush_tag]) + if not torrent: + return None + else: + if brush_config.up_speed or brush_config.dl_speed: + self.tr.change_torrent(hash_string=torrent.hashString, + upload_limit=up_speed, + download_limit=down_speed) + return torrent.hashString + return None + + def __qb_add_torrent(self, + content: Union[str, bytes], + is_paused: bool = False, + download_dir: str = None, + tag: Union[str, list] = None, + category: str = None, + cookie=None, + is_auto=False, + is_first_last_piece_priority=False, + **kwargs + ) -> bool: + """ + 添加种子 + :param content: 种子urls或文件内容 + :param is_paused: 添加后暂停 + :param tag: 标签 + :param category: 种子分类 + :param download_dir: 下载路径 + :param cookie: 站点Cookie用于辅助下载种子 + :return: bool + """ + if not self.qb.qbc or not content: + return False + + # 下载内容 + if isinstance(content, str): + urls = content + torrent_files = None + else: + urls = None + torrent_files = content + + # 保存目录 + if download_dir: + save_path = download_dir + else: + save_path = None + + # 标签 + if tag: + tags = tag + else: + tags = None + + try: + # 添加下载 + qbc_ret = self.qb.qbc.torrents_add(urls=urls, + torrent_files=torrent_files, + save_path=save_path, + is_paused=is_paused, + tags=tags, + use_auto_torrent_management=is_auto, + is_first_last_piece_priority=is_first_last_piece_priority, + cookie=cookie, + category=category, + **kwargs) + return True if qbc_ret and str(qbc_ret).find("Ok") != -1 else False + except Exception as err: + logger.error(f"添加种子出错:{str(err)}") + return False + + def __qb_torrents_reannounce(self, torrent_hashes: List[str]): + """强制重新汇报""" + if not self.qb.qbc: + return + + if not torrent_hashes: + return + + try: + # 重新汇报 + self.qb.qbc.torrents_reannounce(torrent_hashes=torrent_hashes) + except Exception as err: + logger.error(f"强制重新汇报失败:{str(err)}") + + def __get_hash(self, torrent: Any): + """ + 获取种子hash + """ + brush_config = self.__get_brush_config() + try: + return torrent.get("hash") if brush_config.downloader == "qbittorrent" else torrent.hashString + except Exception as e: + print(str(e)) + return "" + + def __get_all_hashes(self, torrents): + """ + 获取torrents列表中所有种子的Hash值 + + :param torrents: 包含种子信息的列表 + :return: 包含所有Hash值的列表 + """ + brush_config = self.__get_brush_config() + try: + all_hashes = [] + for torrent in torrents: + # 根据下载器类型获取Hash值 + hash_value = torrent.get("hash") if brush_config.downloader == "qbittorrent" else torrent.hashString + if hash_value: + all_hashes.append(hash_value) + return all_hashes + except Exception as e: + print(str(e)) + return [] + + def __get_label(self, torrent: Any): + """ + 获取种子标签 + """ + brush_config = self.__get_brush_config() + try: + return [str(tag).strip() for tag in torrent.get("tags").split(',')] \ + if brush_config.downloader == "qbittorrent" else torrent.labels or [] + except Exception as e: + print(str(e)) + return [] + + def __get_torrent_info(self, torrent: Any) -> dict: + """ + 获取种子信息 + """ + date_now = int(time.time()) + brush_config = self.__get_brush_config() + # QB + if brush_config.downloader == "qbittorrent": + """ + { + "added_on": 1693359031, + "amount_left": 0, + "auto_tmm": false, + "availability": -1, + "category": "tJU", + "completed": 67759229411, + "completion_on": 1693609350, + "content_path": "/mnt/sdb/qb/downloads/Steel.Division.2.Men.of.Steel-RUNE", + "dl_limit": -1, + "dlspeed": 0, + "download_path": "", + "downloaded": 67767365851, + "downloaded_session": 0, + "eta": 8640000, + "f_l_piece_prio": false, + "force_start": false, + "hash": "116bc6f3efa6f3b21a06ce8f1cc71875", + "infohash_v1": "116bc6f306c40e072bde8f1cc71875", + "infohash_v2": "", + "last_activity": 1693609350, + "magnet_uri": "magnet:?xt=", + "max_ratio": -1, + "max_seeding_time": -1, + "name": "Steel.Division.2.Men.of.Steel-RUNE", + "num_complete": 1, + "num_incomplete": 0, + "num_leechs": 0, + "num_seeds": 0, + "priority": 0, + "progress": 1, + "ratio": 0, + "ratio_limit": -2, + "save_path": "/mnt/sdb/qb/downloads", + "seeding_time": 615035, + "seeding_time_limit": -2, + "seen_complete": 1693609350, + "seq_dl": false, + "size": 67759229411, + "state": "stalledUP", + "super_seeding": false, + "tags": "", + "time_active": 865354, + "total_size": 67759229411, + "tracker": "https://tracker", + "trackers_count": 2, + "up_limit": -1, + "uploaded": 0, + "uploaded_session": 0, + "upspeed": 0 + } + """ + # ID + torrent_id = torrent.get("hash") + # 标题 + torrent_title = torrent.get("name") + # 下载时间 + if (not torrent.get("added_on") + or torrent.get("added_on") < 0): + dltime = 0 + else: + dltime = date_now - torrent.get("added_on") + # 做种时间 + if (not torrent.get("completion_on") + or torrent.get("completion_on") < 0): + seeding_time = 0 + else: + seeding_time = date_now - torrent.get("completion_on") + # 分享率 + ratio = torrent.get("ratio") or 0 + # 上传量 + uploaded = torrent.get("uploaded") or 0 + # 平均上传速度 Byte/s + if dltime: + avg_upspeed = int(uploaded / dltime) + else: + avg_upspeed = uploaded + # 已未活动 秒 + if (not torrent.get("last_activity") + or torrent.get("last_activity") < 0): + iatime = 0 + else: + iatime = date_now - torrent.get("last_activity") + # 下载量 + downloaded = torrent.get("downloaded") + # 种子大小 + total_size = torrent.get("total_size") + # 添加时间 + add_on = (torrent.get("added_on") or 0) + add_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(add_on)) + # 种子标签 + tags = torrent.get("tags") + # tracker + tracker = torrent.get("tracker") + # TR + else: + # ID + torrent_id = torrent.hashString + # 标题 + torrent_title = torrent.name + # 做种时间 + if (not torrent.date_done + or torrent.date_done.timestamp() < 1): + seeding_time = 0 + else: + seeding_time = date_now - int(torrent.date_done.timestamp()) + # 下载耗时 + if (not torrent.date_added + or torrent.date_added.timestamp() < 1): + dltime = 0 + else: + dltime = date_now - int(torrent.date_added.timestamp()) + # 下载量 + downloaded = int(torrent.total_size * torrent.progress / 100) + # 分享率 + ratio = torrent.ratio or 0 + # 上传量 + uploaded = int(downloaded * torrent.ratio) + # 平均上传速度 + if dltime: + avg_upspeed = int(uploaded / dltime) + else: + avg_upspeed = uploaded + # 未活动时间 + if (not torrent.date_active + or torrent.date_active.timestamp() < 1): + iatime = 0 + else: + iatime = date_now - int(torrent.date_active.timestamp()) + # 种子大小 + total_size = torrent.total_size + # 添加时间 + add_on = (torrent.date_added.timestamp() if torrent.date_added else 0) + add_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(add_on)) + # 种子标签 + tags = torrent.get("tags") + # tracker + tracker = torrent.get("tracker") + + return { + "hash": torrent_id, + "title": torrent_title, + "seeding_time": seeding_time, + "ratio": ratio, + "uploaded": uploaded, + "downloaded": downloaded, + "avg_upspeed": avg_upspeed, + "iatime": iatime, + "dltime": dltime, + "total_size": total_size, + "add_time": add_time, + "add_on": add_on, + "tags": tags, + "tracker": tracker + } + + def __log_and_notify_error(self, message): + """ + 记录错误日志并发送系统通知 + """ + logger.error(message) + self.systemmessage.put(message, title="站点刷流") + + def __send_delete_message(self, site_name: str, torrent_title: str, torrent_desc: str, reason: str, + title: str = "【刷流任务种子删除】"): + """ + 发送删除种子的消息 + """ + brush_config = self.__get_brush_config() + if not brush_config.notify: + return + msg_text = "" + if site_name: + msg_text = f"站点:{site_name}" + if torrent_title: + msg_text = f"{msg_text}\n标题:{torrent_title}" + if torrent_desc: + msg_text = f"{msg_text}\n内容:{torrent_desc}" + if reason: + msg_text = f"{msg_text}\n原因:{reason}" + + self.post_message(mtype=NotificationType.SiteMessage, title=title, text=msg_text) + + @staticmethod + def __build_add_message_text(torrent): + """ + 构建消息文本,兼容TorrentInfo对象和torrent_task字典 + """ + + # 定义一个辅助函数来统一获取数据的方式 + def get_data(_key, default=None): + if isinstance(torrent, dict): + return torrent.get(_key, default) + else: + return getattr(torrent, _key, default) + + # 构造消息文本,确保使用中文标签 + msg_parts = [] + label_mapping = { + "site_name": "站点", + "title": "标题", + "description": "内容", + "size": "大小", + "pubdate": "发布时间", + "seeders": "做种数", + "volume_factor": "促销", + "hit_and_run": "Hit&Run" + } + for key in label_mapping: + value = get_data(key) + if key == "size" and value and str(value).replace(".", "", 1).isdigit(): + value = StringUtils.str_filesize(value) + if value: + msg_parts.append(f"{label_mapping[key]}:{'是' if key == 'hit_and_run' and value else value}") + + return "\n".join(msg_parts) + + def __send_add_message(self, torrent, title: str = "【刷流任务种子下载】"): + """ + 发送添加下载的消息 + """ + brush_config = self.__get_brush_config() + if not brush_config.notify: + return + + # 使用辅助方法构建消息文本 + msg_text = self.__build_add_message_text(torrent) + self.post_message(mtype=NotificationType.SiteMessage, title=title, text=msg_text) + + def __send_message(self, title: str, text: str): + """ + 发送消息 + """ + brush_config = self.__get_brush_config() + if not brush_config.notify: + return + + self.post_message(mtype=NotificationType.SiteMessage, title=title, text=text) + + def __log_and_send_torrent_task_update_message(self, title: str, status: str, reason: str, + torrent_tasks: List[dict]): + """ + 记录和发送刷流任务更新消息 + """ + if torrent_tasks: + sites_names = ', '.join({task.get("site_name", "N/A") for task in torrent_tasks}) + first_title = torrent_tasks[0].get('title', 'N/A') + count = len(torrent_tasks) + msg = f"站点:{sites_names}\n内容:{first_title} 等 {count} 个种子已经{status}\n原因:{reason}" + logger.info(f"{title},{msg}") + self.__send_message(title=title, text=msg) + + def __get_torrents_size(self) -> int: + """ + 获取任务中的种子总大小 + """ + # 读取种子记录 + task_info = self.get_data("torrents") or {} + if not task_info: + return 0 + total_size = sum([task.get("size") or 0 for task in task_info.values()]) + return total_size + + def __get_downloader_info(self) -> schemas.DownloaderInfo: + """ + 获取下载器实时信息(所有下载器) + """ + ret_info = schemas.DownloaderInfo() + + # Qbittorrent + if self.qb: + info = self.qb.transfer_info() + if info: + ret_info.download_speed += info.get("dl_info_speed") + ret_info.upload_speed += info.get("up_info_speed") + ret_info.download_size += info.get("dl_info_data") + ret_info.upload_size += info.get("up_info_data") + + # Transmission + if self.tr: + info = self.tr.transfer_info() + if info: + ret_info.download_speed += info.download_speed + ret_info.upload_speed += info.upload_speed + ret_info.download_size += info.current_stats.downloaded_bytes + ret_info.upload_size += info.current_stats.uploaded_bytes + + return ret_info + + def __get_downloading_count(self) -> int: + """ + 获取正在下载的任务数量 + """ + try: + brush_config = self.__get_brush_config() + downloader = self.__get_downloader(brush_config.downloader) + if not downloader: + return 0 + + torrents = downloader.get_downloading_torrents(tags=brush_config.brush_tag) + if torrents is None: + logger.warn("获取下载数量失败,可能是下载器连接发生异常") + return 0 + + return len(torrents) + except Exception as e: + logger.error(f"获取下载数量发生异常: {e}") + return 0 + + @staticmethod + def __get_pubminutes(pubdate: str) -> float: + """ + 将字符串转换为时间,并计算与当前时间差)(分钟) + """ + try: + if not pubdate: + return 0 + pubdate = pubdate.replace("T", " ").replace("Z", "") + pubdate = datetime.strptime(pubdate, "%Y-%m-%d %H:%M:%S") + now = datetime.now() + return (now - pubdate).total_seconds() // 60 + except Exception as e: + logger.error(f"发布时间 {pubdate} 获取分钟失败,错误详情: {e}") + return 0 + + @staticmethod + def __adjust_site_pubminutes(pub_minutes: float, torrent: TorrentInfo) -> float: + """ + 处理部分站点的时区逻辑 + """ + try: + if not torrent: + return pub_minutes + + if torrent.site_name == "我堡": + # 获取当前时区的UTC偏移量(以秒为单位) + utc_offset_seconds = time.timezone + + # 将UTC偏移量转换为分钟 + utc_offset_minutes = utc_offset_seconds / 60 + + # 增加UTC偏移量到pub_minutes + adjusted_pub_minutes = pub_minutes + utc_offset_minutes + + return adjusted_pub_minutes + + return pub_minutes + except Exception as e: + logger.error(str(e)) + return 0 + + def __filter_torrents_by_tag(self, torrents: List[Any], exclude_tag: str) -> List[Any]: + """ + 根据标签过滤torrents,排除标签格式为逗号分隔的字符串,例如 "MOVIEPILOT, H&R" + """ + # 如果排除标签字符串为空,则返回原始列表 + if not exclude_tag: + return torrents + + # 将 exclude_tag 字符串分割成一个集合,并去除每个标签两端的空白,忽略空白标签并自动去重 + exclude_tags = set(tag.strip() for tag in exclude_tag.split(',') if tag.strip()) + + filter_torrents = [] + for torrent in torrents: + # 使用 __get_label 方法获取每个 torrent 的标签列表 + labels = self.__get_label(torrent) + # 检查是否有任何一个排除标签存在于标签列表中 + if not any(exclude in labels for exclude in exclude_tags): + filter_torrents.append(torrent) + return filter_torrents + + def __get_subscribe_titles(self) -> Set[str]: + """ + 获取当前订阅的所有标题,返回一个不包含None和空白字符的集合 + """ + brush_config = self.__get_brush_config() + if not brush_config.except_subscribe: + logger.info("没有开启排除订阅,取消订阅标题匹配") + return set() + + logger.info("已开启排除订阅,正在准备订阅标题匹配 ...") + + if not self._subscribe_infos: + self._subscribe_infos = {} + + subscribes = self.subscribeoper.list() + if subscribes: + # 遍历订阅 + for subscribe in subscribes: + # 判断当前订阅是否已经在缓存中,如果已经处理过,那么这里直接跳过 + subscribe_key = f"{subscribe.id}_{subscribe.name}" + if subscribe_key in self._subscribe_infos: + continue + + subscribe_titles = [subscribe.name] + try: + # 生成元数据 + meta = MetaInfo(subscribe.name) + meta.year = subscribe.year + meta.begin_season = subscribe.season or None + meta.type = MediaType(subscribe.type) + # 识别媒体信息 + mediainfo: MediaInfo = self.chain.recognize_media(meta=meta, mtype=meta.type, + tmdbid=subscribe.tmdbid, + doubanid=subscribe.doubanid, + cache=True) + if mediainfo: + logger.info(f"订阅 {subscribe.name} 已识别到媒体信息") + logger.debug(f"subscribe {subscribe.name} {mediainfo.to_dict()}") + subscribe_titles.extend(mediainfo.names) + subscribe_titles = [title.strip() for title in subscribe_titles if title and title.strip()] + self._subscribe_infos[subscribe_key] = subscribe_titles + else: + logger.info(f"订阅 {subscribe.name} 没有识别到媒体信息,跳过订阅标题匹配") + except Exception as e: + logger.error(f"识别订阅 {subscribe.name} 媒体信息失败,错误详情: {e}") + + # 移除不再存在的订阅 + current_keys = {f"{subscribe.id}_{subscribe.name}" for subscribe in subscribes} + for key in set(self._subscribe_infos) - current_keys: + del self._subscribe_infos[key] + + logger.info("订阅标题匹配完成") + logger.debug(f"当前订阅的标题集合为:{self._subscribe_infos}") + unique_titles = {title for titles in self._subscribe_infos.values() for title in titles} + return unique_titles + + @staticmethod + def __filter_torrents_contains_subscribe(torrents: Any, subscribe_titles: Set[str]): + # 初始化两个列表,一个用于收集未被排除的种子,一个用于记录被排除的种子 + included_torrents = [] + excluded_torrents = [] + + # 单次遍历处理 + for torrent in torrents: + # 确保title和description至少是空字符串 + title = torrent.title or '' + description = torrent.description or '' + + if any(subscribe_title in title or subscribe_title in description for subscribe_title in subscribe_titles): + # 如果种子的标题或描述包含订阅标题中的任一项,则记录为被排除 + excluded_torrents.append(torrent) + logger.info(f"命中订阅内容,排除种子:{title}|{description}") + else: + # 否则,收集为未被排除的种子 + included_torrents.append(torrent) + + if not excluded_torrents: + logger.info(f"没有命中订阅内容,不需要排除种子") + + # 返回未被排除的种子列表 + return included_torrents + + @staticmethod + def __bytes_to_gb(size_in_bytes: float) -> float: + """ + 将字节单位的大小转换为千兆字节(GB)。 + + :param size_in_bytes: 文件大小,单位为字节。 + :return: 文件大小,单位为千兆字节(GB)。 + """ + if not size_in_bytes: + return 0.0 + return size_in_bytes / (1024 ** 3) + + @staticmethod + def __is_number_or_range(value): + """ + 检查字符串是否表示单个数字或数字范围(如'5', '5.5', '5-10' 或 '5.5-10.2') + """ + return bool(re.match(r"^\d+(\.\d+)?(-\d+(\.\d+)?)?$", value)) + + @staticmethod + def __is_number(value): + """ + 检查给定的值是否可以被转换为数字(整数或浮点数) + """ + try: + float(value) + return True + except ValueError: + return False + + @staticmethod + def __calculate_seeding_torrents_size(torrent_tasks: Dict[str, dict]) -> float: + """ + 计算保种种子体积 + """ + return sum(task.get("size", 0) for task in torrent_tasks.values() if not task.get("deleted", False)) + + def __auto_archive_tasks(self, torrent_tasks: Dict[str, dict]) -> None: + """ + 自动归档已经删除的种子数据 + """ + if not self._brush_config.auto_archive_days or self._brush_config.auto_archive_days <= 0: + logger.info("自动归档记录天数小于等于0,取消自动归档") + return + + # 用于存储已删除的数据 + archived_tasks: Dict[str, dict] = self.get_data("archived") or {} + + current_time = time.time() + archive_threshold_seconds = self._brush_config.auto_archive_days * 86400 # 将天数转换为秒数 + + # 准备一个列表,记录所有需要从原始数据中删除的键 + keys_to_delete = set() + + # 遍历所有 torrent 条目 + for key, value in torrent_tasks.items(): + deleted_time = value.get("deleted_time") + # 场景 1: 检查任务是否已被标记为删除且超出保留天数 + if (value.get("deleted") and isinstance(deleted_time, (int, float)) and + current_time - deleted_time > archive_threshold_seconds): + keys_to_delete.add(key) + archived_tasks[key] = value + continue + + # 场景 2: 检查没有明确删除时间的历史数据 + if value.get("deleted") and deleted_time is None: + keys_to_delete.add(key) + archived_tasks[key] = value + continue + + # 从原始字典中移除已删除的条目 + for key in keys_to_delete: + del torrent_tasks[key] + + self.save_data("archived", archived_tasks) + + def __archive_tasks(self): + """ + 归档已经删除的种子数据 + """ + torrent_tasks: Dict[str, dict] = self.get_data("torrents") or {} + + # 用于存储已删除的数据 + archived_tasks: Dict[str, dict] = self.get_data("archived") or {} + + # 准备一个列表,记录所有需要从原始数据中删除的键 + keys_to_delete = set() + + # 遍历所有 torrent 条目 + for key, value in torrent_tasks.items(): + # 检查是否标记为已删除 + if value.get("deleted"): + # 如果是,加入到归档字典中 + archived_tasks[key] = value + # 记录键,稍后删除 + keys_to_delete.add(key) + + # 从原始字典中移除已删除的条目 + for key in keys_to_delete: + del torrent_tasks[key] + + self.save_data("archived", archived_tasks) + self.save_data("torrents", torrent_tasks) + # 归档需要更新一下统计数据 + self.__update_and_save_statistic_info(torrent_tasks=torrent_tasks) + + def __clear_tasks(self): + """ + 清除统计数据 + 彻底重置所有刷流数据,如当前还存在正在做种的刷流任务,待定时检查任务执行后,会自动纳入刷流管理 + """ + self.save_data("torrents", {}) + self.save_data("archived", {}) + self.save_data("unmanaged", {}) + self.save_data("statistic", {}) + + def __get_statistic_info(self) -> Dict[str, int]: + """ + 获取统计数据 + """ + statistic_info = self.get_data("statistic") or { + "count": 0, + "deleted": 0, + "uploaded": 0, + "downloaded": 0, + "unarchived": 0, + "active": 0, + "active_uploaded": 0, + "active_downloaded": 0 + } + return statistic_info + + @staticmethod + def __is_valid_time_range(time_range: str) -> bool: + """检查时间范围字符串是否有效:格式为"HH:MM-HH:MM",且时间有效""" + if not time_range: + return False + + # 使用正则表达式匹配格式 + pattern = re.compile(r'^\d{2}:\d{2}-\d{2}:\d{2}$') + if not pattern.match(time_range): + return False + + try: + start_str, end_str = time_range.split('-') + datetime.strptime(start_str, '%H:%M').time() + datetime.strptime(end_str, '%H:%M').time() + except Exception as e: + print(str(e)) + return False + + return True + + def __is_current_time_in_range(self) -> bool: + """判断当前时间是否在开启时间区间内""" + + brush_config = self.__get_brush_config() + active_time_range = brush_config.active_time_range + + if not self.__is_valid_time_range(active_time_range): + # 如果时间范围格式不正确或不存在,说明当前没有开启时间段,返回True + return True + + start_str, end_str = active_time_range.split('-') + start_time = datetime.strptime(start_str, '%H:%M').time() + end_time = datetime.strptime(end_str, '%H:%M').time() + now = datetime.now().time() + + if start_time <= end_time: + # 情况1: 时间段不跨越午夜 + return start_time <= now <= end_time + else: + # 情况2: 时间段跨越午夜 + return now >= start_time or now <= end_time + + def __get_site_by_torrent(self, torrent: Any) -> Tuple[int, str]: + """ + 根据tracker获取站点信息 + """ + trackers = [] + try: + tracker_url = torrent.get("tracker") + if tracker_url: + trackers.append(tracker_url) + + magnet_link = torrent.get("magnet_uri") + if magnet_link: + query_params: dict = parse_qs(urlparse(magnet_link).query) + encoded_tracker_urls = query_params.get('tr', []) + # 解码tracker URLs然后扩展到trackers列表中 + decoded_tracker_urls = [unquote(url) for url in encoded_tracker_urls] + trackers.extend(decoded_tracker_urls) + except Exception as e: + logger.error(e) + + domain = "未知" + if not trackers: + return 0, domain + + # 特定tracker到域名的映射 + tracker_mappings = { + "chdbits.xyz": "ptchdbits.co", + "agsvpt.trackers.work": "agsvpt.com", + "tracker.cinefiles.info": "audiences.me", + } + + for tracker in trackers: + if not tracker: + continue + # 检查tracker是否包含特定的关键字,并进行相应的映射 + for key, mapped_domain in tracker_mappings.items(): + if key in tracker: + domain = mapped_domain + break + else: + # 使用StringUtils工具类获取tracker的域名 + domain = StringUtils.get_url_domain(tracker) + + site_info = self.siteshelper.get_indexer(domain) + if site_info: + return site_info.get("id"), site_info.get("name") + + # 当找不到对应的站点信息时,返回一个默认值 + return 0, domain diff --git a/plugins.v2/cleaninvalidseed/__init__.py b/plugins.v2/cleaninvalidseed/__init__.py new file mode 100644 index 0000000..d041065 --- /dev/null +++ b/plugins.v2/cleaninvalidseed/__init__.py @@ -0,0 +1,918 @@ +import glob +import os +import shutil +import time +from datetime import datetime, timedelta +from pathlib import Path + +import pytz +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from app.modules.qbittorrent import Qbittorrent +from app.utils.string import StringUtils +from app.schemas.types import EventType +from app.core.event import eventmanager, Event + +from app.core.config import settings +from app.plugins import _PluginBase +from typing import Any, List, Dict, Tuple, Optional +from app.log import logger +from app.schemas import NotificationType + + +class CleanInvalidSeed(_PluginBase): + # 插件名称 + plugin_name = "清理QB无效做种" + # 插件描述 + plugin_desc = "清理已经被站点删除的种子及源文件,仅支持QB" + # 插件图标 + plugin_icon = "clean_a.png" + # 插件版本 + plugin_version = "2.2" + # 插件作者 + plugin_author = "DzAvril" + # 作者主页 + author_url = "https://github.com/DzAvril" + # 插件配置项ID前缀 + plugin_config_prefix = "cleaninvalidseed" + # 加载顺序 + plugin_order = 1 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled = False + _cron = None + _notify = False + _onlyonce = False + _qb = None + _detect_invalid_files = False + _delete_invalid_files = False + _delete_invalid_torrents = False + _notify_all = False + _label_only = False + _label = "" + _download_dirs = "" + _exclude_keywords = "" + _exclude_categories = "" + _exclude_labels = "" + _more_logs = False + # 定时器 + _scheduler: Optional[BackgroundScheduler] = None + _error_msg = [ + "torrent not registered with this tracker", + "Torrent not registered with this tracker", + "torrent banned", + "err torrent banned", + ] + _custom_error_msg = "" + + def init_plugin(self, config: dict = None): + # 停止现有任务 + self.stop_service() + + if config: + self._enabled = config.get("enabled") + self._cron = config.get("cron") + self._notify = config.get("notify") + self._onlyonce = config.get("onlyonce") + self._delete_invalid_torrents = config.get("delete_invalid_torrents") + self._delete_invalid_files = config.get("delete_invalid_files") + self._detect_invalid_files = config.get("detect_invalid_files") + self._notify_all = config.get("notify_all") + self._label_only = config.get("label_only") + self._label = config.get("label") + self._download_dirs = config.get("download_dirs") + self._exclude_keywords = config.get("exclude_keywords") + self._exclude_categories = config.get("exclude_categories") + self._exclude_labels = config.get("exclude_labels") + self._custom_error_msg = config.get("custom_error_msg") + self._more_logs = config.get("more_logs") + self._qb = Qbittorrent() + + # 加载模块 + if self._onlyonce: + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + logger.info(f"清理无效种子服务启动,立即运行一次") + self._scheduler.add_job( + func=self.clean_invalid_seed, + trigger="date", + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + + timedelta(seconds=3), + name="清理无效种子", + ) + # 关闭一次性开关 + self._onlyonce = False + self._update_config() + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def get_state(self) -> bool: + return self._enabled + + def _update_config(self): + self.update_config( + { + "onlyonce": False, + "cron": self._cron, + "enabled": self._enabled, + "notify": self._notify, + "delete_invalid_torrents": self._delete_invalid_torrents, + "delete_invalid_files": self._delete_invalid_files, + "detect_invalid_files": self._detect_invalid_files, + "notify_all": self._notify_all, + "label_only": self._label_only, + "label": self._label, + "download_dirs": self._download_dirs, + "exclude_keywords": self._exclude_keywords, + "exclude_categories": self._exclude_categories, + "exclude_labels": self._exclude_labels, + "custom_error_msg": self._custom_error_msg, + "more_logs": self._more_logs, + } + ) + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + """ + 定义远程控制命令 + :return: 命令关键字、事件、描述、附带数据 + """ + return [ + { + "cmd": "/detect_invalid_torrents", + "event": EventType.PluginAction, + "desc": "检测无效做种", + "category": "QB", + "data": {"action": "detect_invalid_torrents"}, + }, + { + "cmd": "/delete_invalid_torrents", + "event": EventType.PluginAction, + "desc": "清理无效做种", + "category": "QB", + "data": {"action": "delete_invalid_torrents"}, + }, + { + "cmd": "/detect_invalid_files", + "event": EventType.PluginAction, + "desc": "检测无效源文件", + "category": "QB", + "data": {"action": "detect_invalid_files"}, + }, + { + "cmd": "/delete_invalid_files", + "event": EventType.PluginAction, + "desc": "清理无效源文件", + "category": "QB", + "data": {"action": "delete_invalid_files"}, + }, + { + "cmd": "/toggle_notify_all", + "event": EventType.PluginAction, + "desc": "QB清理插件切换全量通知", + "category": "QB", + "data": {"action": "toggle_notify_all"}, + }, + ] + + @eventmanager.register(EventType.PluginAction) + def handle_commands(self, event: Event): + if event: + event_data = event.event_data + if event_data: + if not ( + event_data.get("action") == "detect_invalid_torrents" + or event_data.get("action") == "delete_invalid_torrents" + or event_data.get("action") == "detect_invalid_files" + or event_data.get("action") == "delete_invalid_files" + or event_data.get("action") == "toggle_notify_all" + ): + return + self.post_message( + channel=event.event_data.get("channel"), + title="开始执行远程命令...", + userid=event.event_data.get("user"), + ) + old_delete_invalid_torrents = self._delete_invalid_torrents + old_detect_invalid_files = self._detect_invalid_files + old_delete_invalid_files = self._delete_invalid_files + if event_data.get("action") == "detect_invalid_torrents": + logger.info("收到远程命令,开始检测无效做种") + self._delete_invalid_torrents = False + self._detect_invalid_files = False + self._delete_invalid_files = False + self.clean_invalid_seed() + elif event_data.get("action") == "delete_invalid_torrents": + logger.info("收到远程命令,开始清理无效做种") + self._delete_invalid_torrents = True + self._detect_invalid_files = False + self._delete_invalid_files = False + self.clean_invalid_seed() + elif event_data.get("action") == "detect_invalid_files": + logger.info("收到远程命令,开始检测无效源文件") + self._delete_invalid_files = False + self.detect_invalid_files() + elif event_data.get("action") == "delete_invalid_files": + logger.info("收到远程命令,开始清理无效源文件") + self._delete_invalid_files = True + self.detect_invalid_files() + elif event_data.get("action") == "toggle_notify_all": + self._notify_all = not self._notify_all + self._update_config() + if self._notify_all: + self.post_message( + channel=event.event_data.get("channel"), + title="已开启全量通知", + userid=event.event_data.get("user"), + ) + else: + self.post_message( + channel=event.event_data.get("channel"), + title="已关闭全量通知", + userid=event.event_data.get("user"), + ) + return + else: + logger.error("收到未知远程命令") + return + self._delete_invalid_torrents = old_delete_invalid_torrents + self._detect_invalid_files = old_detect_invalid_files + self._delete_invalid_files = old_delete_invalid_files + self.post_message( + channel=event.event_data.get("channel"), + title="远程命令执行完成!", + userid=event.event_data.get("user"), + ) + + 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 and self._cron: + return [ + { + "id": "CleanInvalidSeed", + "name": "清理QB无效做种", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self.clean_invalid_seed, + "kwargs": {}, + } + ] + + def get_all_torrents(self): + all_torrents, error = self._qb.get_torrents() + if error: + logger.error(f"获取QB种子失败: {error}") + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【清理QB无效做种】", + text=f"获取QB种子失败,请检查QB配置", + ) + return [] + + if not all_torrents: + logger.warning("QB没有种子") + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【清理QB无效做种】", + text=f"QB中没有种子", + ) + return [] + return all_torrents + + def clean_invalid_seed(self): + logger.info("开始清理QB无效做种") + all_torrents = self.get_all_torrents() + temp_invalid_torrents = [] + # tracker未工作,但暂时不能判定为失效做种,需人工判断 + tracker_not_working_torrents = [] + working_tracker_set = set() + exclude_categories = ( + self._exclude_categories.split("\n") if self._exclude_categories else [] + ) + exclude_labels = ( + self._exclude_labels.split("\n") if self._exclude_labels else [] + ) + custom_msgs = ( + self._custom_error_msg.split("\n") if self._custom_error_msg else [] + ) + error_msgs = self._error_msg + custom_msgs + # 第一轮筛选出所有未工作的种子 + for torrent in all_torrents: + trackers = torrent.trackers + is_invalid = True + is_tracker_working = False + for tracker in trackers: + if tracker.get("tier") == -1: + continue + tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1] + # 有一个tracker工作即为有效做种 + if (tracker.get("status") == 2) or (tracker.get("status") == 3): + is_tracker_working = True + + if not ( + (tracker.get("status") == 4) and (tracker.get("msg") in error_msgs) + ): + is_invalid = False + working_tracker_set.add(tracker_domian) + + if self._more_logs: + logger.info(f"处理 [{torrent.name}] tracker [{tracker_domian}]: 分类: [{torrent.category}], 标签: [{torrent.tags}], 状态: [{tracker.get('status')}], msg: [{tracker.get('msg')}], is_invalid: [{is_invalid}], is_working: [{is_tracker_working}]") + if is_invalid: + temp_invalid_torrents.append(torrent) + elif not is_tracker_working: + # 排除已暂停的种子 + if not torrent.state_enum.is_paused: + tracker_not_working_torrents.append(torrent) + + logger.info(f"初筛共有{len(temp_invalid_torrents)}个无效做种") + # 第二轮筛选出tracker有正常工作种子而当前种子未工作的,避免因临时关站或tracker失效导致误删的问题 + # 失效做种但通过种子分类排除的种子 + invalid_torrents_exclude_categories = [] + # 失效做种但通过种子标签排除的种子 + invalid_torrents_exclude_labels = [] + # 将invalid_torrents基本信息保存起来,在种子被删除后依然可以打印这些信息 + invalid_torrent_tuple_list = [] + deleted_torrent_tuple_list = [] + for torrent in temp_invalid_torrents: + trackers = torrent.trackers + for tracker in trackers: + if tracker.get("tier") == -1: + continue + tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1] + if tracker_domian in working_tracker_set: + # tracker是正常的,说明该种子是无效的 + invalid_torrent_tuple_list.append( + ( + torrent.name, + torrent.category, + torrent.tags, + torrent.size, + tracker_domian, + tracker.msg, + ) + ) + if self._delete_invalid_torrents or self._label_only: + # 检查种子分类和标签是否排除 + is_excluded = False + if torrent.category in exclude_categories: + is_excluded = True + invalid_torrents_exclude_categories.append(torrent) + torrent_labels = [ + tag.strip() for tag in torrent.tags.split(",") + ] + for label in torrent_labels: + if label in exclude_labels: + is_excluded = True + invalid_torrents_exclude_labels.append(torrent) + if not is_excluded: + if self._label_only: + # 仅标记 + self._qb.set_torrents_tag(ids=torrent.get("hash"), tags=[self._label if self._label != "" else "无效做种"]) + else: + # 只删除种子不删除文件,以防其它站点辅种 + self._qb.delete_torrents(False, torrent.get("hash")) + # 标记已处理种子信息 + deleted_torrent_tuple_list.append( + ( + torrent.name, + torrent.category, + torrent.tags, + torrent.size, + tracker_domian, + tracker.msg, + ) + ) + break + invalid_msg = f"检测到{len(invalid_torrent_tuple_list)}个失效做种\n" + tracker_not_working_msg = f"检测到{len(tracker_not_working_torrents)}个tracker未工作做种,请检查种子状态\n" + + if self._label_only or self._delete_invalid_torrents: + if self._label_only: + deleted_msg = f"标记了{len(deleted_torrent_tuple_list)}个失效种子\n" + else: + deleted_msg = f"删除了{len(deleted_torrent_tuple_list)}个失效种子\n" + if len(exclude_categories) != 0: + exclude_categories_msg = f"分类排除{len(invalid_torrents_exclude_categories)}个失效种子未删除,请手动处理\n" + if len(exclude_labels) != 0: + exclude_labels_msg = f"标签排除{len(invalid_torrents_exclude_labels)}个失效种子未删除,请手动处理\n" + for index in range(len(invalid_torrent_tuple_list)): + torrent = invalid_torrent_tuple_list[index] + invalid_msg += f"{index + 1}. {torrent[0]},分类:{torrent[1]},标签:{torrent[2]}, 大小:{StringUtils.str_filesize(torrent[3])},Trackers: {torrent[4]}:{torrent[5]}\n" + + for index in range(len(tracker_not_working_torrents)): + torrent = tracker_not_working_torrents[index] + trackers = torrent.trackers + tracker_msg = "" + for tracker in trackers: + if tracker.get("tier") == -1: + continue + tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1] + tracker_msg += f" {tracker_domian}:{tracker.msg} " + tracker_not_working_msg += f"{index + 1}. {torrent.name},分类:{torrent.category},标签:{torrent.tags}, 大小:{StringUtils.str_filesize(torrent.size)},Trackers: {tracker_msg}\n" + + for index in range(len(invalid_torrents_exclude_categories)): + torrent = invalid_torrents_exclude_categories[index] + trackers = torrent.trackers + tracker_msg = "" + for tracker in trackers: + if tracker.get("tier") == -1: + continue + tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1] + tracker_msg += f" {tracker_domian}:{tracker.msg} " + exclude_categories_msg += f"{index + 1}. {torrent.name},分类:{torrent.category},标签:{torrent.tags}, 大小:{StringUtils.str_filesize(torrent.size)},Trackers: {tracker_msg}\n" + + for index in range(len(invalid_torrents_exclude_labels)): + torrent = invalid_torrents_exclude_labels[index] + trackers = torrent.trackers + tracker_msg = "" + for tracker in trackers: + if tracker.get("tier") == -1: + continue + tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1] + tracker_msg += f" {tracker_domian}:{tracker.msg} " + exclude_labels_msg += f"{index + 1}. {torrent.name},分类:{torrent.category},标签:{torrent.tags}, 大小:{StringUtils.str_filesize(torrent.size)},Trackers: {tracker_msg}\n" + + for index in range(len(deleted_torrent_tuple_list)): + torrent = deleted_torrent_tuple_list[index] + deleted_msg += f"{index + 1}. {torrent[0]},分类:{torrent[1]},标签:{torrent[2]}, 大小:{StringUtils.str_filesize(torrent[3])},Trackers: {torrent[4]}:{torrent[5]}\n" + + # 日志 + logger.info(invalid_msg) + logger.info(tracker_not_working_msg) + if self._delete_invalid_torrents: + logger.info(deleted_msg) + if len(exclude_categories) != 0: + logger.info(exclude_categories_msg) + if len(exclude_labels) != 0: + logger.info(exclude_labels_msg) + # 通知 + if self._notify: + invalid_msg = invalid_msg.replace("_", "\_") + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【清理无效做种】", + text=invalid_msg, + ) + if self._notify_all: + tracker_not_working_msg = tracker_not_working_msg.replace("_", "\_") + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【清理无效做种】", + text=tracker_not_working_msg, + ) + if self._label_only or self._delete_invalid_torrents: + deleted_msg = deleted_msg.replace("_", "\_") + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【清理无效做种】", + text=deleted_msg, + ) + if self._notify_all: + exclude_categories_msg = exclude_categories_msg.replace("_", "\_") + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【清理无效做种】", + text=exclude_categories_msg, + ) + exclude_labels_msg = exclude_labels_msg.replace("_", "\_") + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【清理无效做种】", + text=exclude_labels_msg, + ) + logger.info("检测无效做种任务结束") + if self._detect_invalid_files: + self.detect_invalid_files() + + def detect_invalid_files(self): + logger.info("开始检测未做种的无效源文件") + all_torrents = self.get_all_torrents() + source_path_map = {} + source_paths = [] + total_size = 0 + deleted_file_cnt = 0 + exclude_key_words = ( + self._exclude_keywords.split("\n") if self._exclude_keywords else [] + ) + if not self._download_dirs: + logger.error("未配置下载目录,无法检测未做种无效源文件") + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【检测无效源文件】", + text="未配置下载目录,无法检测未做种无效源文件", + ) + return + for path in self._download_dirs.split("\n"): + mp_path, qb_path = path.split(":") + source_path_map[mp_path] = qb_path + source_paths.append(mp_path) + # 所有做种源文件路径 + content_path_set = set() + for torrent in all_torrents: + content_path_set.add(torrent.content_path) + + message = "检测未做种无效源文件:\n" + for source_path_str in source_paths: + source_path = Path(source_path_str) + # 判断source_path是否存在 + if not source_path.exists(): + logger.error(f"{source_path} 不存在,无法检测未做种无效源文件") + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【检测无效源文件】", + text=f"{source_path} 不存在,无法检测未做种无效源文件", + ) + continue + source_files = [] + # 获取source_path下的所有文件包括文件夹 + for file in source_path.iterdir(): + source_files.append(file) + for source_file in source_files: + skip = False + for key_word in exclude_key_words: + if key_word in source_file.name: + logger.info(f"{str(source_file)}命中关键字{key_word},不做处理") + skip = True + break + if skip: + continue + # 将mp_path替换成 qb_path + qb_path = (str(source_file)).replace( + source_path_str, source_path_map[source_path_str] + ) + # todo: 优化性能 + is_exist = False + for content_path in content_path_set: + if qb_path in content_path: + is_exist = True + break + + if not is_exist: + deleted_file_cnt += 1 + message += f"{deleted_file_cnt}. {str(source_file)}\n" + total_size += self.get_size(source_file) + if self._delete_invalid_files: + if source_file.is_file(): + source_file.unlink() + elif source_file.is_dir(): + shutil.rmtree(source_file) + + message += f"检测到{deleted_file_cnt}个未做种的无效源文件,共占用{StringUtils.str_filesize(total_size)}空间。\n" + if self._delete_invalid_files: + message += f"***已删除无效源文件,释放{StringUtils.str_filesize(total_size)}空间!***\n" + logger.info(message) + if self._notify: + message = message.replace("_", "\_") + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【清理无效做种】", + text=message, + ) + logger.info("检测无效源文件任务结束") + + def get_size(self, path: Path): + total_size = 0 + if path.is_file(): + return path.stat().st_size + # rglob 方法用于递归遍历所有文件和目录 + for entry in path.rglob("*"): + if entry.is_file(): + total_size += entry.stat().st_size + return total_size + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + return [ + { + "component": "VForm", + "content": [ + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "enabled", + "label": "启用插件", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "notify", + "label": "开启通知", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "onlyonce", + "label": "立即运行一次", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "delete_invalid_torrents", + "label": "删除无效种子(确认无误后再开启)", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "detect_invalid_files", + "label": "检测无效源文件", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "delete_invalid_files", + "label": "删除无效源文件(确认无误后再开启)", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "notify_all", + "label": "全量通知", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "label_only", + "label": "仅标记模式(开启后不会删种)", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "more_logs", + "label": "打印更多日志", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": { "cols": 12, "md": 6 }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "cron", + "label": "执行周期", + }, + } + ], + }, + { + "component": "VCol", + "props": { "cols": 12, "md": 6 }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "label", + "label": "增加标签", + "placeholder": "仅标记模式下生效,给待处理的种子打标签", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "download_dirs", + "label": "下载目录映射", + "rows": 2, + "placeholder": "填写要监控的源文件目录,并设置MP和QB的目录映射关系,如/mp/download:/qb/download,多个目录请换行", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "props": {"style": {"margin-top": "0px"}}, + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "exclude_keywords", + "label": "过滤删源文件关键字", + "rows": 2, + "placeholder": "多个关键字请换行,仅针对删除源文件", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "exclude_categories", + "label": "过滤删种分类", + "rows": 2, + "placeholder": "多个分类请换行,仅针对删除种子", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "exclude_labels", + "label": "过滤删种标签", + "rows": 2, + "placeholder": "多个标签请换行,仅针对删除种子", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "custom_error_msg", + "label": "自定义无效做种tracker错误信息", + "rows": 5, + "placeholder": "填入想要清理的种子的tracker错误信息,如'skipping tracker announce (unreachable)',多个信息请换行", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": { + "cols": 12, + }, + "content": [ + { + "component": "VAlert", + "props": { + "type": "error", + "variant": "tonal", + "text": "谨慎起见删除种子/源文件功能做了开关,请确认无误后再开启删除功能", + }, + } + ], + }, + { + "component": "VCol", + "props": { + "cols": 12, + }, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "下载目录映射填入源文件根目录,并设置MP和QB的目录映射关系。如某种子下载的源文件A存放路径为/qb/download/A,则目录映射填入/mp/download:/qb/download,多个目录请换行。注意映射目录不要有多余的'/'", + }, + } + ], + }, + ], + }, + ], + } + ], { + "enabled": False, + "notify": False, + "download_dirs": "", + "delete_invalid_torrents": False, + "delete_invalid_files": False, + "detect_invalid_files": False, + "notify_all": False, + "onlyonce": False, + "cron": "0 0 * * *", + "label_only": False, + "label": "", + "more_logs": False, + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) diff --git a/plugins.v2/clouddiskdel/__init__.py b/plugins.v2/clouddiskdel/__init__.py new file mode 100644 index 0000000..7769171 --- /dev/null +++ b/plugins.v2/clouddiskdel/__init__.py @@ -0,0 +1,540 @@ +import json +import os +import shutil +import time +from pathlib import Path + +from app.core.config import settings +from app.core.event import eventmanager, Event +from app.log import logger +from app.plugins import _PluginBase +from typing import Any, List, Dict, Tuple + +from app.schemas.types import EventType, MediaImageType, NotificationType, MediaType +from app.utils.system import SystemUtils + + +class CloudDiskDel(_PluginBase): + # 插件名称 + plugin_name = "云盘文件删除" + # 插件描述 + plugin_desc = "媒体库删除strm文件后同步删除云盘资源。" + # 插件图标 + plugin_icon = "clouddisk.png" + # 插件版本 + plugin_version = "1.3" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "clouddiskdel_" + # 加载顺序 + plugin_order = 26 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled = False + # 任务执行间隔 + _paths = {} + _notify = False + _del_history = False + + _video_formats = ('.mp4', '.avi', '.rmvb', '.wmv', '.mov', '.mkv', '.flv', '.ts', '.webm', '.iso', '.mpg') + + def init_plugin(self, config: dict = None): + if config: + self._enabled = config.get("enabled") + self._notify = config.get("notify") + self._del_history = config.get("del_history") + for path in str(config.get("path")).split("\n"): + paths = path.split(":") + self._paths[paths[0]] = paths[1] + + # 清理插件历史 + if self._del_history: + self.del_data(key="history") + self.update_config({ + "enabled": self._enabled, + "notify": self._notify, + "path": config.get("path"), + "del_history": False + }) + + @eventmanager.register(EventType.PluginAction) + def clouddisk_del(self, event: Event): + if not self._enabled: + return + if not event: + return + + event_data = event.event_data + if not event_data or event_data.get("action") != "networkdisk_del": + return + + logger.info(f"获取到云盘删除请求 {event_data}") + + media_path = event_data.get("media_path") + if not media_path: + logger.error("未获取到删除路径") + return + + media_name = event_data.get("media_name") + tmdb_id = event_data.get("tmdb_id") + media_type = event_data.get("media_type") + season_num = event_data.get("season_num") + episode_num = event_data.get("episode_num") + + # 不是网盘监控路径,直接排除 + cloud_file_flag = False + + # 判断删除媒体路径是否与配置的媒体库路径相符,相符则继续删除,不符则跳过 + for library_path in list(self._paths.keys()): + if str(media_path).startswith(library_path): + cloud_file_flag = True + # 替换网盘路径 + media_path = str(media_path).replace(library_path, self._paths.get(library_path)) + logger.info(f"获取到moviepilot本地云盘挂载路径 {media_path}") + path = Path(media_path) + if path.is_file() or media_path.endswith(".strm"): + # 删除文件、nfo、jpg等同名文件 + pattern = path.stem.replace('[', '?').replace(']', '?') + logger.info(f"开始筛选同名文件 {pattern}") + files = path.parent.glob(f"{pattern}.*") + + remove_flag = False + for file in files: + Path(file).unlink() + logger.info(f"云盘文件 {file} 已删除") + self.__remove_json(file) + remove_flag = True + + if not remove_flag: + for ext in self._video_formats: + file = path.stem + ext + if Path(file).exists(): + Path(file).unlink() + logger.info(f"云盘文件 {file} 已删除") + self.__remove_json(file) + else: + # 非根目录,才删除目录 + shutil.rmtree(path) + # 删除目录 + logger.warn(f"云盘目录 {path} 已删除") + self.__remove_json(path) + + # 判断当前媒体父路径下是否有媒体文件,如有则无需遍历父级 + if not SystemUtils.exits_files(path.parent, settings.RMT_MEDIAEXT): + # 判断父目录是否为空, 为空则删除 + for parent_path in path.parents: + if str(parent_path.parent) != str(path.root): + # 父目录非根目录,才删除父目录 + if not SystemUtils.exits_files(parent_path, settings.RMT_MEDIAEXT): + # 当前路径下没有媒体文件则删除 + shutil.rmtree(parent_path) + logger.warn(f"云盘目录 {parent_path} 已删除") + self.__remove_json(parent_path) + break + + if cloud_file_flag: + # 发送消息 + image = 'https://emby.media/notificationicon.png' + media_type = MediaType.MOVIE if media_type in ["Movie", "MOV"] else MediaType.TV + if self._notify: + backrop_image = self.chain.obtain_specific_image( + mediaid=tmdb_id, + mtype=media_type, + image_type=MediaImageType.Backdrop, + season=season_num, + episode=episode_num + ) or image + + # 类型 + if media_type == MediaType.MOVIE: + msg = f'电影 {media_name} {tmdb_id}' + # 删除电视剧 + elif media_type == MediaType.TV and not season_num and not episode_num: + msg = f'剧集 {media_name} {tmdb_id}' + # 删除季 S02 + elif media_type == MediaType.TV and season_num and not episode_num: + msg = f'剧集 {media_name} S{season_num} {tmdb_id}' + # 删除剧集S02E02 + elif media_type == MediaType.TV and season_num and episode_num: + msg = f'剧集 {media_name} S{season_num}E{episode_num} {tmdb_id}' + else: + msg = media_name + + # 发送通知 + self.post_message( + mtype=NotificationType.MediaServer, + title="云盘同步删除任务完成", + image=backrop_image, + text=f"{msg}\n" + f"时间 {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}" + ) + + # 读取历史记录 + history = self.get_data('history') or [] + + # 获取poster + poster_image = self.chain.obtain_specific_image( + mediaid=tmdb_id, + mtype=media_type, + image_type=MediaImageType.Poster, + ) or image + history.append({ + "type": media_type.value, + "title": media_name, + "path": media_path, + "season": season_num if season_num and str(season_num).isdigit() else None, + "episode": episode_num if episode_num and str(episode_num).isdigit() else None, + "image": poster_image, + "del_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())) + }) + + # 保存历史 + self.save_data("history", history) + + def __remove_json(self, path): + """ + 删除json中的文件内容 + """ + try: + # 删除本地缓存文件 + cloud_files_json = os.path.join(settings.PLUGIN_DATA_PATH, "CloudStrm", "cloud_files.json") + if Path(cloud_files_json).exists(): + # 删除json文件中已删除部分文件 + # 尝试加载本地 + with open(cloud_files_json, 'r') as file: + content = file.read() + if content: + __cloud_files = json.loads(content) + if __cloud_files: + if not isinstance(__cloud_files, list): + __cloud_files = [__cloud_files] + if str(path) in __cloud_files: + # 删除已删除文件 + __cloud_files.remove(str(path)) + # 重新写入本地 + file = open(cloud_files_json, 'w') + file.write(json.dumps(__cloud_files)) + file.close() + else: + remove_flag = False + # 删除目录下文件 + for cloud_file in __cloud_files: + if str(cloud_file).startswith(str(path)): + __cloud_files.remove(cloud_file) + remove_flag = True + if remove_flag: + # 重新写入本地 + file = open(cloud_files_json, 'w') + file.write(json.dumps(__cloud_files)) + file.close() + except Exception as e: + print(str(e)) + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + """ + 定义远程控制命令 + :return: 命令关键字、事件、描述、附带数据 + """ + return [{ + "cmd": "/networkdisk_del", + "event": EventType.PluginAction, + "desc": "云盘文件删除", + "category": "", + "data": { + "action": "networkdisk_del" + } + }] + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '开启通知', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'del_history', + 'label': '删除历史', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'path', + 'rows': '2', + 'label': '媒体库路径映射', + 'placeholder': '媒体服务器路径:moviepilot内云盘挂载路径(一行一个)' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '需要开启媒体库删除插件且正确配置排除路径。' + '主要针对于strm文件删除后同步删除云盘资源。' + '如遇删除失败,请检查文件权限问题。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '关于路径映射:' + 'emby:/data/series/A.mp4,' + 'moviepilot内云盘挂载路径:/mnt/link/series/A.mp4。' + '路径映射填/data:/mnt/link' + } + } + ] + } + ] + }, + ] + } + ], { + "enabled": False, + "path": "", + "notify": False, + "del_history": False + } + + def get_page(self) -> List[dict]: + """ + 拼装插件详情页面,需要返回页面配置,同时附带数据 + """ + # 查询同步详情 + historys = self.get_data('history') + if not historys: + return [ + { + 'component': 'div', + 'text': '暂无数据', + 'props': { + 'class': 'text-center', + } + } + ] + # 数据按时间降序排序 + historys = sorted(historys, key=lambda x: x.get('del_time'), reverse=True) + # 拼装页面 + contents = [] + for history in historys: + htype = history.get("type") + title = history.get("title") + season = history.get("season") + episode = history.get("episode") + image = history.get("image") + del_time = history.get("del_time") + + if season: + sub_contents = [ + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'类型:{htype}' + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'标题:{title}' + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'季:{season}' + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'集:{episode}' + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'时间:{del_time}' + } + ] + else: + sub_contents = [ + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'类型:{htype}' + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'标题:{title}' + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'时间:{del_time}' + } + ] + + contents.append( + { + 'component': 'VCard', + 'content': [ + { + 'component': 'div', + 'props': { + 'class': 'd-flex justify-space-start flex-nowrap flex-row', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'VImg', + 'props': { + 'src': image, + 'height': 120, + 'width': 80, + 'aspect-ratio': '2/3', + 'class': 'object-cover shadow ring-gray-500', + 'cover': True + } + } + ] + }, + { + 'component': 'div', + 'content': sub_contents + } + ] + } + ] + } + ) + + return [ + { + 'component': 'div', + 'props': { + 'class': 'grid gap-3 grid-info-card', + }, + 'content': contents + } + ] + + def stop_service(self): + """ + 退出插件 + """ + pass diff --git a/plugins.v2/configcenter/__init__.py b/plugins.v2/configcenter/__init__.py new file mode 100644 index 0000000..733de8b --- /dev/null +++ b/plugins.v2/configcenter/__init__.py @@ -0,0 +1,597 @@ +import copy +from typing import Any, List, Dict, Tuple + +from dotenv import set_key + +from app.core.config import settings +from app.core.module import ModuleManager +from app.log import logger +from app.plugins import _PluginBase + + +class ConfigCenter(_PluginBase): + # 插件名称 + plugin_name = "配置中心" + # 插件描述 + plugin_desc = "快速调整部分系统设定。" + # 插件图标 + plugin_icon = "setting.png" + # 插件版本 + plugin_version = "2.6" + # 插件作者 + plugin_author = "jxxghp" + # 作者主页 + author_url = "https://github.com/jxxghp" + # 插件配置项ID前缀 + plugin_config_prefix = "configcenter_" + # 加载顺序 + plugin_order = 0 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled = False + _params = "" + _writeenv = False + settings_attributes = [ + "GITHUB_TOKEN", "API_TOKEN", "TMDB_API_DOMAIN", "TMDB_IMAGE_DOMAIN", "WALLPAPER", + "RECOGNIZE_SOURCE", "SCRAP_FOLLOW_TMDB", "AUTO_DOWNLOAD_USER", + "OCR_HOST", "DOWNLOAD_SUBTITLE", "PLUGIN_MARKET", "MOVIE_RENAME_FORMAT", + "TV_RENAME_FORMAT", "FANART_ENABLE", "DOH_ENABLE", "SEARCH_MULTIPLE_NAME", "META_CACHE_EXPIRE", + "GITHUB_PROXY", "DOH_DOMAINS", "DOH_RESOLVERS" + ] + + def init_plugin(self, config: dict = None): + if not config: + return + + self._enabled = config.get("enabled") + self._writeenv = config.get("writeenv") + if not self._enabled: + return + logger.info(f"正在应用配置中心配置:{config}") + for attribute in self.settings_attributes: + setattr(settings, attribute, config.get(attribute) or getattr(settings, attribute)) + # 自定义配置,以换行分隔 + self._params = config.get("params") or "" + for key, value in self.__parse_params(self._params).items(): + if hasattr(settings, key): + setattr(settings, key, str(value)) + + # 重新加载模块 + ModuleManager().stop() + ModuleManager().load_modules() + + # 如果写入app.env文件,则关闭插件开关 + if self._writeenv: + # 写入env文件 + self.update_env(config) + # 自动关闭插件 + self._enabled = False + logger.info("配置中心设置已写入app.env文件,插件关闭...") + # 保存配置 + config.update({"enabled": False}) + self.update_config(config) + + def update_env(self, config: dict): + """ + 更新设置到app.env + """ + if not config: + return + + # 避免修改原值 + conf = copy.deepcopy(config) + + # 自定义配置,以换行分隔 + config_params = self.__parse_params(conf.get("params")) + conf.update(config_params) + # 读写app.env + env_path = settings.CONFIG_PATH / "app.env" + for key, value in conf.items(): + if not key: + continue + # 如果参数不在支持列表中, 则跳过 + if key not in self.settings_attributes and key not in config_params: + continue + if value is None or str(value) == "None": + value = '' + else: + value = str(value) + set_key(env_path, key, value) + logger.info("app.env文件写入完成") + self.systemmessage.put("配置中心设置已写入app.env文件,插件关闭", title="配置中心") + + @staticmethod + def __parse_params(param_str: str) -> dict: + """ + 解析自定义配置 + """ + if not param_str: + return {} + result = {} + params = param_str.split("\n") + for param in params: + if not param: + continue + if str(param).strip().startswith("#"): + continue + parts = param.split("=", 1) + if len(parts) != 2: + continue + key = parts[0].strip() + value = parts[1].strip() + if not key: + continue + if not value: + continue + result[key] = value + return result + + def get_state(self) -> bool: + return self._enabled + + @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、数据结构 + """ + default_settings = { + "enabled": False, + "params": "", + } + for attribute in self.settings_attributes: + default_settings[attribute] = getattr(settings, attribute) + return [ + { + "component": "VForm", + "content": [ + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "enabled", + "label": "启用插件" + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "writeenv", + "label": "写入app.env文件" + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "GITHUB_TOKEN", + "label": "Github Token" + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "API_TOKEN", + "label": "API密钥" + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "TMDB_API_DOMAIN", + "label": "TMDB API地址" + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "TMDB_IMAGE_DOMAIN", + "label": "TheMovieDb图片服务器" + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VSelect", + "props": { + "model": "RECOGNIZE_SOURCE", + "label": "媒体信息识别来源", + "items": [ + {"title": "TheMovieDb", "value": "themoviedb"}, + {"title": "豆瓣", "value": "douban"} + ] + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VSelect", + "props": { + "model": "SCRAP_SOURCE", + "label": "刮削元数据及图片使用的数据源", + "items": [ + {"title": "TheMovieDb", "value": "themoviedb"}, + {"title": "豆瓣", "value": "douban"}, + ] + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VSelect", + "props": { + "model": "WALLPAPER", + "label": "登录首页电影海报", + "items": [ + {"title": "TheMovieDb电影海报", "value": "tmdb"}, + {"title": "Bing每日壁纸", "value": "bing"} + ] + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "OCR_HOST", + "label": "验证码识别服务器" + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "GITHUB_PROXY", + "label": "Github加速服务器", + "placeholder": "https://mirror.ghproxy.com/" + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "DOH_DOMAINS", + "label": "DOH解析的域名", + "placeholder": "多个域名使用,分隔" + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "DOH_RESOLVERS", + "label": "DOH解析服务器", + "placeholder": "多个地址使用,分隔" + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + "component": "VCol", + "props": { + "cols": 12, + }, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "MOVIE_RENAME_FORMAT", + "label": "电影重命名格式" + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + "component": "VCol", + "props": { + "cols": 12, + }, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "TV_RENAME_FORMAT", + "label": "电视剧重命名格式" + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + "component": "VCol", + "props": { + "cols": 12, + }, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "PLUGIN_MARKET", + "label": "插件市场", + "placeholder": "多个地址使用,分隔" + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + "component": "VCol", + "props": { + "cols": 12, + }, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "params", + "label": "自定义配置", + "placeholder": "每行一个配置项,格式:配置项=值" + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + "component": "VCol", + "props": { + "cols": 12, + "md": 4 + }, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "DOWNLOAD_SUBTITLE", + "label": "自动下载站点字幕" + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 4 + }, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "SCRAP_FOLLOW_TMDB", + "label": "新增入库跟随TMDB信息变化" + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 4 + }, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "FANART_ENABLE", + "label": "使用Fanart图片数据源" + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 4 + }, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "DOH_ENABLE", + "label": "启用DNS over HTTPS" + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 4 + }, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "SEARCH_MULTIPLE_NAME", + "label": "资源搜索整合多名称搜索结果" + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '注意:开启写入app.env后将直接修改配置文件,否则只是运行时修改生效对应配置(插件关闭且重启后配置失效);有些自定义配置需要重启才能生效。' + } + } + ] + } + ] + } + ] + } + ], default_settings + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + pass diff --git a/plugins.v2/crossseed/__init__.py b/plugins.v2/crossseed/__init__.py new file mode 100644 index 0000000..82fb138 --- /dev/null +++ b/plugins.v2/crossseed/__init__.py @@ -0,0 +1,1232 @@ +import hashlib +import os +import re +import time +from datetime import datetime, timedelta +from pathlib import Path +from threading import Event +from typing import Any, Dict, List, Optional, Tuple, Union + +import pytz +import requests +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from bencode import bdecode, bencode + +from app.core.config import settings +from app.core.event import eventmanager +from app.db.site_oper import SiteOper +from app.helper.sites import SitesHelper +from app.helper.torrent import TorrentHelper +from app.log import logger +from app.modules.qbittorrent import Qbittorrent +from app.modules.transmission import Transmission +from app.plugins import _PluginBase +from app.schemas import NotificationType +from app.schemas.types import EventType +from app.utils.string import StringUtils +from app.utils.timer import TimerUtils + + +class CSSiteConfig(object): + """ + 站点辅种配置类 + """ + + 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}nexusapi/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}" + + +class TorInfo: + + def __init__( + self, + site_name: str = None, + torrent_path: str = None, + file_path: str = None, + info_hash: str = None, + pieces_hash: str = None, + torrent_id: str = None, + ) -> None: + self.site_name = site_name + self.torrent_path = torrent_path + self.file_path = file_path + self.info_hash = info_hash + self.pieces_hash = pieces_hash + self.torrent_id = torrent_id + self.torrent_announce = None + + @staticmethod + def local(torrent_path: str, info_hash: str, pieces_hash: str): + + return TorInfo( + torrent_path=torrent_path, info_hash=info_hash, pieces_hash=pieces_hash + ) + + @staticmethod + def remote(site_name: str, pieces_hash: str, torrent_id: str): + return TorInfo( + site_name=site_name, pieces_hash=pieces_hash, torrent_id=torrent_id + ) + + @staticmethod + def from_data(data: bytes) -> Tuple[Optional[Any], Optional[str]]: + try: + torrent = bdecode(data) + info = torrent["info"] + pieces = info["pieces"] + info_hash = hashlib.sha1(bencode(info)).hexdigest() + pieces_hash = hashlib.sha1(pieces).hexdigest() + local_tor = TorInfo(info_hash=info_hash, pieces_hash=pieces_hash) + # 从种子中获取 announce, qb可能存在获取不到的情况,会存在于fastresume文件中 + if "announce" in torrent: + local_tor.torrent_announce = torrent["announce"] + return local_tor, None + except Exception as err: + return None, str(err) + + def get_name_id_tag(self): + return f"{self.site_name}:{self.torrent_id}" + + def get_name_pieces_tag(self): + return f"{self.site_name}:{self.pieces_hash}" + + +class CrossSeedHelper(object): + _version = "0.2.0" + + @staticmethod + def get_local_torrent_info(torrent_path: Path | str) -> Tuple[Optional[TorInfo], str]: + try: + if isinstance(torrent_path, Path): + torrent_data = torrent_path.read_bytes() + else: + with open(torrent_path, "rb") as f: + torrent_data = f.read() + local_tor, err = TorInfo.from_data(torrent_data) + if not local_tor: + return None, err + local_tor.torrent_path = str(torrent_path) + return local_tor, "" + except Exception as err: + return None, str(err) + + @staticmethod + def get_target_torrent( + site: CSSiteConfig, + pieces_hash_set: List[str] + ) -> Tuple[Optional[List[TorInfo]], Optional[str]]: + """ + 返回pieces_hash对应的种子信息,包括站点id,pieces_hash,种子id + """ + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "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, + proxies=settings.PROXY if site.proxy else None, + ) + 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}" + return remote_torrent_infos, None + + +class CrossSeed(_PluginBase): + # 插件名称 + plugin_name = "青蛙辅种助手" + # 插件描述 + plugin_desc = "参考ReseedPuppy和IYUU辅种插件实现自动辅种,支持站点:青蛙、AGSVPT、麒麟、UBits、聆音、憨憨等。" + # 插件图标 + plugin_icon = "qingwa.png" + # 插件版本 + plugin_version = "2.3" + # 插件作者 + plugin_author = "233@qingwa" + # 作者主页 + author_url = "https://qingwapt.com/" + # 插件配置项ID前缀 + plugin_config_prefix = "cross_seed_" + # 加载顺序 + plugin_order = 17 + # 可使用的用户级别 + auth_level = 2 + + # 私有属性 + _scheduler = None + cross_helper = None + qb = None + tr = None + sites = None + siteoper = None + torrent = None + # 开关 + _enabled = False + _cron = None + _onlyonce = False + _token = None + _downloaders = [] + _sites = [] + _torrentpath = None + _notify = False + _nolabels = None + _nopaths = None + _clearcache = False + # 退出事件 + _event = Event() + _torrent_tags = ["已整理", "辅种"] + # 待校全种子hash清单 + _recheck_torrents = {} + _is_recheck_running = False + # 辅种缓存,出错的种子不再重复辅种,可清除 + _error_caches = [] + # 辅种缓存,辅种成功的种子,可清除 + _success_caches = [] + # 辅种缓存,出错的种子不再重复辅种,且无法清除。种子被删除404等情况 + _permanent_error_caches = [] + _torrentpaths = [] + _site_cs_infos = [] + # 辅种计数 + total = 0 + realtotal = 0 + success = 0 + exist = 0 + fail = 0 + cached = 0 + + def init_plugin(self, config: dict = None): + self.sites = SitesHelper() + self.siteoper = SiteOper() + self.torrent = TorrentHelper() + # 读取配置 + if config: + self._enabled = config.get("enabled") + self._onlyonce = config.get("onlyonce") + self._cron = config.get("cron") + self._token = config.get("token") # passkey格式 青蛙:xxxxxx,站点名称:xxxxxxx + + self._downloaders = config.get("downloaders") + self._torrentpath = config.get("torrentpath") # 种子路径和下载器对应 /qb,/tr + self._torrentpaths = self._torrentpath.strip().split(",") + self._sites = config.get("sites") or [] + self._notify = config.get("notify") + self._nolabels = config.get("nolabels") + self._nopaths = config.get("nopaths") + self._clearcache = config.get("clearcache") + self._permanent_error_caches = [] if self._clearcache else config.get("permanent_error_caches") or [] + self._error_caches = [] if self._clearcache else config.get("error_caches") or [] + self._success_caches = [] if self._clearcache else config.get("success_caches") or [] + + # 过滤掉已删除的站点 + inner_site_list = self.siteoper.list_order_by_pri() + all_sites = [(site.id, site.name) for site in inner_site_list] + [ + (site.get("id"), site.get("name")) for site in self.__custom_sites() + ] + self._sites = [site_id for site_id, site_name in all_sites if site_id in self._sites] + + # 整理所有可用内部站点信息 + 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=True if site.proxy else False, + ) + 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(r"[\s::]+", site_key.strip()) + site_name = site_key_arr[0] + if len(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() + + # 停止现有任务 + self.stop_service() + + # 启动定时任务 & 立即运行一次 + if self.get_state() or self._onlyonce: + self.cross_helper = CrossSeedHelper() + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + self.qb = Qbittorrent() + self.tr = Transmission() + + if self._onlyonce: + logger.info(f"辅种服务启动,立即运行一次") + self._scheduler.add_job(self.auto_seed, 'date', + run_date=datetime.now( + tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3) + ) + + # 关闭一次性开关 + self._onlyonce = False + if self._scheduler.get_jobs(): + # 追加种子校验服务 + self._scheduler.add_job(self.check_recheck, 'interval', minutes=3) + # 启动服务 + self._scheduler.print_jobs() + self._scheduler.start() + + if self._clearcache: + # 关闭清除缓存开关 + self._clearcache = False + + if self._clearcache or self._onlyonce: + # 保存配置 + self.__update_config() + + def get_state(self) -> bool: + return True if self._enabled and self._token and self._downloaders and self._torrentpath else False + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + 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.get_state(): + # 如果开启了定时任务,并且参数齐全 + if self._cron: + return [{ + "id": "CrossSeed", + "name": "青蛙辅种助手", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self.auto_seed, + "kwargs": {} + }] + else: + # 随机时间 + triggers = TimerUtils.random_scheduler(num_executions=1, + begin_hour=2, + end_hour=7, + max_interval=290, + min_interval=0) + ret_jobs = [] + for trigger in triggers: + ret_jobs.append({ + "id": f"CrossSeed|{trigger.hour}:{trigger.minute}", + "name": "青蛙辅种助手", + "trigger": "cron", + "func": self.auto_seed, + "kwargs": { + "hour": trigger.hour, + "minute": trigger.minute + } + }) + return ret_jobs + elif self._enabled: + logger.warn(f"青蛙辅种助手插件参数不全,定时任务未正常启动") + return [] + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + # 站点的可选项(内置站点 + 自定义站点) + customSites = self.__custom_sites() + + # 站点的可选项 + site_options = ([{"title": site.name, "value": site.id} + for site in self.siteoper.list_order_by_pri()] + + [{"title": site.get("name"), "value": site.get("id")} + for site in customSites]) + # 测试版本,只支持青蛙 + # site_options = [s for s in site_options if s["title"]=="青蛙"] + + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '发送通知', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'chips': True, + 'multiple': True, + 'model': 'sites', + 'label': '辅种站点', + 'items': site_options + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'token', + 'label': '站点Passkey', + 'rows': 3, + 'placeholder': '每行一个, 格式为 站点名称:Passkey ,站点名称为上面选择的名称,例如青蛙为 青蛙:xxxxxx 其中xxxxxx替换为你的Passkey' + } + } + ] + }, + + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'chips': True, + 'multiple': True, + 'model': 'downloaders', + 'label': '辅种下载器', + 'items': [ + {'title': 'Qbittorrent', 'value': 'qbittorrent'}, + {'title': 'Transmission', 'value': 'transmission'} + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '执行周期', + 'placeholder': '0 0 0 ? *' + } + } + ] + }, + + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 12 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'torrentpath', + 'label': '种子文件目录', + 'placeholder': '多个目录逗号分隔,按下载器顺序对应填写,每个下载器只能有一个种子目录' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'nolabels', + 'label': '不辅种标签', + 'placeholder': '使用,分隔多个标签' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'nopaths', + 'label': '不辅种数据文件目录', + 'rows': 3, + 'placeholder': '每一行一个目录' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'clearcache', + 'label': '清除缓存后运行', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '1. 定时任务周期建议每次辅种间隔时间大于1天,不填写每天上午2点到7点随机辅种一次; ' + '2. 支持辅种站点列表:青蛙、AGSVPT、红豆饭、麒麟、UBits、聆音等,配置passkey时,站点名称需严格和上面选项一致,只有选中的站点会辅种,passkey可保存多个; ' + '3. 请勿与IYUU辅种插件同时添加相同站点,可能会有冲突,且意义不大;' + '4. 测试站点是否支持的方法:【站点域名/api/pieces-hash】接口访问返回405则大概率支持 ' + } + } + ] + } + ] + }, + { + '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' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "onlyonce": False, + "notify": False, + "clearcache": False, + "cron": "", + "token": "", + "downloaders": [], + "torrentpath": "", + "sites": [], + "nopaths": "", + "nolabels": "" + } + + def get_page(self) -> List[dict]: + pass + + def __update_config(self): + self.update_config({ + "enabled": self._enabled, + "onlyonce": self._onlyonce, + "clearcache": self._clearcache, + "cron": self._cron, + "token": self._token, + "downloaders": self._downloaders, + "torrentpath": self._torrentpath, + "sites": self._sites, + "notify": self._notify, + "nolabels": self._nolabels, + "nopaths": self._nopaths, + "success_caches": self._success_caches, + "error_caches": self._error_caches, + "permanent_error_caches": self._permanent_error_caches + }) + + def __get_downloader(self, dtype: str): + """ + 根据类型返回下载器实例 + """ + if dtype == "qbittorrent": + return self.qb + elif dtype == "transmission": + return self.tr + else: + return None + + def auto_seed(self): + """ + 开始辅种 + """ + logger.info("开始辅种任务 ...") + + # 计数器初始化 + self.total = 0 + self.realtotal = 0 + self.success = 0 + self.exist = 0 + self.fail = 0 + self.cached = 0 + # 扫描下载器辅种 + for idx, downloader in enumerate(self._downloaders): + logger.info(f"开始扫描下载器 {downloader} ...") + downloader_obj = self.__get_downloader(downloader) + # 获取下载器中已完成的种子 + torrents = downloader_obj.get_completed_torrents() + if torrents: + logger.info(f"下载器 {downloader} 已完成种子数:{len(torrents)}") + else: + logger.info(f"下载器 {downloader} 没有已完成种子") + continue + hash_strs = [] + for torrent in torrents: + if self._event.is_set(): + logger.info(f"辅种服务停止") + return + # 获取种子hash + hash_str = self.__get_hash(torrent, downloader) + if hash_str in self._error_caches or hash_str in self._permanent_error_caches: + logger.info(f"种子 {hash_str} 辅种失败且已缓存,跳过 ...") + continue + save_path = self.__get_save_path(torrent, downloader) + # 获取种子文件路径 + torrent_path = Path(self._torrentpaths[idx]) / f"{hash_str}.torrent" + torrent_info = None + if not torrent_path.exists(): + if False and downloader == "qbittorrent": + # qb开启SQLite功能后将不再以hash命名的方式保存torrent文件 + # TODO 导出功能需要qb4.5.0以上版本才支持 + 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}") + continue + else: + logger.error(f"种子文件不存在:{torrent_path}") + continue + + # 读取种子文件具体信息 + if not torrent_info: + torrent_info, err = self.cross_helper.get_local_torrent_info(torrent_path) + if not torrent_info: + logger.error(f"未能读取到种子文件具体信息:{torrent_path} {err}") + continue + + # 用站点+pieces_hash记录该站点是否已经在该下载器中,需要从tracker补充站点名字 + tracker_urls = set() + try: + if downloader == "qbittorrent": + for i in torrent.trackers: + if "https" in i.get("url"): + tracker_urls.add(i.get("url")) + elif downloader == "transmission": + if torrent_info and torrent_info.torrent_announce: + if "https" in torrent_info.torrent_announce: + tracker_urls.add(torrent_info.torrent_announce) + except Exception as err: + logger.warn(f"尝试获取 {downloader} 的tracker出错 {err}") + # 根据tracker补充站点信息 + for tracker in tracker_urls: + # 优先通过passkey获取站点名 + for site_config in self._site_cs_infos: + if site_config.passkey in tracker: + torrent_info.site_name = site_config.name + break + if not torrent_info.site_name: + # 尝试通过域名获取站点信息 + tracker_domain = StringUtils.get_url_domain(tracker) + site_info = self.sites.get_indexer(tracker_domain) + if site_info: + torrent_info.site_name = site_info.get("name") + + if self._nopaths and save_path: + # 过滤不需要转移的路径 + nopath_skip = False + for nopath in self._nopaths.split('\n'): + if os.path.normpath(save_path).startswith(os.path.normpath(nopath)): + logger.info(f"种子 {hash_str} 保存路径 {save_path} 不需要辅种,跳过 ...") + nopath_skip = True + break + if nopath_skip: + continue + + # 获取种子标签 + torrent_labels = self.__get_label(torrent, downloader) + if torrent_labels and self._nolabels: + is_skip = False + for label in self._nolabels.split(','): + if label in torrent_labels: + logger.info(f"种子 {hash_str} 含有不辅种标签 {label},跳过 ...") + is_skip = True + break + if is_skip: + continue + hash_strs.append({ + "hash": hash_str, + "save_path": save_path, + "torrent_info": torrent_info + }) + if hash_strs: + self.__seed_torrents(hash_strs=hash_strs, downloader=downloader) + # 触发校验检查 + self.check_recheck() + else: + logger.info(f"没有需要辅种的种子") + # 保存缓存 + self.__update_config() + # 发送消息 + if self._notify: + if self.success or self.fail: + self.post_message( + mtype=NotificationType.SiteMessage, + title="【青蛙辅种助手辅种任务完成】", + text=f"服务器返回可辅种总数:{self.total}\n" + f"实际可辅种数:{self.realtotal}\n" + f"已存在:{self.exist}\n" + f"成功:{self.success}\n" + f"失败:{self.fail}\n" + f"{self.cached} 条失败记录已加入缓存" + ) + logger.info("辅种任务执行完成") + + def check_recheck(self): + """ + 定时检查下载器中种子是否校验完成,校验完成且完整的自动开始辅种 + """ + if not self._recheck_torrents: + return + if self._is_recheck_running: + return + self._is_recheck_running = True + for downloader in self._downloaders: + # 需要检查的种子 + recheck_torrents = self._recheck_torrents.get(downloader) or [] + if not recheck_torrents: + continue + logger.info(f"开始检查下载器 {downloader} 的校验任务 ...") + # 下载器 + downloader_obj = self.__get_downloader(downloader) + # 获取下载器中的种子状态 + torrents, _ = downloader_obj.get_torrents(ids=recheck_torrents) + if torrents: + can_seeding_torrents = [] + for torrent in torrents: + # 获取种子hash + hash_str = self.__get_hash(torrent, downloader) + if self.__can_seeding(torrent, downloader): + can_seeding_torrents.append(hash_str) + if can_seeding_torrents: + logger.info(f"共 {len(can_seeding_torrents)} 个任务校验完成,开始辅种 ...") + # 开始任务 + downloader_obj.start_torrents(ids=can_seeding_torrents) + # 去除已经处理过的种子 + self._recheck_torrents[downloader] = list( + set(recheck_torrents).difference(set(can_seeding_torrents))) + elif torrents is None: + logger.info(f"下载器 {downloader} 查询校验任务失败,将在下次继续查询 ...") + continue + else: + logger.info(f"下载器 {downloader} 中没有需要检查的校验任务,清空待处理列表 ...") + self._recheck_torrents[downloader] = [] + self._is_recheck_running = False + + def __seed_torrents(self, hash_strs: list, downloader: str): + """ + 执行所有种子的辅种 + """ + if not hash_strs: + return + logger.info(f"下载器 {downloader} 开始查询辅种,种子总数量:{len(hash_strs)} ...") + + # 每个Hash的保存目录 + save_paths = {} + pieces_hash_set = set() + site_pieces_hash_set = set() + for item in hash_strs: + tor_info: TorInfo = item.get("torrent_info") + save_paths[tor_info.pieces_hash] = item.get("save_path") + pieces_hash_set.add(tor_info.pieces_hash) + if tor_info.site_name: + site_pieces_hash_set.add(tor_info.get_name_pieces_tag()) + + logger.info(f"去重后,总共需要辅种查询的种子数:{len(pieces_hash_set)}") + pieces_hashes = list(pieces_hash_set) + + # 分站点逐个批次辅种 + # 逐个站点查询可辅种数据 + 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): + if self._event.is_set(): + logger.info(f"辅种服务停止") + return + # 切片操作 + chunk = pieces_hashes[i:i + chunk_size] + # 处理分组 + chunk_tors, err_msg = self.cross_helper.get_target_torrent(site_config, chunk) + if not chunk_tors and err_msg: + logger.info( + f"查询站点{site_config.name}可辅种的信息出错 {err_msg},进度={i + 1}/{total_size}" + ) + else: + logger.info( + f"站点{site_config.name}本批次的可辅种/查询数={len(chunk_tors)}/{len(chunk)},进度={i + 1}/{total_size}" + ) + remote_tors = remote_tors + chunk_tors + + logger.info(f"站点{site_config.name}返回可以辅种的种子总数为{len(remote_tors)}") + + # 去除已经下载过的种子 + local_cnt = 0 + not_local_tors = [] + for tor_info in remote_tors: + if ( + tor_info + and tor_info.site_name + and tor_info.pieces_hash + and tor_info.get_name_pieces_tag() in site_pieces_hash_set + ): + local_cnt = local_cnt + 1 + else: + not_local_tors.append(tor_info) + 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: + continue + if tor_info.get_name_id_tag() in self._success_caches: + logger.info(f"{tor_info.get_name_id_tag()} 已处理过辅种,跳过 ...") + continue + if tor_info.get_name_id_tag() in self._error_caches or tor_info.get_name_id_tag() in self._permanent_error_caches: + logger.info(f"种子 {tor_info.get_name_id_tag()} 辅种失败且已缓存,跳过 ...") + continue + # 添加任务 + self.__download_torrent(tor=tor_info, site_config=site_config, + downloader=downloader, + save_path=save_paths.get(tor_info.pieces_hash)) + + logger.info(f"下载器 {downloader} 辅种完成") + + def __download(self, downloader: str, content: Union[bytes, str], + save_path: str) -> Optional[str]: + """ + 添加下载任务 + """ + if downloader == "qbittorrent": + # 生成随机Tag + tag = StringUtils.generate_random_str(10) + state = self.qb.add_torrent(content=content, + download_dir=save_path, + is_paused=True, + tag=["已整理", "辅种", tag]) + if not state: + return None + else: + # 获取种子Hash + torrent_hash = self.qb.get_torrent_id_by_tag(tags=tag) + if not torrent_hash: + logger.error(f"{downloader} 下载任务添加成功,但获取任务信息失败!") + return None + return torrent_hash + elif downloader == "transmission": + # 添加任务 + torrent = self.tr.add_torrent(content=content, + download_dir=save_path, + is_paused=True, + labels=["已整理", "辅种"]) + if not torrent: + return None + else: + return torrent.hashString + + logger.error(f"不支持的下载器:{downloader}") + return None + + def __download_torrent( + self, + tor: TorInfo, + site_config: CSSiteConfig, + downloader: str, + save_path: str, + ): + """ + 下载种子 + + """ + self.total += 1 + self.realtotal += 1 + + # 下载种子 + torrent_url = site_config.get_torrent_url(tor.torrent_id) + + # 下载种子文件 + _, content, _, _, error_msg = self.torrent.download_torrent( + url=torrent_url, + 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): + # 下载失败 + self.fail += 1 + self.cached += 1 + # 加入失败缓存 + if error_msg and ('无法打开链接' in error_msg or '触发站点流控' in error_msg): + self._error_caches.append(tor.get_name_id_tag()) + else: + # 种子不存在的情况 + self._permanent_error_caches.append(tor.get_name_id_tag()) + logger.error(f"下载种子文件失败:{tor.get_name_id_tag()}") + return False + + # 添加任务前查询校验一次,避免重复添加,导致暂停的任务被重新开始 + tmp_tor_info, err_msg = TorInfo.from_data(content) + if tmp_tor_info and tmp_tor_info.info_hash: + tors, msg = self.__get_downloader(downloader).get_torrents(ids=[tmp_tor_info.info_hash]) + if tors: + self.exist += 1 + self._success_caches.append(tor.get_name_id_tag()) + logger.info(f"下载的种子{tor.get_name_id_tag()}已存在, 跳过") + return True + else: + logger.warn(f"获取下载种子的信息出错{err_msg},不能检查该种子是否已暂停") + + # 添加下载,辅种任务默认暂停 + logger.info(f"添加下载任务:{tor.get_name_id_tag()} ...") + download_id = self.__download(downloader=downloader, + content=content, + save_path=save_path) + if not download_id: + # 下载失败 + self.fail += 1 + self.cached += 1 + # 加入失败缓存 + self._error_caches.append(tor.get_name_id_tag()) + return False + else: + self.success += 1 + # 追加校验任务 + logger.info(f"添加校验检查任务:{download_id} ...") + if not self._recheck_torrents.get(downloader): + self._recheck_torrents[downloader] = [] + self._recheck_torrents[downloader].append(download_id) + # 下载成功 + logger.info(f"成功添加辅种下载,站点种子:{tor.get_name_id_tag()}") + # TR会自动校验 + if downloader == "qbittorrent": + # 开始校验种子 + self.__get_downloader(downloader).recheck_torrents(ids=[download_id]) + # 成功也加入缓存,有一些改了路径校验不通过的,手动删除后,下一次又会辅上 + self._success_caches.append(tor.get_name_id_tag()) + return True + + @staticmethod + def __get_hash(torrent: Any, dl_type: str): + """ + 获取种子hash + """ + try: + return torrent.get("hash") if dl_type == "qbittorrent" else torrent.hashString + except Exception as e: + print(str(e)) + return "" + + @staticmethod + def __get_label(torrent: Any, dl_type: str): + """ + 获取种子标签 + """ + try: + return [str(tag).strip() for tag in torrent.get("tags").split(',')] \ + if dl_type == "qbittorrent" else torrent.labels or [] + except Exception as e: + print(str(e)) + return [] + + @staticmethod + def __can_seeding(torrent: Any, dl_type: str): + """ + 判断种子是否可以做种并处于暂停状态 + """ + try: + return torrent.get("state") == "pausedUP" if dl_type == "qbittorrent" \ + else (torrent.status.stopped and torrent.percent_done == 1) + except Exception as e: + print(str(e)) + return False + + @staticmethod + def __get_save_path(torrent: Any, dl_type: str): + """ + 获取种子保存路径 + """ + try: + return torrent.get("save_path") if dl_type == "qbittorrent" else torrent.download_dir + except Exception as e: + print(str(e)) + return "" + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._event.set() + self._scheduler.shutdown() + self._event.clear() + self._scheduler = None + except Exception as e: + print(str(e)) + + def __custom_sites(self) -> List[Any]: + custom_sites = [] + custom_sites_config = self.get_config("CustomSites") + if custom_sites_config and custom_sites_config.get("enabled"): + custom_sites = custom_sites_config.get("sites") + return custom_sites + + @eventmanager.register(EventType.SiteDeleted) + def site_deleted(self, event): + """ + 删除对应站点选中 + """ + site_id = event.event_data.get("site_id") + config = self.get_config() + if config: + sites = config.get("sites") + if sites: + if isinstance(sites, str): + sites = [sites] + + # 删除对应站点 + if site_id: + sites = [site for site in sites if int(site) != int(site_id)] + else: + # 清空 + sites = [] + + # 若无站点,则停止 + if len(sites) == 0: + self._enabled = False + + self._sites = sites + # 保存配置 + self.__update_config() diff --git a/plugins.v2/diagparamadjust/__init__.py b/plugins.v2/diagparamadjust/__init__.py new file mode 100644 index 0000000..791504f --- /dev/null +++ b/plugins.v2/diagparamadjust/__init__.py @@ -0,0 +1,456 @@ +import json +import re +from datetime import datetime, timedelta + +from app.modules.emby import Emby +from app.core.config import settings +from app.plugins import _PluginBase +from app.log import logger +from typing import List, Tuple, Dict, Any, Optional +import pytz +from app.schemas import WebhookEventInfo +from app.schemas.types import EventType +from app.core.event import eventmanager, Event + +from apscheduler.triggers.cron import CronTrigger +from apscheduler.schedulers.background import BackgroundScheduler + + +class DiagParamAdjust(_PluginBase): + # 插件名称 + plugin_name = "诊断参数调整" + # 插件描述 + plugin_desc = "Emby专用插件|暂时性解决emby字幕偏移问题,需要emby安装Diagnostics插件。" + # 插件图标 + plugin_icon = "Gatus_A.png" + # 插件版本 + plugin_version = "1.3" + # 插件作者 + plugin_author = "jeblove" + # 作者主页 + author_url = "https://github.com/jeblove" + # 插件配置项ID前缀 + plugin_config_prefix = "dpa_" + # 加载顺序 + plugin_order = 14 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled: bool = False + # 修正字幕偏移用途(播放时执行) + _offset_play = True + _onlyonce = False + _base_url = None + _endpoint = None + _api_key = None + _search_text = None + _replace_text = None + _cron = None + _cron_switch = False + + # 请求接口 + _url = "[HOST]emby/EncodingDiagnostics/DiagnosticOptions?api_key=[APIKEY]" + # 定时器 + _scheduler: Optional[BackgroundScheduler] = None + + # 目标消息 + _webhook_actions = { + "playback.start": "开始播放", + } + + # 分辨率标识 + _resolution = None + # 分辨率改动 + _last_resolution = None + # 目标参数 + _target_search_text = None + _target_replace_text = None + + def init_plugin(self, config: dict = None): + # 停止现有任务 + self.stop_service() + + if config: + self._enabled = config.get("enabled") + self._offset_play = config.get("offset_play") + self._onlyonce = config.get("onlyonce") + self._search_text = config.get("search") + self._replace_text = config.get("replace") + self._cron = config.get("cron") + self._cron_switch = config.get("cron_switch") + + if self._onlyonce: + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + logger.info(f"诊断参数调整服务启动,立刻运行一次") + self._scheduler.add_job(func=self.run, trigger='date', + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="诊断参数调整") + + # 关闭一次性开关 + self._onlyonce = False + self.update_config({ + "enabled": self._enabled, + "offset_play": self._offset_play, + "onlyonce": False, + "search": self._search_text, + "replace": self._replace_text, + "cron": self._cron, + "cron_switch": self._cron_switch, + }) + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + 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 and self._cron and self._cron_switch: + return [{ + "id": "DiagParamAdjust", + "name": "诊断参数调整定时服务", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self.run, + "kwargs": {} + }] + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'offset_play', + 'label': '修正字幕偏移(播放时执行)', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'search', + 'label': '搜索文本' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'replace', + 'label': '替换文本' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '检测执行周期', + 'placeholder': '*/5 * * * *' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'cron_switch', + 'label': '周期模式', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '- 暂时性解决emby字幕偏移问题,如默认参数不合适请在基础上修改【替换文本】x、y至适合(4K视频情况下!),如[x=W/4:y=h/5]。\n - 【修正字幕偏移(播放时执行)】需要emby配置webhooks消息通知:勾选[播放-开始](具体可参考【媒体库服务器通知】插件)', + 'style': 'white-space: pre-line;' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '- 播放视频分辨率与上次视频分辨率不一致时,在通知延迟和已加载旧位置字幕影响下,需要片刻后才会加载到新位置字幕,或关闭视频再次打开(建议)。\n - 此替换文本参数应用于emby-Diagnostics-Parameter Adjustment。\n - 默认参数用于修改ffmpeg中字幕覆盖在视频上的位置。\n - 方案来源于https://opve.cn/archives/983.html', + 'style': 'white-space: pre-line;' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "offset_play": True, + "onlyonce": False, + "search": "x=(W-w)/2:y=(H-h):repeatlast=0", + "replace": "x=W/4:y=h/4:repeatlast=0", + "cron": "*/5 * * * *", + "cron_switch": False, + } + + def detect(self): + """ + 检测是否存在目标参数(修正字幕偏移用途) + + :return True: 存在; False: 不存在 + """ + logger.info('字幕偏移修正,检测目标参数') + try: + res = Emby().get_data(self._url) + result = res.json() + data = result['Object']['CommandLineOptions'] + searchText = data['SearchText'] + replaceText = data['ReplaceText'] + except json.JSONDecodeError: + logger.error('服务停止,Emby请安装【Diagnostics】插件') + return None + except KeyError: + # 已装插件,未设置过该参数 + # logger.info('目标参数为空') + return False + + # 符合所有情况 + if (('repeatlast' in replaceText + and 'x=(W-w)/2:y=(H-h):repeatlast=0' in searchText + and result['Object']['TranscodingOptions']['DisableHardwareSubtitleOverlay'] is True) + or (searchText == "" and replaceText == "")) \ + and self._resolution == self._last_resolution: + # (A or B) and C + return True + + return False + + def set_options(self): + """ + 向Emby发送请求设置参数 + """ + + # 根据分辨率情况而选择是否替换 + if self._resolution == 0 and self._offset_play is True: + # 1080p,不替换(清空文本) + self._target_search_text = "" + self._target_replace_text = "" + logger.info('清空替换参数') + else: + # >1080p or 非字幕偏移用途 + self._target_search_text = self._search_text + self._target_replace_text = self._replace_text + logger.info("替换值为:{}".format(self._target_replace_text)) + + data = { + "CommandLineOptions": { + "SearchText": self._target_search_text, + "ReplaceText": self._target_replace_text + }, + "TranscodingOptions": { + "DisableHardwareSubtitleOverlay": True + } + } + data = json.dumps(data) + headers = { + 'Content-Type': 'application/octet-stream' + } + res = Emby().post_data(self._url, data, headers) + if res.status_code // 100 == 2: + logger.info('参数设置成功') + return True + else: + logger.error('参数设置失败 {}'.format(res.status_code)) + return False + + @eventmanager.register(EventType.WebhookMessage) + def get_msg(self, event: Event): + # 消息方式开关 + if not self._enabled or not self._offset_play: + return + + # 消息获取 + event_info: WebhookEventInfo = event.event_data + if not event_info: + return + + # 非目标消息 + if not self._webhook_actions.get(event_info.event): + return + + # 根据视频名获得分辨率信息 + item_path = event_info.item_path + video_resolution = re.findall(r"\d{3,4}p", item_path) + video_width = int(video_resolution[0][:-1]) + logger.info('视频分辨率:{}'.format(video_width)) + + self._last_resolution = self._resolution + # 分辨率变化情况 + if video_width > 1080: + # 2160p/4k + self._resolution = 1 + else: + self._resolution = 0 + self.run() + + def run(self): + # 字幕偏移修正,则带检测 + if self._offset_play: + state = self.detect() + if state: + logger.info('参数正常,无需修正') + return True + elif state is None: + logger.info('插件退出') + return None + + self.set_options() + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) diff --git a/plugins.v2/downloadsitetag/__init__.py b/plugins.v2/downloadsitetag/__init__.py new file mode 100644 index 0000000..6c473e2 --- /dev/null +++ b/plugins.v2/downloadsitetag/__init__.py @@ -0,0 +1,812 @@ +import datetime +import pytz +import threading +from typing import List, Tuple, Dict, Any, Optional + +from app.core.context import Context +from app.core.event import eventmanager, Event +from app.schemas.types import EventType, MediaType +from app.core.config import settings +from app.log import logger +from app.plugins import _PluginBase +from app.modules.qbittorrent import Qbittorrent +from app.modules.transmission import Transmission +from app.db.downloadhistory_oper import DownloadHistoryOper +from app.db.models.downloadhistory import DownloadHistory +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from app.helper.sites import SitesHelper +from app.utils.string import StringUtils + + +class DownloadSiteTag(_PluginBase): + # 插件名称 + plugin_name = "下载任务分类与标签" + # 插件描述 + plugin_desc = "自动给下载任务分类与打站点标签、剧集名称标签" + # 插件图标 + plugin_icon = "Youtube-dl_B.png" + # 插件版本 + plugin_version = "2.1" + # 插件作者 + plugin_author = "叮叮当" + # 作者主页 + author_url = "https://github.com/cikezhu" + # 插件配置项ID前缀 + plugin_config_prefix = "DownloadSiteTag_" + # 加载顺序 + plugin_order = 2 + # 可使用的用户级别 + auth_level = 1 + # 日志前缀 + LOG_TAG = "[DownloadSiteTag] " + + # 退出事件 + _event = threading.Event() + # 私有属性 + downloader_qb = None + downloader_tr = None + downloadhistory_oper = None + sites_helper = None + _scheduler = None + _enabled = False + _onlyonce = False + _interval = "计划任务" + _interval_cron = "5 4 * * *" + _interval_time = 6 + _interval_unit = "小时" + _enabled_media_tag = False + _enabled_tag = True + _enabled_category = False + _category_movie = None + _category_tv = None + _category_anime = None + + def init_plugin(self, config: dict = None): + self.downloader_qb = Qbittorrent() + self.downloader_tr = Transmission() + self.downloadhistory_oper = DownloadHistoryOper() + self.sites_helper = SitesHelper() + # 读取配置 + if config: + self._enabled = config.get("enabled") + self._onlyonce = config.get("onlyonce") + self._interval = config.get("interval") or "计划任务" + self._interval_cron = config.get("interval_cron") or "5 4 * * *" + self._interval_time = self.str_to_number(config.get("interval_time"), 6) + self._interval_unit = config.get("interval_unit") or "小时" + self._enabled_media_tag = config.get("enabled_media_tag") + self._enabled_tag = config.get("enabled_tag") + self._enabled_category = config.get("enabled_category") + self._category_movie = config.get("category_movie") or "电影" + self._category_tv = config.get("category_tv") or "电视" + self._category_anime = config.get("category_anime") or "动漫" + if not ("interval_cron" in config): + # 新版本v1.6更新插件配置默认配置 + config["interval"] = self._interval + config["interval_cron"] = self._interval_cron + config["interval_time"] = self._interval_time + config["interval_unit"] = self._interval_unit + self.update_config(config) + logger.warn(f"{self.LOG_TAG}新版本v{self.plugin_version} 配置修正 ...") + + # 停止现有任务 + self.stop_service() + + if self._onlyonce: + # 创建定时任务控制器 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + # 执行一次, 关闭onlyonce + self._onlyonce = False + config.update({"onlyonce": self._onlyonce}) + self.update_config(config) + # 添加 补全下载历史的标签与分类 任务 + self._scheduler.add_job(func=self._complemented_history, trigger='date', + run_date=datetime.datetime.now( + tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3) + ) + + if self._scheduler and self._scheduler.get_jobs(): + # 启动服务 + self._scheduler.print_jobs() + self._scheduler.start() + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + 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: + if self._interval == "计划任务" or self._interval == "固定间隔": + if self._interval == "固定间隔": + if self._interval_unit == "小时": + return [{ + "id": "DownloadSiteTag", + "name": "补全下载历史的标签与分类", + "trigger": "interval", + "func": self._complemented_history, + "kwargs": { + "hours": self._interval_time + } + }] + else: + if self._interval_time < 5: + self._interval_time = 5 + logger.info(f"{self.LOG_TAG}启动定时服务: 最小不少于5分钟, 防止执行间隔太短任务冲突") + return [{ + "id": "DownloadSiteTag", + "name": "补全下载历史的标签与分类", + "trigger": "interval", + "func": self._complemented_history, + "kwargs": { + "minutes": self._interval_time + } + }] + else: + return [{ + "id": "DownloadSiteTag", + "name": "补全下载历史的标签与分类", + "trigger": CronTrigger.from_crontab(self._interval_cron), + "func": self._complemented_history, + "kwargs": {} + }] + return [] + + @staticmethod + def str_to_number(s: str, i: int) -> int: + try: + return int(s) + except ValueError: + return i + + def _complemented_history(self): + """ + 补全下载历史的标签与分类 + """ + logger.info(f"{self.LOG_TAG}开始执行 ...") + # 记录处理的种子, 供辅种(无下载历史)使用 + dispose_history = {} + # 所有站点索引 + indexers = [indexer.get("name") for indexer in self.sites_helper.get_indexers()] + # JackettIndexers索引器支持多个站点, 如果不存在历史记录, 则通过tracker会再次附加其他站点名称 + indexers.append("JackettIndexers") + indexers = set(indexers) + tracker_mappings = { + "chdbits.xyz": "ptchdbits.co", + "agsvpt.trackers.work": "agsvpt.com", + "tracker.cinefiles.info": "audiences.me", + } + for DOWNLOADER in ["qbittorrent", "transmission"]: + logger.info(f"{self.LOG_TAG}开始扫描下载器 {DOWNLOADER} ...") + # 获取下载器中的种子 + downloader_obj = self._get_downloader(DOWNLOADER) + if not downloader_obj: + logger.error(f"{self.LOG_TAG} 获取下载器失败 {DOWNLOADER}") + continue + torrents, error = downloader_obj.get_torrents() + # 如果下载器获取种子发生错误 或 没有种子 则跳过 + if error or not torrents: + continue + logger.info(f"{self.LOG_TAG}按时间重新排序 {DOWNLOADER} 种子数:{len(torrents)}") + # 按添加时间进行排序, 时间靠前的按大小和名称加入处理历史, 判定为原始种子, 其他为辅种 + torrents = self._torrents_sort(torrents=torrents, dl_type=DOWNLOADER) + logger.info(f"{self.LOG_TAG}下载器 {DOWNLOADER} 分析种子信息中 ...") + for torrent in torrents: + try: + if self._event.is_set(): + logger.info( + f"{self.LOG_TAG}停止服务") + return + # 获取已处理种子的key (size, name) + _key = self._torrent_key(torrent=torrent, dl_type=DOWNLOADER) + # 获取种子hash + _hash = self._get_hash(torrent=torrent, dl_type=DOWNLOADER) + if not _hash: + continue + # 获取种子当前标签 + torrent_tags = self._get_label(torrent=torrent, dl_type=DOWNLOADER) + torrent_cat = self._get_category(torrent=torrent, dl_type=DOWNLOADER) + # 提取种子hash对应的下载历史 + history: DownloadHistory = self.downloadhistory_oper.get_by_hash(_hash) + if not history: + # 如果找到已处理种子的历史, 表明当前种子是辅种, 否则创建一个空DownloadHistory + if _key and _key in dispose_history: + history = dispose_history[_key] + # 因为辅种站点必定不同, 所以需要更新站点名字 history.torrent_site + history.torrent_site = None + else: + history = DownloadHistory() + else: + # 加入历史记录 + if _key: + dispose_history[_key] = history + # 如果标签已经存在任意站点, 则不再添加站点标签 + if indexers.intersection(set(torrent_tags)): + history.torrent_site = None + # 如果站点名称为空, 尝试通过trackers识别 + elif not history.torrent_site: + trackers = self._get_trackers(torrent=torrent, dl_type=DOWNLOADER) + for tracker in trackers: + # 检查tracker是否包含特定的关键字,并进行相应的映射 + for key, mapped_domain in tracker_mappings.items(): + if key in tracker: + domain = mapped_domain + break + else: + domain = StringUtils.get_url_domain(tracker) + site_info = self.sites_helper.get_indexer(domain) + if site_info: + history.torrent_site = site_info.get("name") + break + # 如果通过tracker还是无法获取站点名称, 且tmdbid, type, title都是空的, 那么跳过当前种子 + if not history.torrent_site and not history.tmdbid and not history.type and not history.title: + continue + # 按设置生成需要写入的标签与分类 + _tags = [] + _cat = None + # 站点标签, 如果勾选开关的话 因允许torrent_site为空时运行到此, 因此需要判断torrent_site不为空 + if self._enabled_tag and history.torrent_site: + _tags.append(history.torrent_site) + # 媒体标题标签, 如果勾选开关的话 因允许title为空时运行到此, 因此需要判断title不为空 + if self._enabled_media_tag and history.title: + _tags.append(history.title) + # 分类, 如果勾选开关的话 因允许mtype为空时运行到此, 因此需要判断mtype不为空。为防止不必要的识别, 种子已经存在分类torrent_cat时 也不执行 + if DOWNLOADER == "qbittorrent" and self._enabled_category and not torrent_cat and history.type: + # 如果是电视剧 需要区分是否动漫 + genre_ids = None + # 因允许tmdbid为空时运行到此, 因此需要判断tmdbid不为空 + history_type = MediaType(history.type) if history.type else None + if history.tmdbid and history_type == MediaType.TV: + # tmdb_id获取tmdb信息 + tmdb_info = self.chain.tmdb_info(mtype=history_type, tmdbid=history.tmdbid) + if tmdb_info: + genre_ids = tmdb_info.get("genre_ids") + _cat = self._genre_ids_get_cat(history.type, genre_ids) + + # 去除种子已经存在的标签 + if _tags and torrent_tags: + _tags = list(set(_tags) - set(torrent_tags)) + # 如果分类一样, 那么不需要修改 + if _cat == torrent_cat: + _cat = None + # 判断当前种子是否不需要修改 + if not _cat and not _tags: + continue + # 执行通用方法, 设置种子标签与分类 + self._set_torrent_info(DOWNLOADER=DOWNLOADER, _hash=_hash, _torrent=torrent, _tags=_tags, _cat=_cat, + _original_tags=torrent_tags) + except Exception as e: + logger.error( + f"{self.LOG_TAG}分析种子信息时发生了错误: {str(e)}") + + logger.info(f"{self.LOG_TAG}执行完成") + + def _genre_ids_get_cat(self, mtype, genre_ids=None): + """ + 根据genre_ids判断是否<动漫>分类 + """ + _cat = None + if mtype == MediaType.MOVIE or mtype == MediaType.MOVIE.value: + # 电影 + _cat = self._category_movie + elif mtype: + ANIME_GENREIDS = settings.ANIME_GENREIDS + if genre_ids \ + and set(genre_ids).intersection(set(ANIME_GENREIDS)): + # 动漫 + _cat = self._category_anime + else: + # 电视剧 + _cat = self._category_tv + return _cat + + def _get_downloader(self, dtype: str): + """ + 根据类型返回下载器实例 + """ + if dtype == "qbittorrent": + return self.downloader_qb + elif dtype == "transmission": + return self.downloader_tr + else: + return None + + @staticmethod + def _torrent_key(torrent: Any, dl_type: str) -> Optional[Tuple[int, str]]: + """ + 按种子大小和时间返回key + """ + if dl_type == "qbittorrent": + size = torrent.get('size') + name = torrent.get('name') + else: + size = torrent.total_size + name = torrent.name + if not size or not name: + return None + else: + return size, name + + @staticmethod + def _torrents_sort(torrents: Any, dl_type: str): + """ + 按种子添加时间排序 + """ + if dl_type == "qbittorrent": + torrents = sorted(torrents, key=lambda x: x.get("added_on"), reverse=False) + else: + torrents = sorted(torrents, key=lambda x: x.added_date, reverse=False) + return torrents + + @staticmethod + def _get_hash(torrent: Any, dl_type: str): + """ + 获取种子hash + """ + try: + return torrent.get("hash") if dl_type == "qbittorrent" else torrent.hashString + except Exception as e: + print(str(e)) + return "" + + @staticmethod + def _get_trackers(torrent: Any, dl_type: str): + """ + 获取种子trackers + """ + try: + if dl_type == "qbittorrent": + """ + url 字符串 跟踪器网址 + status 整数 跟踪器状态。有关可能的值,请参阅下表 + tier 整数 跟踪器优先级。较低级别的跟踪器在较高级别的跟踪器之前试用。当特殊条目(如 DHT)不存在时,层号用作占位符时,层号有效。>= 0< 0tier + num_peers 整数 跟踪器报告的当前 torrent 的对等体数量 + num_seeds 整数 当前种子的种子数,由跟踪器报告 + num_leeches 整数 当前种子的水蛭数量,如跟踪器报告的那样 + num_downloaded 整数 跟踪器报告的当前 torrent 的已完成下载次数 + msg 字符串 跟踪器消息(无法知道此消息是什么 - 由跟踪器管理员决定) + """ + return [tracker.get("url") for tracker in (torrent.trackers or []) if + tracker.get("tier", -1) >= 0 and tracker.get("url")] + else: + """ + class Tracker(Container): + @property + def id(self) -> int: + return self.fields["id"] + + @property + def announce(self) -> str: + return self.fields["announce"] + + @property + def scrape(self) -> str: + return self.fields["scrape"] + + @property + def tier(self) -> int: + return self.fields["tier"] + """ + return [tracker.announce for tracker in (torrent.trackers or []) if + tracker.tier >= 0 and tracker.announce] + except Exception as e: + print(str(e)) + return [] + + @staticmethod + def _get_label(torrent: Any, dl_type: str): + """ + 获取种子标签 + """ + try: + return [str(tag).strip() for tag in torrent.get("tags", "").split(',')] \ + if dl_type == "qbittorrent" else torrent.labels or [] + except Exception as e: + print(str(e)) + return [] + + @staticmethod + def _get_category(torrent: Any, dl_type: str): + """ + 获取种子分类 + """ + try: + return torrent.get("category") if dl_type == "qbittorrent" else None + except Exception as e: + print(str(e)) + return None + + def _set_torrent_info(self, DOWNLOADER: str, _hash: str, _torrent: Any = None, _tags=None, _cat: str = None, + _original_tags: list = None): + """ + 设置种子标签与分类 + """ + # 当前下载器 + if _tags is None: + _tags = [] + downloader_obj = self._get_downloader(DOWNLOADER) + if not _torrent: + _torrent, error = downloader_obj.get_torrents(ids=_hash) + if not _torrent or error: + logger.error( + f"{self.LOG_TAG}设置种子标签与分类时发生了错误: 通过 {_hash} 查询不到任何种子!") + return + logger.info( + f"{self.LOG_TAG}设置种子标签与分类: {_hash} 查询到 {len(_torrent)} 个种子") + _torrent = _torrent[0] + # 判断是否可执行 + if DOWNLOADER and downloader_obj and _hash and _torrent: + # 下载器api不通用, 因此需分开处理 + if DOWNLOADER == "qbittorrent": + # 设置标签 + if _tags: + downloader_obj.set_torrents_tag(ids=_hash, tags=_tags) + # 设置分类 + if _cat: + # 尝试设置种子分类, 如果失败, 则创建再设置一遍 + try: + _torrent.setCategory(category=_cat) + except Exception as e: + logger.warn(f"下载器 {DOWNLOADER} 种子id: {_hash} 设置分类 {_cat} 失败:{str(e)}, " + f"尝试创建分类再设置 ...") + downloader_obj.qbc.torrents_createCategory(name=_cat) + _torrent.setCategory(category=_cat) + else: + # 设置标签 + if _tags: + # _original_tags = None表示未指定, 因此需要获取原始标签 + if _original_tags is None: + _original_tags = self._get_label(torrent=_torrent, dl_type=DOWNLOADER) + # 如果原始标签不是空的, 那么合并原始标签 + if _original_tags: + _tags = list(set(_original_tags).union(set(_tags))) + downloader_obj.set_torrent_tag(ids=_hash, tags=_tags) + logger.warn( + f"{self.LOG_TAG}下载器: {DOWNLOADER} 种子id: {_hash} {(' 标签: ' + ','.join(_tags)) if _tags else ''} {(' 分类: ' + _cat) if _cat else ''}") + + @eventmanager.register(EventType.DownloadAdded) + def DownloadAdded(self, event: Event): + """ + 添加下载事件 + """ + if not self.get_state(): + return + + if not event.event_data: + return + + try: + context: Context = event.event_data.get("context") + _hash = event.event_data.get("hash") + _torrent = context.torrent_info + _media = context.media_info + _tags = [] + _cat = None + # 站点标签, 如果勾选开关的话 + if self._enabled_tag and _torrent.site_name: + _tags.append(_torrent.site_name) + # 媒体标题标签, 如果勾选开关的话 + if self._enabled_media_tag and _media.title: + _tags.append(_media.title) + # 分类, 如果勾选开关的话 + if self._enabled_category and _media.type: + _cat = self._genre_ids_get_cat(_media.type, _media.genre_ids) + if _hash and (_tags or _cat): + # 执行通用方法, 设置种子标签与分类 + self._set_torrent_info(DOWNLOADER=settings.DEFAULT_DOWNLOADER, _hash=_hash, _tags=_tags, _cat=_cat) + except Exception as e: + logger.error( + f"{self.LOG_TAG}分析下载事件时发生了错误: {str(e)}") + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VCheckboxBtn', + 'props': { + 'model': 'enabled_tag', + 'label': '自动站点标签', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VCheckboxBtn', + 'props': { + 'model': 'enabled_media_tag', + 'label': '自动剧名标签', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VCheckboxBtn', + 'props': { + 'model': 'enabled_category', + 'label': '自动设置分类', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 12 + }, + 'content': [ + { + 'component': 'VCheckboxBtn', + 'props': { + 'model': 'onlyonce', + 'label': '补全下载历史的标签与分类(一次性任务)' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'interval', + 'label': '定时任务', + 'items': [ + {'title': '禁用', 'value': '禁用'}, + {'title': '计划任务', 'value': '计划任务'}, + {'title': '固定间隔', 'value': '固定间隔'} + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'interval_cron', + 'label': '计划任务设置', + 'placeholder': '5 4 * * *' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 6, + 'md': 3, + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'interval_time', + 'label': '固定间隔设置, 间隔每', + 'placeholder': '6' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 6, + 'md': 3, + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'interval_unit', + 'label': '单位', + 'items': [ + {'title': '小时', 'value': '小时'}, + {'title': '分钟', 'value': '分钟'} + ] + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'category_movie', + 'label': '电影分类名称(默认: 电影)', + 'placeholder': '电影' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'category_tv', + 'label': '电视分类名称(默认: 电视)', + 'placeholder': '电视' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'category_anime', + 'label': '动漫分类名称(默认: 动漫)', + 'placeholder': '动漫' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '定时任务:支持两种定时方式,主要针对辅种刷流等种子补全站点信息。如没有对应的需求建议切换为禁用。' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "onlyonce": False, + "enabled_tag": True, + "enabled_media_tag": False, + "enabled_category": False, + "category_movie": "电影", + "category_tv": "电视", + "category_anime": "动漫", + "interval": "计划任务", + "interval_cron": "5 4 * * *", + "interval_time": "6", + "interval_unit": "小时" + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 停止服务 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._event.set() + self._scheduler.shutdown() + self._event.clear() + self._scheduler = None + except Exception as e: + print(str(e)) diff --git a/plugins.v2/episodegroupmeta/__init__.py b/plugins.v2/episodegroupmeta/__init__.py new file mode 100644 index 0000000..7a3be26 --- /dev/null +++ b/plugins.v2/episodegroupmeta/__init__.py @@ -0,0 +1,872 @@ +import base64 +import json +import threading +import time +from pathlib import Path +from typing import Any, List, Dict, Tuple, Optional, Union + +from pydantic import BaseModel +from requests import RequestException + +from app import schemas +from app.chain.mediaserver import MediaServerChain +from app.core.config import settings +from app.core.event import eventmanager, Event +from app.core.meta import MetaBase +from app.log import logger +from app.modules.emby import Emby +from app.modules.jellyfin import Jellyfin +from app.modules.plex import Plex +from app.modules.themoviedb.tmdbv3api import TV +from app.plugins import _PluginBase +from app.schemas.types import EventType +from app.utils.common import retry +from app.utils.http import RequestUtils + + +class ExistMediaInfo(BaseModel): + # 类型 电影、电视剧 + type: Optional[schemas.MediaType] + # 季, 集 + groupep: Optional[Dict[int, list]] = {} + # 集在媒体服务器的ID + groupid: Optional[Dict[int, List[list]]] = {} + # 媒体服务器 + server: Optional[str] = None + # 媒体ID + itemid: Optional[Union[str, int]] = None + + +class EpisodeGroupMeta(_PluginBase): + # 插件名称 + plugin_name = "TMDB剧集组刮削" + # 插件描述 + plugin_desc = "从TMDB剧集组刮削季集的实际顺序。" + # 插件图标 + plugin_icon = "Element_A.png" + # 主题色 + plugin_color = "#098663" + # 插件版本 + plugin_version = "1.1" + # 插件作者 + plugin_author = "叮叮当" + # 作者主页 + author_url = "https://github.com/cikezhu" + # 插件配置项ID前缀 + plugin_config_prefix = "EpisodeGroupMeta_" + # 加载顺序 + plugin_order = 29 + # 可使用的用户级别 + auth_level = 1 + + # 退出事件 + _event = threading.Event() + + # 私有属性 + mschain = None + tv = None + emby = None + plex = None + jellyfin = None + + _enabled = False + _ignorelock = False + _delay = 0 + _allowlist = [] + + def init_plugin(self, config: dict = None): + self.mschain = MediaServerChain() + self.tv = TV() + self.emby = Emby() + self.plex = Plex() + self.jellyfin = Jellyfin() + if config: + self._enabled = config.get("enabled") + self._ignorelock = config.get("ignorelock") + self._delay = config.get("delay") or 120 + self._allowlist = [] + for s in str(config.get("allowlist", "")).split(","): + s = s.strip() + if s and s not in self._allowlist: + self._allowlist.append(s) + self.log_info(f"白名单数量: {len(self._allowlist)} > {self._allowlist}") + + def get_state(self) -> bool: + return self._enabled + + @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、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'ignorelock', + 'label': '媒体信息锁定时也进行刮削', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'delay', + 'label': '入库延迟时间(秒)', + 'placeholder': '120' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'allowlist', + 'label': '刮削白名单', + 'rows': 6, + 'placeholder': '使用,分隔电视剧名称' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '注意:刮削白名单(留空), 则全部刮削. 否则仅刮削白名单.' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '注意:如需刮削已经入库的项目, 可通过mp重新整理单集即可.' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "ignorelock": False, + "allowlist": "", + "delay": 120 + } + + def get_page(self) -> List[dict]: + pass + + @eventmanager.register(EventType.TransferComplete) + def scrap_rt(self, event: Event): + """ + 根据事件实时刮削剧集组信息 + """ + if not self.get_state(): + return + # 事件数据 + mediainfo: schemas.MediaInfo = event.event_data.get("mediainfo") + meta: MetaBase = event.event_data.get("meta") + # self.log_error(f"{event.event_data}") + if not mediainfo or not meta: + return + # 非TV类型不处理 + if mediainfo.type != schemas.MediaType.TV: + self.log_warn(f"{mediainfo.title} 非TV类型, 无需处理") + return + # 没有tmdbID不处理 + if not mediainfo.tmdb_id: + self.log_warn(f"{mediainfo.title} 没有tmdbID, 无需处理") + return + if len(self._allowlist) != 0 \ + and mediainfo.title not in self._allowlist: + self.log_warn(f"{mediainfo.title} 不在白名单, 无需处理") + return + # 获取剧集组信息 + try: + episode_groups = self.tv.episode_groups(mediainfo.tmdb_id) + if not episode_groups: + self.log_warn(f"{mediainfo.title} 没有剧集组, 无需处理") + return + self.log_info(f"{mediainfo.title_year} 剧集组数量: {len(episode_groups)} - {episode_groups}") + # episodegroup = self.tv.group_episodes(episode_groups[0].get('id')) + except Exception as e: + self.log_error(f"{mediainfo.title} {str(e)}") + return + # 延迟 + if self._delay: + self.log_warn(f"{mediainfo.title} 将在 {self._delay} 秒后开始处理..") + time.sleep(int(self._delay)) + # 获取可用的媒体服务器 + _existsinfo = self.chain.media_exists(mediainfo=mediainfo) + existsinfo: ExistMediaInfo = self.__media_exists(server=_existsinfo.server, mediainfo=mediainfo, + existsinfo=_existsinfo) + if not existsinfo or not existsinfo.itemid: + self.log_warn(f"{mediainfo.title_year} 在媒体库中不存在") + return + # 新增需要的属性 + existsinfo.server = _existsinfo.server + existsinfo.type = _existsinfo.type + self.log_info(f"{mediainfo.title_year} 存在于媒体服务器: {_existsinfo.server}") + # 获取全部剧集组信息 + copy_keys = ['Id', 'Name', 'ChannelNumber', 'OriginalTitle', 'ForcedSortName', 'SortName', 'CommunityRating', + 'CriticRating', 'IndexNumber', 'ParentIndexNumber', 'SortParentIndexNumber', 'SortIndexNumber', + 'DisplayOrder', 'Album', 'AlbumArtists', 'ArtistItems', 'Overview', 'Status', 'Genres', 'Tags', + 'TagItems', 'Studios', 'PremiereDate', 'DateCreated', 'ProductionYear', 'Video3DFormat', + 'OfficialRating', 'CustomRating', 'People', 'LockData', 'LockedFields', 'ProviderIds', + 'PreferredMetadataLanguage', 'PreferredMetadataCountryCode', 'Taglines'] + for episode_group in episode_groups: + if not bool(existsinfo.groupep): + break + try: + id = episode_group.get('id') + name = episode_group.get('name') + if not id: + continue + # 处理 + self.log_info(f"正在匹配剧集组: {id}") + groups_meta = self.tv.group_episodes(id) + if not groups_meta: + continue + for groups in groups_meta: + if not bool(existsinfo.groupep): + break + # 剧集组中的季 + order = groups.get("order") + # 剧集组中的集列表 + episodes = groups.get("episodes") + if not order or not episodes or len(episodes) == 0: + continue + # 进行集数匹配, 确定剧集组信息 + ep = existsinfo.groupep.get(order) + if not ep or len(ep) != len(episodes): + continue + self.log_info(f"已匹配剧集组: {name}, {id}, 第 {order} 季") + # 遍历全部媒体项并更新 + for _index, _ids in enumerate(existsinfo.groupid.get(order)): + # 提取出媒体库中集id对应的集数index + ep_num = ep[_index] + for _id in _ids: + # 获取媒体服务器媒体项 + iteminfo = self.get_iteminfo(server=existsinfo.server, itemid=_id) + if not iteminfo: + self.log_info(f"未找到媒体项 - itemid: {_id}, 第 {order} 季, 第 {ep_num} 集") + continue + # 是否无视项目锁定 + if not self._ignorelock: + if iteminfo.get("LockData") or ( + "Name" in iteminfo.get("LockedFields", []) + and "Overview" in iteminfo.get("LockedFields", [])): + self.log_warn(f"已锁定媒体项 - itemid: {_id}, 第 {order} 季, 第 {ep_num} 集") + continue + # 替换项目数据 + episode = episodes[ep_num - 1] + new_dict = {} + new_dict.update({k: v for k, v in iteminfo.items() if k in copy_keys}) + new_dict["Name"] = episode["name"] + new_dict["Overview"] = episode["overview"] + new_dict["ParentIndexNumber"] = str(order) + new_dict["IndexNumber"] = str(ep_num) + new_dict["LockData"] = True + if episode.get("vote_average"): + new_dict["CommunityRating"] = episode.get("vote_average") + if not new_dict["LockedFields"]: + new_dict["LockedFields"] = [] + self.__append_to_list(new_dict["LockedFields"], "Name") + self.__append_to_list(new_dict["LockedFields"], "Overview") + # 更新数据 + self.set_iteminfo(server=existsinfo.server, itemid=_id, iteminfo=new_dict) + # still_path 图片 + if episode.get("still_path"): + self.set_item_image(server=existsinfo.server, itemid=_id, + imageurl=f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{episode['still_path']}") + self.log_info(f"已修改剧集 - itemid: {_id}, 第 {order} 季, 第 {ep_num} 集") + # 移除已经处理成功的季 + existsinfo.groupep.pop(order, 0) + existsinfo.groupid.pop(order, 0) + continue + except Exception as e: + self.log_warn(f"错误忽略: {str(e)}") + continue + + self.log_info(f"{mediainfo.title_year} 已经运行完毕了..") + + @staticmethod + def __append_to_list(list, item): + if item not in list: + list.append(item) + + def __media_exists(self, server: str, mediainfo: schemas.MediaInfo, + existsinfo: schemas.ExistMediaInfo) -> ExistMediaInfo: + """ + 根据媒体信息,返回剧集列表与剧集ID列表 + :param mediainfo: 媒体信息 + :return: 剧集列表与剧集ID列表 + """ + + def __emby_media_exists(): + # 获取系列id + item_id = None + try: + res = self.emby.get_data(("[HOST]emby/Items?" + "IncludeItemTypes=Series" + "&Fields=ProductionYear" + "&StartIndex=0" + "&Recursive=true" + "&SearchTerm=%s" + "&Limit=10" + "&IncludeSearchTypes=false" + "&api_key=[APIKEY]") % mediainfo.title) + res_items = res.json().get("Items") + if res_items: + for res_item in res_items: + if res_item.get('Name') == mediainfo.title and ( + not mediainfo.year or str(res_item.get('ProductionYear')) == str(mediainfo.year)): + item_id = res_item.get('Id') + except Exception as e: + self.log_error(f"连接Items出错:" + str(e)) + if not item_id: + return None + # 验证tmdbid是否相同 + item_info = self.emby.get_iteminfo(item_id) + if item_info: + if mediainfo.tmdb_id and item_info.tmdbid: + if str(mediainfo.tmdb_id) != str(item_info.tmdbid): + self.log_error(f"tmdbid不匹配或不存在") + return None + try: + res_json = self.emby.get_data( + "[HOST]emby/Shows/%s/Episodes?Season=&IsMissing=false&api_key=[APIKEY]" % item_id) + if res_json: + tv_item = res_json.json() + res_items = tv_item.get("Items") + group_ep = {} + group_id = {} + for res_item in res_items: + season_index = res_item.get("ParentIndexNumber") + if not season_index: + continue + episode_index = res_item.get("IndexNumber") + if not episode_index: + continue + if season_index not in group_ep: + group_ep[season_index] = [] + group_id[season_index] = [] + if episode_index not in group_ep[season_index]: + group_ep[season_index].append(episode_index) + group_id[season_index].append([]) + # 找到准确的插入索引 + _index = group_ep[season_index].index(episode_index) + if res_item.get("Id") not in group_id[season_index][_index]: + group_id[season_index][_index].append(res_item.get("Id")) + # 返回 + return ExistMediaInfo( + itemid=item_id, + groupep=group_ep, + groupid=group_id + ) + except Exception as e: + self.log_error(f"连接Shows/Id/Episodes出错:{str(e)}") + return None + + def __jellyfin_media_exists(): + # 获取系列id + item_id = None + try: + res = self.jellyfin.get_data(url=f"[HOST]Users/[USER]/Items?api_key=[APIKEY]" + f"&searchTerm={mediainfo.title}" + f"&IncludeItemTypes=Series" + f"&Limit=10&Recursive=true") + res_items = res.json().get("Items") + if res_items: + for res_item in res_items: + if res_item.get('Name') == mediainfo.title and ( + not mediainfo.year or str(res_item.get('ProductionYear')) == str(mediainfo.year)): + item_id = res_item.get('Id') + except Exception as e: + self.log_error(f"连接Items出错:" + str(e)) + if not item_id: + return None + # 验证tmdbid是否相同 + item_info = self.jellyfin.get_iteminfo(item_id) + if item_info: + if mediainfo.tmdb_id and item_info.tmdbid: + if str(mediainfo.tmdb_id) != str(item_info.tmdbid): + self.log_error(f"tmdbid不匹配或不存在") + return None + try: + res_json = self.jellyfin.get_data( + "[HOST]emby/Shows/%s/Episodes?Season=&IsMissing=false&api_key=[APIKEY]" % item_id) + if res_json: + tv_item = res_json.json() + res_items = tv_item.get("Items") + group_ep = {} + group_id = {} + for res_item in res_items: + season_index = res_item.get("ParentIndexNumber") + if not season_index: + continue + episode_index = res_item.get("IndexNumber") + if not episode_index: + continue + if season_index not in group_ep: + group_ep[season_index] = [] + group_id[season_index] = [] + if episode_index not in group_ep[season_index]: + group_ep[season_index].append(episode_index) + group_id[season_index].append([]) + # 找到准确的插入索引 + _index = group_ep[season_index].index(episode_index) + if res_item.get("Id") not in group_id[season_index][_index]: + group_id[season_index][_index].append(res_item.get("Id")) + # 返回 + return ExistMediaInfo( + itemid=item_id, + groupep=group_ep, + groupid=group_id + ) + except Exception as e: + self.log_error(f"连接Shows/Id/Episodes出错:{str(e)}") + return None + + def __plex_media_exists(): + try: + _plex = self.plex.get_plex() + if not _plex: + return None + if existsinfo.itemid: + videos = _plex.fetchItem(existsinfo.itemid) + else: + # 根据标题和年份模糊搜索,该结果不够准确 + videos = _plex.library.search(title=mediainfo.title, + year=mediainfo.year, + libtype="show") + if (not videos + and mediainfo.original_title + and str(mediainfo.original_title) != str(mediainfo.title)): + videos = _plex.library.search(title=mediainfo.original_title, + year=mediainfo.year, + libtype="show") + if not videos: + return None + if isinstance(videos, list): + videos = videos[0] + video_tmdbid = __get_ids(videos.guids).get('tmdb_id') + if mediainfo.tmdb_id and video_tmdbid: + if str(video_tmdbid) != str(mediainfo.tmdb_id): + self.log_error(f"tmdbid不匹配或不存在") + return None + episodes = videos.episodes() + group_ep = {} + group_id = {} + for episode in episodes: + season_index = episode.seasonNumber + if not season_index: + continue + episode_index = episode.index + if not episode_index: + continue + episode_id = episode.key + if not episode_id: + continue + if season_index not in group_ep: + group_ep[season_index] = [] + group_id[season_index] = [] + if episode_index not in group_ep[season_index]: + group_ep[season_index].append(episode_index) + group_id[season_index].append([]) + # 找到准确的插入索引 + _index = group_ep[season_index].index(episode_index) + if episode_id not in group_id[season_index][_index]: + group_id[season_index][_index].append(episode_id) + # 返回 + return ExistMediaInfo( + itemid=videos.key, + groupep=group_ep, + groupid=group_id + ) + except Exception as e: + self.log_error(f"连接Shows/Id/Episodes出错:{str(e)}") + return None + + def __get_ids(guids: List[Any]) -> dict: + guid_mapping = { + "imdb://": "imdb_id", + "tmdb://": "tmdb_id", + "tvdb://": "tvdb_id" + } + ids = {} + for prefix, varname in guid_mapping.items(): + ids[varname] = None + for guid in guids: + for prefix, varname in guid_mapping.items(): + if isinstance(guid, dict): + if guid['id'].startswith(prefix): + # 找到匹配的ID + ids[varname] = guid['id'][len(prefix):] + break + else: + if guid.id.startswith(prefix): + # 找到匹配的ID + ids[varname] = guid.id[len(prefix):] + break + return ids + + if server == "emby": + return __emby_media_exists() + elif server == "jellyfin": + return __jellyfin_media_exists() + else: + return __plex_media_exists() + + def get_iteminfo(self, server: str, itemid: str) -> dict: + """ + 获得媒体项详情 + """ + + def __get_emby_iteminfo() -> dict: + """ + 获得Emby媒体项详情 + """ + try: + url = f'[HOST]emby/Users/[USER]/Items/{itemid}?' \ + f'Fields=ChannelMappingInfo&api_key=[APIKEY]' + res = self.emby.get_data(url=url) + if res: + return res.json() + except Exception as err: + self.log_error(f"获取Emby媒体项详情失败:{str(err)}") + return {} + + def __get_jellyfin_iteminfo() -> dict: + """ + 获得Jellyfin媒体项详情 + """ + try: + url = f'[HOST]Users/[USER]/Items/{itemid}?Fields=ChannelMappingInfo&api_key=[APIKEY]' + res = self.jellyfin.get_data(url=url) + if res: + result = res.json() + if result: + result['FileName'] = Path(result['Path']).name + return result + except Exception as err: + self.log_error(f"获取Jellyfin媒体项详情失败:{str(err)}") + return {} + + def __get_plex_iteminfo() -> dict: + """ + 获得Plex媒体项详情 + """ + iteminfo = {} + try: + plexitem = self.plex.get_plex().library.fetchItem(ekey=itemid) + if 'movie' in plexitem.METADATA_TYPE: + iteminfo['Type'] = 'Movie' + iteminfo['IsFolder'] = False + elif 'episode' in plexitem.METADATA_TYPE: + iteminfo['Type'] = 'Series' + iteminfo['IsFolder'] = False + if 'show' in plexitem.TYPE: + iteminfo['ChildCount'] = plexitem.childCount + iteminfo['Name'] = plexitem.title + iteminfo['Id'] = plexitem.key + iteminfo['ProductionYear'] = plexitem.year + iteminfo['ProviderIds'] = {} + for guid in plexitem.guids: + idlist = str(guid.id).split(sep='://') + if len(idlist) < 2: + continue + iteminfo['ProviderIds'][idlist[0]] = idlist[1] + for location in plexitem.locations: + iteminfo['Path'] = location + iteminfo['FileName'] = Path(location).name + iteminfo['Overview'] = plexitem.summary + iteminfo['CommunityRating'] = plexitem.audienceRating + # 增加锁定属性列表 + iteminfo['LockedFields'] = [] + try: + if plexitem.title.locked: + iteminfo['LockedFields'].append('Name') + except Exception as err: + logger.warn(f"获取Plex媒体项详情失败:{str(err)}") + pass + try: + if plexitem.summary.locked: + iteminfo['LockedFields'].append('Overview') + except Exception as err: + logger.warn(f"获取Plex媒体项详情失败:{str(err)}") + pass + return iteminfo + except Exception as err: + self.log_error(f"获取Plex媒体项详情失败:{str(err)}") + return {} + + if server == "emby": + return __get_emby_iteminfo() + elif server == "jellyfin": + return __get_jellyfin_iteminfo() + else: + return __get_plex_iteminfo() + + def set_iteminfo(self, server: str, itemid: str, iteminfo: dict): + """ + 更新媒体项详情 + """ + + def __set_emby_iteminfo(): + """ + 更新Emby媒体项详情 + """ + try: + res = self.emby.post_data( + url=f'[HOST]emby/Items/{itemid}?api_key=[APIKEY]&reqformat=json', + data=json.dumps(iteminfo), + headers={ + "Content-Type": "application/json" + } + ) + if res and res.status_code in [200, 204]: + return True + else: + self.log_error(f"更新Emby媒体项详情失败,错误码:{res.status_code}") + return False + except Exception as err: + self.log_error(f"更新Emby媒体项详情失败:{str(err)}") + return False + + def __set_jellyfin_iteminfo(): + """ + 更新Jellyfin媒体项详情 + """ + try: + res = self.jellyfin.post_data( + url=f'[HOST]Items/{itemid}?api_key=[APIKEY]', + data=json.dumps(iteminfo), + headers={ + "Content-Type": "application/json" + } + ) + if res and res.status_code in [200, 204]: + return True + else: + self.log_error(f"更新Jellyfin媒体项详情失败,错误码:{res.status_code}") + return False + except Exception as err: + self.log_error(f"更新Jellyfin媒体项详情失败:{str(err)}") + return False + + def __set_plex_iteminfo(): + """ + 更新Plex媒体项详情 + """ + try: + plexitem = self.plex.get_plex().library.fetchItem(ekey=itemid) + if 'CommunityRating' in iteminfo and iteminfo['CommunityRating']: + edits = { + 'audienceRating.value': iteminfo['CommunityRating'], + 'audienceRating.locked': 1 + } + plexitem.edit(**edits) + plexitem.editTitle(iteminfo['Name']).editSummary(iteminfo['Overview']).reload() + return True + except Exception as err: + self.log_error(f"更新Plex媒体项详情失败:{str(err)}") + return False + + if server == "emby": + return __set_emby_iteminfo() + elif server == "jellyfin": + return __set_jellyfin_iteminfo() + else: + return __set_plex_iteminfo() + + @retry(RequestException, logger=logger) + def set_item_image(self, server: str, itemid: str, imageurl: str): + """ + 更新媒体项图片 + """ + + def __download_image(): + """ + 下载图片 + """ + try: + if "doubanio.com" in imageurl: + r = RequestUtils(headers={ + 'Referer': "https://movie.douban.com/" + }, ua=settings.USER_AGENT).get_res(url=imageurl, raise_exception=True) + else: + r = RequestUtils().get_res(url=imageurl, raise_exception=True) + if r: + return base64.b64encode(r.content).decode() + else: + self.log_error(f"{imageurl} 图片下载失败,请检查网络连通性") + except Exception as err: + self.log_error(f"下载图片失败:{str(err)}") + return None + + def __set_emby_item_image(_base64: str): + """ + 更新Emby媒体项图片 + """ + try: + url = f'[HOST]emby/Items/{itemid}/Images/Primary?api_key=[APIKEY]' + res = self.emby.post_data( + url=url, + data=_base64, + headers={ + "Content-Type": "image/png" + } + ) + if res and res.status_code in [200, 204]: + return True + else: + self.log_error(f"更新Emby媒体项图片失败,错误码:{res.status_code}") + return False + except Exception as result: + self.log_error(f"更新Emby媒体项图片失败:{result}") + return False + + def __set_jellyfin_item_image(): + """ + 更新Jellyfin媒体项图片 + # FIXME 改为预下载图片 + """ + try: + url = f'[HOST]Items/{itemid}/RemoteImages/Download?' \ + f'Type=Primary&ImageUrl={imageurl}&ProviderName=TheMovieDb&api_key=[APIKEY]' + res = self.jellyfin.post_data(url=url) + if res and res.status_code in [200, 204]: + return True + else: + self.log_error(f"更新Jellyfin媒体项图片失败,错误码:{res.status_code}") + return False + except Exception as err: + self.log_error(f"更新Jellyfin媒体项图片失败:{err}") + return False + + def __set_plex_item_image(): + """ + 更新Plex媒体项图片 + # FIXME 改为预下载图片 + """ + try: + plexitem = self.plex.get_plex().library.fetchItem(ekey=itemid) + plexitem.uploadPoster(url=imageurl) + return True + except Exception as err: + self.log_error(f"更新Plex媒体项图片失败:{err}") + return False + + if server == "emby": + # 下载图片获取base64 + image_base64 = __download_image() + if image_base64: + return __set_emby_item_image(image_base64) + elif server == "jellyfin": + return __set_jellyfin_item_image() + else: + return __set_plex_item_image() + return None + + def log_error(self, ss: str): + logger.error(f"<{self.plugin_name}> {str(ss)}") + + def log_warn(self, ss: str): + logger.warn(f"<{self.plugin_name}> {str(ss)}") + + def log_info(self, ss: str): + logger.info(f"<{self.plugin_name}> {str(ss)}") + + def stop_service(self): + """ + 停止服务 + """ + pass diff --git a/plugins.v2/ffmpegthumb/__init__.py b/plugins.v2/ffmpegthumb/__init__.py new file mode 100644 index 0000000..b12454e --- /dev/null +++ b/plugins.v2/ffmpegthumb/__init__.py @@ -0,0 +1,360 @@ +import threading +from datetime import datetime, timedelta +from pathlib import Path +from threading import Event as ThreadEvent +from typing import List, Tuple, Dict, Any + +import pytz +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from app.core.config import settings +from app.core.event import eventmanager, Event +from app.log import logger +from app.plugins import _PluginBase +from app.plugins.ffmpegthumb.ffmpeg_helper import FfmpegHelper +from app.schemas import TransferInfo +from app.schemas.types import EventType +from app.utils.system import SystemUtils + +ffmpeg_lock = threading.Lock() + + +class FFmpegThumb(_PluginBase): + # 插件名称 + plugin_name = "FFmpeg缩略图" + # 插件描述 + plugin_desc = "TheMovieDb没有背景图片时使用FFmpeg截取视频文件缩略图。" + # 插件图标 + plugin_icon = "ffmpeg.png" + # 插件版本 + plugin_version = "1.2" + # 插件作者 + plugin_author = "jxxghp" + # 作者主页 + author_url = "https://github.com/jxxghp" + # 插件配置项ID前缀 + plugin_config_prefix = "ffmpegthumb_" + # 加载顺序 + plugin_order = 31 + # 可使用的用户级别 + user_level = 1 + + # 私有属性 + _scheduler = None + _enabled = False + _onlyonce = False + _cron = None + _timeline = "00:03:01" + _scan_paths = "" + _exclude_paths = "" + # 退出事件 + _event = ThreadEvent() + + def init_plugin(self, config: dict = None): + # 读取配置 + if config: + self._enabled = config.get("enabled") + self._onlyonce = config.get("onlyonce") + self._cron = config.get("cron") + self._timeline = config.get("timeline") + self._scan_paths = config.get("scan_paths") or "" + self._exclude_paths = config.get("exclude_paths") or "" + + # 停止现有任务 + self.stop_service() + + # 启动定时任务 & 立即运行一次 + if self._enabled or self._onlyonce: + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + if self._cron: + logger.info(f"FFmpeg缩略图服务启动,周期:{self._cron}") + try: + self._scheduler.add_job(func=self.__libraryscan, + trigger=CronTrigger.from_crontab(self._cron), + name="FFmpeg缩略图") + except Exception as e: + logger.error(f"FFmpeg缩略图服务启动失败,原因:{str(e)}") + self.systemmessage.put(f"FFmpeg缩略图服务启动失败,原因:{str(e)}", title="FFmpeg缩略图") + if self._onlyonce: + logger.info(f"FFmpeg缩略图服务,立即运行一次") + self._scheduler.add_job(func=self.__libraryscan, trigger='date', + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="FFmpeg缩略图") + # 关闭一次性开关 + self._onlyonce = False + self.update_config({ + "onlyonce": False, + "enabled": self._enabled, + "cron": self._cron, + "timeline": self._timeline, + "scan_paths": self._scan_paths, + "exclude_paths": self._exclude_paths + }) + if self._scheduler.get_jobs(): + # 启动服务 + self._scheduler.print_jobs() + self._scheduler.start() + + def get_state(self) -> bool: + return self._enabled + + @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]]: + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'timeline', + 'label': '截取时间', + 'placeholder': '00:03:01' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '定时扫描周期', + 'placeholder': '5位cron表达式,留空关闭' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'scan_paths', + 'label': '定时扫描路径', + 'rows': 5, + 'placeholder': '每一行一个目录' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'exclude_paths', + 'label': '定时扫描排除路径', + 'rows': 2, + 'placeholder': '每一行一个目录' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '开启插件后默认会实时处理增量整理的媒体文件,需要处理存量媒体文件时才需开启定时;需要提前安装FFmpeg:https://www.ffmpeg.org' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "cron": "", + "timeline": "00:03:01", + "scan_paths": "", + "err_hosts": "" + } + + def get_page(self) -> List[dict]: + pass + + @eventmanager.register(EventType.TransferComplete) + def scan_rt(self, event: Event): + """ + 根据事件实时扫描缩略图 + """ + if not self._enabled: + return + # 事件数据 + transferinfo: TransferInfo = event.event_data.get("transferinfo") + if not transferinfo: + return + file_list = transferinfo.file_list_new + for file in file_list: + logger.info(f"FFmpeg缩略图处理文件:{file}") + file_path = Path(file) + if not file_path.exists(): + logger.warn(f"{file_path} 不存在") + continue + if file_path.suffix not in settings.RMT_MEDIAEXT: + logger.warn(f"{file_path} 不是支持的视频文件") + continue + self.gen_file_thumb(file_path) + + def __libraryscan(self): + """ + 开始扫描媒体库 + """ + if not self._scan_paths: + return + # 排除目录 + exclude_paths = self._exclude_paths.split("\n") + # 已选择的目录 + paths = self._scan_paths.split("\n") + for path in paths: + if not path: + continue + scan_path = Path(path) + if not scan_path.exists(): + logger.warning(f"FFmpeg缩略图扫描路径不存在:{path}") + continue + logger.info(f"开始FFmpeg缩略图扫描:{path} ...") + # 遍历目录下的所有文件 + for file_path in SystemUtils.list_files(scan_path, extensions=settings.RMT_MEDIAEXT): + if self._event.is_set(): + logger.info(f"FFmpeg缩略图扫描服务停止") + return + # 排除目录 + exclude_flag = False + for exclude_path in exclude_paths: + try: + if file_path.is_relative_to(Path(exclude_path)): + exclude_flag = True + break + except Exception as err: + print(str(err)) + if exclude_flag: + logger.debug(f"{file_path} 在排除目录中,跳过 ...") + continue + # 开始处理文件 + self.gen_file_thumb(file_path) + logger.info(f"目录 {path} 扫描完成") + + def gen_file_thumb(self, file_path: Path): + """ + 处理一个文件 + """ + # 单线程处理 + with ffmpeg_lock: + try: + thumb_path = file_path.with_name(file_path.stem + "-thumb.jpg") + if thumb_path.exists(): + logger.info(f"缩略图已存在:{thumb_path}") + return + if FfmpegHelper.get_thumb(video_path=str(file_path), + image_path=str(thumb_path), frames=self._timeline): + logger.info(f"{file_path} 缩略图已生成:{thumb_path}") + except Exception as err: + logger.error(f"FFmpeg处理文件 {file_path} 时发生错误:{str(err)}") + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._event.set() + self._scheduler.shutdown() + self._event.clear() + self._scheduler = None + except Exception as e: + print(str(e)) diff --git a/plugins.v2/ffmpegthumb/ffmpeg_helper.py b/plugins.v2/ffmpegthumb/ffmpeg_helper.py new file mode 100644 index 0000000..d4ee67c --- /dev/null +++ b/plugins.v2/ffmpegthumb/ffmpeg_helper.py @@ -0,0 +1,82 @@ +import json +import subprocess + +from app.utils.system import SystemUtils + + +class FfmpegHelper: + + @staticmethod + def get_thumb(video_path: str, image_path: str, frames: str = None): + """ + 使用ffmpeg从视频文件中截取缩略图 + """ + if not frames: + frames = "00:03:01" + if not video_path or not image_path: + return False + cmd = 'ffmpeg -i "{video_path}" -ss {frames} -vframes 1 -f image2 "{image_path}"'.format(video_path=video_path, + frames=frames, + image_path=image_path) + result = SystemUtils.execute(cmd) + if result: + return True + return False + + @staticmethod + def extract_wav(video_path: str, audio_path: str, audio_index: str = None): + """ + 使用ffmpeg从视频文件中提取16000hz, 16-bit的wav格式音频 + """ + if not video_path or not audio_path: + return False + + # 提取指定音频流 + if audio_index: + command = ['ffmpeg', "-hide_banner", "-loglevel", "warning", '-y', '-i', video_path, + '-map', f'0:a:{audio_index}', + '-acodec', 'pcm_s16le', '-ac', '1', '-ar', '16000', audio_path] + else: + command = ['ffmpeg', "-hide_banner", "-loglevel", "warning", '-y', '-i', video_path, + '-acodec', 'pcm_s16le', '-ac', '1', '-ar', '16000', audio_path] + + ret = subprocess.run(command).returncode + if ret == 0: + return True + return False + + @staticmethod + def get_metadata(video_path: str): + """ + 获取视频元数据 + """ + if not video_path: + return False + + try: + command = ['ffprobe', '-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', video_path] + result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if result.returncode == 0: + return json.loads(result.stdout.decode("utf-8")) + except Exception as e: + print(e) + return None + + @staticmethod + def extract_subtitle(video_path: str, subtitle_path: str, subtitle_index: str = None): + """ + 从视频中提取字幕 + """ + if not video_path or not subtitle_path: + return False + + if subtitle_index: + command = ['ffmpeg', "-hide_banner", "-loglevel", "warning", '-y', '-i', video_path, + '-map', f'0:s:{subtitle_index}', + subtitle_path] + else: + command = ['ffmpeg', "-hide_banner", "-loglevel", "warning", '-y', '-i', video_path, subtitle_path] + ret = subprocess.run(command).returncode + if ret == 0: + return True + return False diff --git a/plugins.v2/iyuuautoseed/__init__.py b/plugins.v2/iyuuautoseed/__init__.py new file mode 100644 index 0000000..64e4f2f --- /dev/null +++ b/plugins.v2/iyuuautoseed/__init__.py @@ -0,0 +1,1246 @@ +import os +import re +from datetime import datetime, timedelta +from threading import Event +from typing import Any, List, Dict, Tuple, Optional + +import pytz +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from lxml import etree +from ruamel.yaml import CommentedMap + +from app.core.config import settings +from app.core.event import eventmanager +from app.db.site_oper import SiteOper +from app.helper.sites import SitesHelper +from app.helper.torrent import TorrentHelper +from app.log import logger +from app.modules.qbittorrent import Qbittorrent +from app.modules.transmission import Transmission +from app.plugins import _PluginBase +from app.plugins.iyuuautoseed.iyuu_helper import IyuuHelper +from app.schemas import NotificationType +from app.schemas.types import EventType +from app.utils.http import RequestUtils +from app.utils.string import StringUtils + + +class IYUUAutoSeed(_PluginBase): + # 插件名称 + plugin_name = "IYUU自动辅种" + # 插件描述 + plugin_desc = "基于IYUU官方Api实现自动辅种。" + # 插件图标 + plugin_icon = "IYUU.png" + # 插件版本 + plugin_version = "1.9.5" + # 插件作者 + plugin_author = "jxxghp" + # 作者主页 + author_url = "https://github.com/jxxghp" + # 插件配置项ID前缀 + plugin_config_prefix = "iyuuautoseed_" + # 加载顺序 + plugin_order = 17 + # 可使用的用户级别 + auth_level = 2 + + # 私有属性 + _scheduler = None + iyuuhelper = None + qb = None + tr = None + sites = None + siteoper = None + torrent = None + # 开关 + _enabled = False + _cron = None + _skipverify = False + _onlyonce = False + _token = None + _downloaders = [] + _sites = [] + _notify = False + _nolabels = None + _nopaths = None + _labelsafterseed = None + _categoryafterseed = None + _addhosttotag = False + _size = None + _clearcache = False + # 退出事件 + _event = Event() + # 种子链接xpaths + _torrent_xpaths = [ + "//form[contains(@action, 'download.php?id=')]/@action", + "//a[contains(@href, 'download.php?hash=')]/@href", + "//a[contains(@href, 'download.php?id=')]/@href", + "//a[@class='index'][contains(@href, '/dl/')]/@href", + ] + # 待校全种子hash清单 + _recheck_torrents = {} + _is_recheck_running = False + # 辅种缓存,出错的种子不再重复辅种,可清除 + _error_caches = [] + # 辅种缓存,辅种成功的种子,可清除 + _success_caches = [] + # 辅种缓存,出错的种子不再重复辅种,且无法清除。种子被删除404等情况 + _permanent_error_caches = [] + # 辅种计数 + total = 0 + realtotal = 0 + success = 0 + exist = 0 + fail = 0 + cached = 0 + + def init_plugin(self, config: dict = None): + self.sites = SitesHelper() + self.siteoper = SiteOper() + self.torrent = TorrentHelper() + # 读取配置 + if config: + self._enabled = config.get("enabled") + self._skipverify = config.get("skipverify") + self._onlyonce = config.get("onlyonce") + self._cron = config.get("cron") + self._token = config.get("token") + self._downloaders = config.get("downloaders") + self._sites = config.get("sites") or [] + self._notify = config.get("notify") + self._nolabels = config.get("nolabels") + self._nopaths = config.get("nopaths") + self._labelsafterseed = config.get("labelsafterseed") if config.get("labelsafterseed") else "已整理,辅种" + self._categoryafterseed = config.get("categoryafterseed") + self._addhosttotag = config.get("addhosttotag") + self._size = float(config.get("size")) if config.get("size") else 0 + self._clearcache = config.get("clearcache") + self._permanent_error_caches = [] if self._clearcache else config.get("permanent_error_caches") or [] + self._error_caches = [] if self._clearcache else config.get("error_caches") or [] + self._success_caches = [] if self._clearcache else config.get("success_caches") or [] + + # 过滤掉已删除的站点 + all_sites = [site.id for site in self.siteoper.list_order_by_pri()] + [site.get("id") for site in + self.__custom_sites()] + self._sites = [site_id for site_id in all_sites if site_id in self._sites] + self.__update_config() + + # 停止现有任务 + self.stop_service() + + # 启动定时任务 & 立即运行一次 + if self.get_state() or self._onlyonce: + self.iyuuhelper = IyuuHelper(token=self._token) + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + self.qb = Qbittorrent() + self.tr = Transmission() + + if self._onlyonce: + logger.info(f"辅种服务启动,立即运行一次") + self._scheduler.add_job(self.auto_seed, 'date', + run_date=datetime.now( + tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3) + ) + + # 关闭一次性开关 + self._onlyonce = False + if self._scheduler.get_jobs(): + # 追加种子校验服务 + self._scheduler.add_job(self.check_recheck, 'interval', minutes=3) + # 启动服务 + self._scheduler.print_jobs() + self._scheduler.start() + + if self._clearcache: + # 关闭清除缓存开关 + self._clearcache = False + + if self._clearcache or self._onlyonce: + # 保存配置 + self.__update_config() + + def get_state(self) -> bool: + return True if self._enabled and self._cron and self._token and self._downloaders else False + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + 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.get_state(): + return [{ + "id": "IYUUAutoSeed", + "name": "IYUU自动辅种服务", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self.auto_seed, + "kwargs": {} + }] + return [] + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + # 站点的可选项(内置站点 + 自定义站点) + customSites = self.__custom_sites() + + # 站点的可选项 + site_options = ([{"title": site.name, "value": site.id} + for site in self.siteoper.list_order_by_pri()] + + [{"title": site.get("name"), "value": site.get("id")} + for site in customSites]) + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '发送通知', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'token', + 'label': 'IYUU Token', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '执行周期', + 'placeholder': '0 0 0 ? *' + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'chips': True, + 'multiple': True, + 'model': 'downloaders', + 'label': '辅种下载器', + 'items': [ + {'title': 'Qbittorrent', 'value': 'qbittorrent'}, + {'title': 'Transmission', 'value': 'transmission'} + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'size', + 'label': '辅种体积大于(GB)', + 'placeholder': '只有大于该值的才辅种' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'chips': True, + 'multiple': True, + 'model': 'sites', + 'label': '辅种站点', + 'items': site_options + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'nolabels', + 'label': '不辅种标签', + 'placeholder': '使用,分隔多个标签' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'labelsafterseed', + 'label': '辅种后增加标签', + 'placeholder': '使用,分隔多个标签,不填写则默认为(已整理,辅种)' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'categoryafterseed', + 'label': '辅种后增加分类', + 'placeholder': '设置辅种的种子分类' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'nopaths', + 'label': '不辅种数据文件目录', + 'rows': 3, + 'placeholder': '每一行一个目录' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'addhosttotag', + 'label': '将站点名添加到标签中', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'skipverify', + 'label': '跳过校验(仅QB有效)', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'clearcache', + 'label': '清除缓存后运行', + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "skipverify": False, + "onlyonce": False, + "notify": False, + "clearcache": False, + "addhosttotag": False, + "cron": "", + "token": "", + "downloaders": [], + "sites": [], + "nopaths": "", + "nolabels": "", + "labelsafterseed": "", + "categoryafterseed": "", + "size": "" + } + + def get_page(self) -> List[dict]: + pass + + def __update_config(self): + self.update_config({ + "enabled": self._enabled, + "skipverify": self._skipverify, + "onlyonce": self._onlyonce, + "clearcache": self._clearcache, + "cron": self._cron, + "token": self._token, + "downloaders": self._downloaders, + "sites": self._sites, + "notify": self._notify, + "nolabels": self._nolabels, + "nopaths": self._nopaths, + "labelsafterseed": self._labelsafterseed, + "categoryafterseed": self._categoryafterseed, + "addhosttotag": self._addhosttotag, + "size": self._size, + "success_caches": self._success_caches, + "error_caches": self._error_caches, + "permanent_error_caches": self._permanent_error_caches + }) + + def __get_downloader(self, dtype: str): + """ + 根据类型返回下载器实例 + """ + if dtype == "qbittorrent": + return self.qb + elif dtype == "transmission": + return self.tr + else: + return None + + def auto_seed(self): + """ + 开始辅种 + """ + if not self.iyuuhelper: + return + logger.info("开始辅种任务 ...") + + # 计数器初始化 + self.total = 0 + self.realtotal = 0 + self.success = 0 + self.exist = 0 + self.fail = 0 + self.cached = 0 + # 扫描下载器辅种 + for downloader in self._downloaders: + logger.info(f"开始扫描下载器 {downloader} ...") + downloader_obj = self.__get_downloader(downloader) + # 获取下载器中已完成的种子 + torrents = downloader_obj.get_completed_torrents() + if torrents: + logger.info(f"下载器 {downloader} 已完成种子数:{len(torrents)}") + else: + logger.info(f"下载器 {downloader} 没有已完成种子") + continue + hash_strs = [] + for torrent in torrents: + if self._event.is_set(): + logger.info(f"辅种服务停止") + return + # 获取种子hash + hash_str = self.__get_hash(torrent, downloader) + if hash_str in self._error_caches or hash_str in self._permanent_error_caches: + logger.info(f"种子 {hash_str} 辅种失败且已缓存,跳过 ...") + continue + save_path = self.__get_save_path(torrent, downloader) + + if self._nopaths and save_path: + # 过滤不需要转移的路径 + nopath_skip = False + for nopath in self._nopaths.split('\n'): + if os.path.normpath(save_path).startswith(os.path.normpath(nopath)): + logger.info(f"种子 {hash_str} 保存路径 {save_path} 不需要辅种,跳过 ...") + nopath_skip = True + break + if nopath_skip: + continue + + # 获取种子标签 + torrent_labels = self.__get_label(torrent, downloader) + if torrent_labels and self._nolabels: + is_skip = False + for label in self._nolabels.split(','): + if label in torrent_labels: + logger.info(f"种子 {hash_str} 含有不辅种标签 {label},跳过 ...") + is_skip = True + break + if is_skip: + continue + + # 体积排除辅种 + torrent_size = self.__get_torrent_size(torrent, downloader) / 1024 / 1024 / 1024 + if self._size and torrent_size < self._size: + logger.info(f"种子 {hash_str} 大小:{torrent_size:.2f}GB,小于设定 {self._size}GB,跳过 ...") + continue + + hash_strs.append({ + "hash": hash_str, + "save_path": save_path + }) + if hash_strs: + logger.info(f"总共需要辅种的种子数:{len(hash_strs)}") + # 分组处理,减少IYUU Api请求次数 + chunk_size = 200 + for i in range(0, len(hash_strs), chunk_size): + # 切片操作 + chunk = hash_strs[i:i + chunk_size] + # 处理分组 + self.__seed_torrents(hash_strs=chunk, + downloader=downloader) + # 触发校验检查 + self.check_recheck() + else: + logger.info(f"没有需要辅种的种子") + # 保存缓存 + self.__update_config() + # 发送消息 + if self._notify: + if self.success or self.fail: + self.post_message( + mtype=NotificationType.SiteMessage, + title="【IYUU自动辅种任务完成】", + text=f"服务器返回可辅种总数:{self.total}\n" + f"实际可辅种数:{self.realtotal}\n" + f"已存在:{self.exist}\n" + f"成功:{self.success}\n" + f"失败:{self.fail}\n" + f"{self.cached} 条失败记录已加入缓存" + ) + logger.info("辅种任务执行完成") + + def check_recheck(self): + """ + 定时检查下载器中种子是否校验完成,校验完成且完整的自动开始辅种 + """ + if not self._recheck_torrents: + return + if self._is_recheck_running: + return + self._is_recheck_running = True + for downloader in self._downloaders: + # 需要检查的种子 + recheck_torrents = self._recheck_torrents.get(downloader) or [] + if not recheck_torrents: + continue + logger.info(f"开始检查下载器 {downloader} 的校验任务 ...") + # 下载器 + downloader_obj = self.__get_downloader(downloader) + # 获取下载器中的种子状态 + torrents, _ = downloader_obj.get_torrents(ids=recheck_torrents) + if torrents: + can_seeding_torrents = [] + for torrent in torrents: + # 获取种子hash + hash_str = self.__get_hash(torrent, downloader) + if self.__can_seeding(torrent, downloader): + can_seeding_torrents.append(hash_str) + if can_seeding_torrents: + logger.info(f"共 {len(can_seeding_torrents)} 个任务校验完成,开始辅种 ...") + # 开始任务 + downloader_obj.start_torrents(ids=can_seeding_torrents) + # 去除已经处理过的种子 + self._recheck_torrents[downloader] = list( + set(recheck_torrents).difference(set(can_seeding_torrents))) + elif torrents is None: + logger.info(f"下载器 {downloader} 查询校验任务失败,将在下次继续查询 ...") + continue + else: + logger.info(f"下载器 {downloader} 中没有需要检查的校验任务,清空待处理列表 ...") + self._recheck_torrents[downloader] = [] + self._is_recheck_running = False + + def __seed_torrents(self, hash_strs: list, downloader: str): + """ + 执行一批种子的辅种 + """ + if not hash_strs: + return + logger.info(f"下载器 {downloader} 开始查询辅种,数量:{len(hash_strs)} ...") + # 下载器中的Hashs + hashs = [item.get("hash") for item in hash_strs] + # 每个Hash的保存目录 + save_paths = {} + for item in hash_strs: + save_paths[item.get("hash")] = item.get("save_path") + # 查询可辅种数据 + seed_list, msg = self.iyuuhelper.get_seed_info(hashs) + if not isinstance(seed_list, dict): + # 判断辅种异常是否是由于Token未认证导致的,由于没有解决接口,只能从返回值来判断 + if self._token and msg == '请求缺少token': + logger.warn(f'IYUU辅种失败,疑似站点未绑定插件配置不完整,请先检查是否完成站点绑定!{msg}') + else: + logger.warn(f"当前种子列表没有可辅种的站点:{msg}") + return + else: + logger.info(f"IYUU返回可辅种数:{len(seed_list)}") + # 遍历 + for current_hash, seed_info in seed_list.items(): + if not seed_info: + continue + seed_torrents = seed_info.get("torrent") + if not isinstance(seed_torrents, list): + seed_torrents = [seed_torrents] + + # 本次辅种成功的种子 + success_torrents = [] + + for seed in seed_torrents: + if not seed: + continue + if not isinstance(seed, dict): + continue + if not seed.get("sid") or not seed.get("info_hash"): + continue + if seed.get("info_hash") in hashs: + logger.info(f"{seed.get('info_hash')} 已在下载器中,跳过 ...") + continue + if seed.get("info_hash") in self._success_caches: + logger.info(f"{seed.get('info_hash')} 已处理过辅种,跳过 ...") + continue + if seed.get("info_hash") in self._error_caches or seed.get("info_hash") in self._permanent_error_caches: + logger.info(f"种子 {seed.get('info_hash')} 辅种失败且已缓存,跳过 ...") + continue + # 添加任务 + success = self.__download_torrent(seed=seed, + downloader=downloader, + save_path=save_paths.get(current_hash)) + if success: + success_torrents.append(seed.get("info_hash")) + + # 辅种成功的去重放入历史 + if len(success_torrents) > 0: + self.__save_history(current_hash=current_hash, + downloader=downloader, + success_torrents=success_torrents) + + logger.info(f"下载器 {downloader} 辅种完成") + + def __save_history(self, current_hash: str, downloader: str, success_torrents: []): + """ + [ + { + "downloader":"2", + "torrents":[ + "248103a801762a66c201f39df7ea325f8eda521b", + "bd13835c16a5865b01490962a90b3ec48889c1f0" + ] + }, + { + "downloader":"3", + "torrents":[ + "248103a801762a66c201f39df7ea325f8eda521b", + "bd13835c16a5865b01490962a90b3ec48889c1f0" + ] + } + ] + """ + try: + # 查询当前Hash的辅种历史 + seed_history = self.get_data(key=current_hash) or [] + + new_history = True + if len(seed_history) > 0: + for history in seed_history: + if not history: + continue + if not isinstance(history, dict): + continue + if not history.get("downloader"): + continue + # 如果本次辅种下载器之前有过记录则继续添加 + if str(history.get("downloader")) == downloader: + history_torrents = history.get("torrents") or [] + history["torrents"] = list(set(history_torrents + success_torrents)) + new_history = False + break + + # 本次辅种下载器之前没有成功记录则新增 + if new_history: + seed_history.append({ + "downloader": downloader, + "torrents": list(set(success_torrents)) + }) + + # 保存历史 + self.save_data(key=current_hash, + value=seed_history) + except Exception as e: + print(str(e)) + + def __download(self, downloader: str, content: bytes, + save_path: str, site_name: str) -> Optional[str]: + + torrent_tags = self._labelsafterseed.split(',') + + # 辅种 tag 叠加站点名 + if self._addhosttotag: + torrent_tags.append(site_name) + + """ + 添加下载任务 + """ + if downloader == "qbittorrent": + # 生成随机Tag + tag = StringUtils.generate_random_str(10) + + torrent_tags.append(tag) + + state = self.qb.add_torrent(content=content, + download_dir=save_path, + is_paused=True, + tag=torrent_tags, + category=self._categoryafterseed, + is_skip_checking=self._skipverify) + if not state: + return None + else: + # 获取种子Hash + torrent_hash = self.qb.get_torrent_id_by_tag(tags=tag) + if not torrent_hash: + logger.error(f"{downloader} 下载任务添加成功,但获取任务信息失败!") + return None + return torrent_hash + elif downloader == "transmission": + # 添加任务 + torrent = self.tr.add_torrent(content=content, + download_dir=save_path, + is_paused=True, + labels=torrent_tags) + if not torrent: + return None + else: + return torrent.hashString + + logger.error(f"不支持的下载器:{downloader}") + return None + + def __download_torrent(self, seed: dict, downloader: str, save_path: str): + """ + 下载种子 + torrent: { + "sid": 3, + "torrent_id": 377467, + "info_hash": "a444850638e7a6f6220e2efdde94099c53358159" + } + """ + + def __is_special_site(url): + """ + 判断是否为特殊站点(是否需要添加https) + """ + if "hdsky.me" in url: + return False + return True + + self.total += 1 + # 获取种子站点及下载地址模板 + site_url, download_page = self.iyuuhelper.get_torrent_url(seed.get("sid")) + if not site_url or not download_page: + # 加入缓存 + self._error_caches.append(seed.get("info_hash")) + self.fail += 1 + self.cached += 1 + return False + # 查询站点 + site_domain = StringUtils.get_url_domain(site_url) + # 站点信息 + site_info = self.sites.get_indexer(site_domain) + if not site_info or not site_info.get('url'): + logger.debug(f"没有维护种子对应的站点:{site_url}") + return False + if self._sites and site_info.get('id') not in self._sites: + logger.info("当前站点不在选择的辅种站点范围,跳过 ...") + return False + self.realtotal += 1 + # 查询hash值是否已经在下载器中 + downloader_obj = self.__get_downloader(downloader) + torrent_info, _ = downloader_obj.get_torrents(ids=[seed.get("info_hash")]) + if torrent_info: + logger.info(f"{seed.get('info_hash')} 已在下载器中,跳过 ...") + self.exist += 1 + return False + # 站点流控 + check, checkmsg = self.sites.check(site_domain) + if check: + logger.warn(checkmsg) + self.fail += 1 + return False + # 下载种子 + torrent_url = self.__get_download_url(seed=seed, + site=site_info, + base_url=download_page) + if not torrent_url: + # 加入失败缓存 + self._error_caches.append(seed.get("info_hash")) + self.fail += 1 + self.cached += 1 + return False + # 强制使用Https + if __is_special_site(torrent_url): + if "?" in torrent_url: + torrent_url += "&https=1" + else: + torrent_url += "?https=1" + # 下载种子文件 + _, content, _, _, error_msg = self.torrent.download_torrent( + url=torrent_url, + cookie=site_info.get("cookie"), + ua=site_info.get("ua") or settings.USER_AGENT, + proxy=site_info.get("proxy")) + if not content: + # 下载失败 + self.fail += 1 + # 加入失败缓存 + if error_msg and ('无法打开链接' in error_msg or '触发站点流控' in error_msg): + self._error_caches.append(seed.get("info_hash")) + else: + # 种子不存在的情况 + self._permanent_error_caches.append(seed.get("info_hash")) + logger.error(f"下载种子文件失败:{torrent_url}") + return False + # 添加下载,辅种任务默认暂停 + logger.info(f"添加下载任务:{torrent_url} ...") + download_id = self.__download(downloader=downloader, + content=content, + save_path=save_path, + site_name=site_info.get("name")) + if not download_id: + # 下载失败 + self.fail += 1 + # 加入失败缓存 + self._error_caches.append(seed.get("info_hash")) + return False + else: + self.success += 1 + if self._skipverify: + # 跳过校验 + logger.info(f"{download_id} 跳过校验,请自行检查...") + # 请注意这里是故意不自动开始的 + # 跳过校验存在直接失败、种子目录相同文件不同等异常情况 + # 必须要用户自行二次确认之后才能开始做种 + # 否则会出现反复下载刷掉分享率、做假种的情况 + else: + # 追加校验任务 + logger.info(f"添加校验检查任务:{download_id} ...") + if not self._recheck_torrents.get(downloader): + self._recheck_torrents[downloader] = [] + self._recheck_torrents[downloader].append(download_id) + # TR会自动校验 + if downloader == "qbittorrent": + # 开始校验种子 + downloader_obj.recheck_torrents(ids=[download_id]) + # 下载成功 + logger.info(f"成功添加辅种下载,站点:{site_info.get('name')},种子链接:{torrent_url}") + # 成功也加入缓存,有一些改了路径校验不通过的,手动删除后,下一次又会辅上 + self._success_caches.append(seed.get("info_hash")) + return True + + @staticmethod + def __get_hash(torrent: Any, dl_type: str): + """ + 获取种子hash + """ + try: + return torrent.get("hash") if dl_type == "qbittorrent" else torrent.hashString + except Exception as e: + print(str(e)) + return "" + + @staticmethod + def __get_label(torrent: Any, dl_type: str): + """ + 获取种子标签 + """ + try: + return [str(tag).strip() for tag in torrent.get("tags").split(',')] \ + if dl_type == "qbittorrent" else torrent.labels or [] + except Exception as e: + print(str(e)) + return [] + + @staticmethod + def __can_seeding(torrent: Any, dl_type: str): + """ + 判断种子是否可以做种并处于暂停状态 + """ + try: + return torrent.get("state") == "pausedUP" if dl_type == "qbittorrent" \ + else (torrent.status.stopped and torrent.percent_done == 1) + except Exception as e: + print(str(e)) + return False + + @staticmethod + def __get_save_path(torrent: Any, dl_type: str): + """ + 获取种子保存路径 + """ + try: + return torrent.get("save_path") if dl_type == "qbittorrent" else torrent.download_dir + except Exception as e: + print(str(e)) + return "" + + @staticmethod + def __get_torrent_size(torrent: Any, dl_type: str): + """ + 获取种子大小 int bytes + """ + try: + return torrent.get("total_size") if dl_type == "qbittorrent" else torrent.total_size + except Exception as e: + print(str(e)) + return "" + + def __get_download_url(self, seed: dict, site: CommentedMap, base_url: str): + """ + 拼装种子下载链接 + """ + + def __is_mteam(url: str): + """ + 判断是否为mteam站点 + """ + return True if "m-team." in url else False + + def __is_monika(url: str): + """ + 判断是否为monika站点 + """ + return True if "monikadesign." in url else False + + def __get_mteam_enclosure(tid: str, apikey: str): + """ + 获取mteam种子下载链接 + """ + if not apikey: + logger.error("m-team站点的apikey未配置") + return None + + """ + 将mteam种子下载链接域名替换为使用API + """ + api_url = re.sub(r'//[^/]+\.m-team', '//api.m-team', site.get('url')) + + res = RequestUtils( + headers={ + 'Content-Type': 'application/json', + 'User-Agent': f'{site.get("ua")}', + 'Accept': 'application/json, text/plain, */*', + 'x-api-key': apikey + } + ).post_res(f"{api_url}api/torrent/genDlToken", params={ + 'id': tid + }) + if not res: + logger.warn(f"m-team 获取种子下载链接失败:{tid}") + return None + return res.json().get("data") + + def __get_monika_torrent(tid: str, rssurl: str): + """ + Monika下载需要使用rsskey从站点配置中获取并拼接下载链接 + """ + if not rssurl: + logger.error("Monika站点的rss链接未配置") + return None + + rss_match = re.search(r'/rss/\d+\.(\w+)', rssurl) + rsskey = rss_match.group(1) + download_url = f"{site.get('url')}torrents/download/{tid}.{rsskey}" + return download_url + + def __is_special_site(url: str): + """ + 判断是否为特殊站点 + """ + spec_params = ["hash=", "authkey="] + if any(field in base_url for field in spec_params): + return True + if "hdchina.org" in url: + return True + if "hdsky.me" in url: + return True + if "hdcity.in" in url: + return True + if "totheglory.im" in url: + return True + return False + + try: + if __is_mteam(site.get('url')): + # 调用mteam接口获取下载链接 + return __get_mteam_enclosure(tid=seed.get("torrent_id"), apikey=site.get("apikey")) + if __is_monika(site.get('url')): + # 返回种子id和站点配置中所Monika的rss链接 + return __get_monika_torrent(tid=seed.get("torrent_id"), rssurl=site.get("rss")) + elif __is_special_site(site.get('url')): + # 从详情页面获取下载链接 + return self.__get_torrent_url_from_page(seed=seed, site=site) + else: + download_url = base_url.replace( + "id={}", + "id={id}" + ).replace( + "/{}", + "/{id}" + ).replace( + "/{torrent_key}", + "" + ).format( + **{ + "id": seed.get("torrent_id"), + "passkey": site.get("passkey") or '', + "uid": site.get("uid") or '', + } + ) + if download_url.count("{"): + logger.warn(f"当前不支持该站点的辅助任务,Url转换失败:{seed}") + return None + download_url = re.sub(r"[&?]passkey=", "", + re.sub(r"[&?]uid=", "", + download_url, + flags=re.IGNORECASE), + flags=re.IGNORECASE) + return f"{site.get('url')}{download_url}" + except Exception as e: + logger.warn( + f"{site.get('name')} Url转换失败,{str(e)}:site_url={site.get('url')},base_url={base_url}, seed={seed}") + return self.__get_torrent_url_from_page(seed=seed, site=site) + + def __get_torrent_url_from_page(self, seed: dict, site: dict): + """ + 从详情页面获取下载链接 + """ + if not site.get('url'): + logger.warn(f"站点 {site.get('name')} 未获取站点地址,无法获取种子下载链接") + return None + try: + page_url = f"{site.get('url')}details.php?id={seed.get('torrent_id')}&hit=1" + logger.info(f"正在获取种子下载链接:{page_url} ...") + res = RequestUtils( + cookies=site.get("cookie"), + ua=site.get("ua"), + proxies=settings.PROXY if site.get("proxy") else None + ).get_res(url=page_url) + if res is not None and res.status_code in (200, 500): + if "charset=utf-8" in res.text or "charset=UTF-8" in res.text: + res.encoding = "UTF-8" + else: + res.encoding = res.apparent_encoding + if not res.text: + logger.warn(f"获取种子下载链接失败,页面内容为空:{page_url}") + return None + # 使用xpath从页面中获取下载链接 + html = etree.HTML(res.text) + for xpath in self._torrent_xpaths: + download_url = html.xpath(xpath) + if download_url: + download_url = download_url[0] + logger.info(f"获取种子下载链接成功:{download_url}") + if not download_url.startswith("http"): + if download_url.startswith("/"): + download_url = f"{site.get('url')}{download_url[1:]}" + else: + download_url = f"{site.get('url')}{download_url}" + return download_url + logger.warn(f"获取种子下载链接失败,未找到下载链接:{page_url}") + return None + else: + logger.error(f"获取种子下载链接失败,请求失败:{page_url},{res.status_code if res else ''}") + return None + except Exception as e: + logger.warn(f"获取种子下载链接失败:{str(e)}") + return None + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._event.set() + self._scheduler.shutdown() + self._event.clear() + self._scheduler = None + except Exception as e: + print(str(e)) + + def __custom_sites(self) -> List[Any]: + custom_sites = [] + custom_sites_config = self.get_config("CustomSites") + if custom_sites_config and custom_sites_config.get("enabled"): + custom_sites = custom_sites_config.get("sites") + return custom_sites + + @eventmanager.register(EventType.SiteDeleted) + def site_deleted(self, event): + """ + 删除对应站点选中 + """ + site_id = event.event_data.get("site_id") + config = self.get_config() + if config: + sites = config.get("sites") + if sites: + if isinstance(sites, str): + sites = [sites] + + # 删除对应站点 + if site_id: + sites = [site for site in sites if int(site) != int(site_id)] + else: + # 清空 + sites = [] + + # 若无站点,则停止 + if len(sites) == 0: + self._enabled = False + + self._sites = sites + # 保存配置 + self.__update_config() diff --git a/plugins.v2/iyuuautoseed/iyuu_helper.py b/plugins.v2/iyuuautoseed/iyuu_helper.py new file mode 100644 index 0000000..7fd1a70 --- /dev/null +++ b/plugins.v2/iyuuautoseed/iyuu_helper.py @@ -0,0 +1,115 @@ +import hashlib +import json +import time +from typing import Tuple, Optional + +from app.utils.http import RequestUtils + + +class IyuuHelper(object): + """ + 适配新版本IYUU开发版 + """ + _version = "8.2.0" + _api_base = "https://dev.iyuu.cn" + _sites = {} + _token = None + _sid_sha1 = None + + def __init__(self, token: str): + self._token = token + if self._token: + self.init_config() + + def init_config(self): + pass + + def __request_iyuu(self, url: str, method: str = "get", params: dict = None) -> Tuple[Optional[dict], str]: + """ + 向IYUUApi发送请求 + """ + if method == "post": + ret = RequestUtils( + accept_type="application/json", + headers={'token': self._token} + ).post_res(f'{self._api_base + url}', json=params) + else: + ret = RequestUtils( + accept_type="application/json", + headers={'token': self._token} + ).get_res(f'{self._api_base + url}', params=params) + if ret: + result = ret.json() + if result.get('code') == 0: + return result.get('data'), "" + else: + return None, f'请求IYUU失败,状态码:{result.get("code")},返回信息:{result.get("msg")}' + elif ret is not None: + return None, f"请求IYUU失败,状态码:{ret.status_code},错误原因:{ret.reason}" + else: + return None, f"请求IYUU失败,未获取到返回信息" + + def get_torrent_url(self, sid: str) -> Tuple[Optional[str], Optional[str]]: + if not sid: + return None, None + if not self._sites: + self._sites = self.__get_sites() + if not self._sites.get(sid): + return None, None + site = self._sites.get(sid) + return site.get('base_url'), site.get('download_page') + + def __get_sites(self) -> dict: + """ + 返回支持辅种的全部站点 + :return: 站点列表、错误信息 + """ + result, msg = self.__request_iyuu(url='/reseed/sites/index') + if result: + ret_sites = {} + sites = result.get('sites') + for site in sites: + ret_sites[site.get('id')] = site + return ret_sites + else: + print(msg) + return {} + + def __report_existing(self) -> Optional[str]: + """ + 汇报辅种的站点 + :return: + """ + if not self._sites: + self._sites = self.__get_sites() + sid_list = list(self._sites.keys()) + result, msg = self.__request_iyuu(url='/reseed/sites/reportExisting', + method='post', + params={'sid_list': sid_list}) + if result: + return result.get('sid_sha1') + return None + + def get_seed_info(self, info_hashs: list) -> Tuple[Optional[dict], str]: + """ + 返回info_hash对应的站点id、种子id + :param info_hashs: + :return: + """ + if not self._sid_sha1: + self._sid_sha1 = self.__report_existing() + info_hashs.sort() + json_data = json.dumps(info_hashs, separators=(',', ':'), ensure_ascii=False) + sha1 = self.get_sha1(json_data) + result, msg = self.__request_iyuu(url='/reseed/index/index', method='post', params={ + 'hash': json_data, + 'sha1': sha1, + 'sid_sha1': self._sid_sha1, + 'timestamp': int(time.time()), + 'version': self._version + }) + return result, msg + + @staticmethod + def get_sha1(json_str: str) -> str: + return hashlib.sha1(json_str.encode('utf-8')).hexdigest() diff --git a/plugins.v2/libraryscraper/__init__.py b/plugins.v2/libraryscraper/__init__.py new file mode 100644 index 0000000..be18407 --- /dev/null +++ b/plugins.v2/libraryscraper/__init__.py @@ -0,0 +1,437 @@ +from datetime import datetime, timedelta +from pathlib import Path +from threading import Event +from typing import List, Tuple, Dict, Any + +import pytz +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from app.core.config import settings +from app.core.metainfo import MetaInfoPath +from app.db.transferhistory_oper import TransferHistoryOper +from app.helper.nfo import NfoReader +from app.log import logger +from app.plugins import _PluginBase +from app.schemas import MediaType +from app.utils.system import SystemUtils + + +class LibraryScraper(_PluginBase): + # 插件名称 + plugin_name = "媒体库刮削" + # 插件描述 + plugin_desc = "定时对媒体库进行刮削,补齐缺失元数据和图片。" + # 插件图标 + plugin_icon = "scraper.png" + # 插件版本 + plugin_version = "1.5" + # 插件作者 + plugin_author = "jxxghp" + # 作者主页 + author_url = "https://github.com/jxxghp" + # 插件配置项ID前缀 + plugin_config_prefix = "libraryscraper_" + # 加载顺序 + plugin_order = 7 + # 可使用的用户级别 + user_level = 1 + + # 私有属性 + transferhis = None + _scheduler = None + _scraper = None + # 限速开关 + _enabled = False + _onlyonce = False + _cron = None + _mode = "" + _scraper_paths = "" + _exclude_paths = "" + # 退出事件 + _event = Event() + + def init_plugin(self, config: dict = None): + # 读取配置 + if config: + self._enabled = config.get("enabled") + self._onlyonce = config.get("onlyonce") + self._cron = config.get("cron") + self._mode = config.get("mode") or "" + self._scraper_paths = config.get("scraper_paths") or "" + self._exclude_paths = config.get("exclude_paths") or "" + + # 停止现有任务 + self.stop_service() + + # 启动定时任务 & 立即运行一次 + if self._enabled or self._onlyonce: + self.transferhis = TransferHistoryOper() + + if self._onlyonce: + logger.info(f"媒体库刮削服务,立即运行一次") + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + self._scheduler.add_job(func=self.__libraryscraper, trigger='date', + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="媒体库刮削") + # 关闭一次性开关 + self._onlyonce = False + self.update_config({ + "onlyonce": False, + "enabled": self._enabled, + "cron": self._cron, + "mode": self._mode, + "scraper_paths": self._scraper_paths, + "exclude_paths": self._exclude_paths + }) + if self._scheduler.get_jobs(): + # 启动服务 + self._scheduler.print_jobs() + self._scheduler.start() + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + 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 and self._cron: + return [{ + "id": "LibraryScraper", + "name": "媒体库刮削", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self.__libraryscraper, + "kwargs": {} + }] + elif self._enabled: + return [{ + "id": "LibraryScraper", + "name": "媒体库刮削", + "trigger": CronTrigger.from_crontab("0 0 */7 * *"), + "func": self.__libraryscraper, + "kwargs": {} + }] + return [] + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'mode', + 'label': '刮削模式', + 'items': [ + {'title': '仅刮削缺失元数据和图片', 'value': ''}, + {'title': '覆盖所有元数据和图片', 'value': 'force_all'}, + {'title': '覆盖所有元数据', 'value': 'force_nfo'}, + {'title': '覆盖所有图片', 'value': 'force_image'}, + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '执行周期', + 'placeholder': '5位cron表达式,留空自动' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'scraper_paths', + 'label': '削刮路径', + 'rows': 5, + 'placeholder': '每一行一个目录' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'exclude_paths', + 'label': '排除路径', + 'rows': 2, + 'placeholder': '每一行一个目录' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '刮削路径后拼接#电视剧/电影,强制指定该媒体路径媒体类型。' + '不加默认根据文件名自动识别媒体类型。' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "cron": "0 0 */7 * *", + "mode": "", + "scraper_paths": "", + "err_hosts": "" + } + + def get_page(self) -> List[dict]: + pass + + def __libraryscraper(self): + """ + 开始刮削媒体库 + """ + if not self._scraper_paths: + return + # 排除目录 + exclude_paths = self._exclude_paths.split("\n") + # 已选择的目录 + paths = self._scraper_paths.split("\n") + for path in paths: + if not path: + continue + # 强制指定该路径媒体类型 + mtype = None + if str(path).count("#") == 1: + mtype = next( + (mediaType for mediaType in MediaType.__members__.values() if + mediaType.value == str(str(path).split("#")[1])), + None) + path = str(path).split("#")[0] + scraper_path = Path(path) + if not scraper_path.exists(): + logger.warning(f"媒体库刮削路径不存在:{path}") + continue + logger.info(f"开始刮削媒体库:{path} {mtype} ...") + # 遍历所有文件 + files = SystemUtils.list_files(scraper_path, settings.RMT_MEDIAEXT) + for file_path in files: + if self._event.is_set(): + logger.info(f"媒体库刮削服务停止") + return + # 排除目录 + exclude_flag = False + for exclude_path in exclude_paths: + try: + if file_path.is_relative_to(Path(exclude_path)): + exclude_flag = True + break + except Exception as err: + print(str(err)) + if exclude_flag: + logger.debug(f"{file_path} 在排除目录中,跳过 ...") + continue + # 开始刮削文件 + self.__scrape_file(file=file_path, mtype=mtype) + logger.info(f"媒体库 {path} 刮削完成") + + def __scrape_file(self, file: Path, mtype: MediaType = None): + """ + 削刮一个目录,该目录必须是媒体文件目录 + """ + # 识别元数据 + meta_info = MetaInfoPath(file) + # 强制指定类型 + if mtype: + meta_info.type = mtype + + # 是否刮削 + force_nfo = self._mode in ["force_all", "force_nfo"] + force_img = self._mode in ["force_all", "force_image"] + + # 优先读取本地nfo文件 + tmdbid = None + if meta_info.type == MediaType.MOVIE: + # 电影 + movie_nfo = file.parent / "movie.nfo" + if movie_nfo.exists(): + tmdbid = self.__get_tmdbid_from_nfo(movie_nfo) + file_nfo = file.with_suffix(".nfo") + if not tmdbid and file_nfo.exists(): + tmdbid = self.__get_tmdbid_from_nfo(file_nfo) + else: + # 电视剧 + tv_nfo = file.parent.parent / "tvshow.nfo" + if tv_nfo.exists(): + tmdbid = self.__get_tmdbid_from_nfo(tv_nfo) + if tmdbid: + # 按TMDBID识别 + logger.info(f"读取到本地nfo文件的tmdbid:{tmdbid}") + mediainfo = self.chain.recognize_media(tmdbid=tmdbid, mtype=meta_info.type) + else: + # 按名称识别 + mediainfo = self.chain.recognize_media(meta=meta_info) + if not mediainfo: + logger.warn(f"未识别到媒体信息:{file}") + return + + # 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title + if not settings.SCRAP_FOLLOW_TMDB: + transfer_history = self.transferhis.get_by_type_tmdbid(tmdbid=mediainfo.tmdb_id, + mtype=mediainfo.type.value) + if transfer_history: + mediainfo.title = transfer_history.title + # 获取图片 + self.chain.obtain_images(mediainfo) + # 刮削 + self.chain.scrape_metadata(path=file, + mediainfo=mediainfo, + transfer_type=settings.TRANSFER_TYPE, + force_nfo=force_nfo, + force_img=force_img) + + @staticmethod + def __get_tmdbid_from_nfo(file_path: Path): + """ + 从nfo文件中获取信息 + :param file_path: + :return: tmdbid + """ + if not file_path: + return None + xpaths = [ + "uniqueid[@type='Tmdb']", + "uniqueid[@type='tmdb']", + "uniqueid[@type='TMDB']", + "tmdbid" + ] + try: + reader = NfoReader(file_path) + for xpath in xpaths: + tmdbid = reader.get_element_value(xpath) + if tmdbid: + return tmdbid + except Exception as err: + logger.warn(f"从nfo文件中获取tmdbid失败:{str(err)}") + return None + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._event.set() + self._scheduler.shutdown() + self._event.clear() + self._scheduler = None + except Exception as e: + print(str(e)) diff --git a/plugins.v2/mediaservermsg/__init__.py b/plugins.v2/mediaservermsg/__init__.py new file mode 100644 index 0000000..315f1d1 --- /dev/null +++ b/plugins.v2/mediaservermsg/__init__.py @@ -0,0 +1,295 @@ +import time +from typing import Any, List, Dict, Tuple + +from app.core.event import eventmanager, Event +from app.log import logger +from app.modules.emby import Emby +from app.modules.jellyfin import Jellyfin +from app.modules.plex import Plex +from app.plugins import _PluginBase +from app.schemas import WebhookEventInfo +from app.schemas.types import EventType, MediaType, MediaImageType, NotificationType +from app.utils.web import WebUtils + + +class MediaServerMsg(_PluginBase): + # 插件名称 + plugin_name = "媒体库服务器通知" + # 插件描述 + plugin_desc = "发送Emby/Jellyfin/Plex服务器的播放、入库等通知消息。" + # 插件图标 + plugin_icon = "mediaplay.png" + # 插件版本 + plugin_version = "1.3" + # 插件作者 + plugin_author = "jxxghp" + # 作者主页 + author_url = "https://github.com/jxxghp" + # 插件配置项ID前缀 + plugin_config_prefix = "mediaservermsg_" + # 加载顺序 + plugin_order = 14 + # 可使用的用户级别 + auth_level = 1 + + # 对像 + plex = None + emby = None + jellyfin = None + + # 私有属性 + _enabled = False + _types = [] + _webhook_msg_keys = {} + + # 拼装消息内容 + _webhook_actions = { + "library.new": "新入库", + "system.webhooktest": "测试", + "playback.start": "开始播放", + "playback.stop": "停止播放", + "user.authenticated": "登录成功", + "user.authenticationfailed": "登录失败", + "media.play": "开始播放", + "media.stop": "停止播放", + "PlaybackStart": "开始播放", + "PlaybackStop": "停止播放", + "item.rate": "标记了" + } + _webhook_images = { + "emby": "https://emby.media/notificationicon.png", + "plex": "https://www.plex.tv/wp-content/uploads/2022/04/new-logo-process-lines-gray.png", + "jellyfin": "https://play-lh.googleusercontent.com/SCsUK3hCCRqkJbmLDctNYCfehLxsS4ggD1ZPHIFrrAN1Tn9yhjmGMPep2D9lMaaa9eQi" + } + + def init_plugin(self, config: dict = None): + if config: + self._enabled = config.get("enabled") + self._types = config.get("types") or [] + if self._enabled: + self.emby = Emby() + self.plex = Plex() + self.jellyfin = Jellyfin() + + def get_state(self) -> bool: + return self._enabled + + @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、数据结构 + """ + types_options = [ + {"title": "新入库", "value": "library.new"}, + {"title": "开始播放", "value": "playback.start|media.play|PlaybackStart"}, + {"title": "停止播放", "value": "playback.stop|media.stop|PlaybackStop"}, + {"title": "用户标记", "value": "item.rate"}, + {"title": "测试", "value": "system.webhooktest"}, + {"title": "登录成功", "value": "user.authenticated"}, + {"title": "登录失败", "value": "user.authenticationfailed"}, + ] + 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': 'VSelect', + 'props': { + 'chips': True, + 'multiple': True, + 'model': 'types', + 'label': '消息类型', + 'items': types_options + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '需要设置媒体服务器Webhook,回调相对路径为 /api/v1/webhook?token=moviepilot(3001端口),其中 moviepilot 为设置的 API_TOKEN。' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "types": [] + } + + def get_page(self) -> List[dict]: + pass + + @eventmanager.register(EventType.WebhookMessage) + def send(self, event: Event): + """ + 发送通知消息 + """ + if not self._enabled: + return + + event_info: WebhookEventInfo = event.event_data + if not event_info: + return + + # 不在支持范围不处理 + if not self._webhook_actions.get(event_info.event): + return + + # 不在选中范围不处理 + msgflag = False + for _type in self._types: + if event_info.event in _type.split("|"): + msgflag = True + break + if not msgflag: + logger.info(f"未开启 {event_info.event} 类型的消息通知") + return + + expiring_key = f"{event_info.item_id}-{event_info.client}-{event_info.user_name}" + # 过滤停止播放重复消息 + if str(event_info.event) == "playback.stop" and expiring_key in self._webhook_msg_keys.keys(): + # 刷新过期时间 + self.__add_element(expiring_key) + return + + # 消息标题 + if event_info.item_type in ["TV", "SHOW"]: + message_title = f"{self._webhook_actions.get(event_info.event)}剧集 {event_info.item_name}" + elif event_info.item_type == "MOV": + message_title = f"{self._webhook_actions.get(event_info.event)}电影 {event_info.item_name}" + elif event_info.item_type == "AUD": + message_title = f"{self._webhook_actions.get(event_info.event)}有声书 {event_info.item_name}" + else: + message_title = f"{self._webhook_actions.get(event_info.event)}" + + # 消息内容 + message_texts = [] + if event_info.user_name: + message_texts.append(f"用户:{event_info.user_name}") + if event_info.device_name: + message_texts.append(f"设备:{event_info.client} {event_info.device_name}") + if event_info.ip: + message_texts.append(f"IP地址:{event_info.ip} {WebUtils.get_location(event_info.ip)}") + if event_info.percentage: + percentage = round(float(event_info.percentage), 2) + message_texts.append(f"进度:{percentage}%") + if event_info.overview: + message_texts.append(f"剧情:{event_info.overview}") + message_texts.append(f"时间:{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}") + + # 消息内容 + message_content = "\n".join(message_texts) + + # 消息图片 + image_url = event_info.image_url + # 查询剧集图片 + if (event_info.tmdb_id + and event_info.season_id + and event_info.episode_id): + specific_image = self.chain.obtain_specific_image( + mediaid=event_info.tmdb_id, + mtype=MediaType.TV, + image_type=MediaImageType.Backdrop, + season=event_info.season_id, + episode=event_info.episode_id + ) + if specific_image: + image_url = specific_image + # 使用默认图片 + if not image_url: + image_url = self._webhook_images.get(event_info.channel) + + # 获取链接地址 + if event_info.channel == "emby": + play_link = self.emby.get_play_url(event_info.item_id) + elif event_info.channel == "plex": + play_link = self.plex.get_play_url(event_info.item_id) + elif event_info.channel == "jellyfin": + play_link = self.jellyfin.get_play_url(event_info.item_id) + else: + play_link = None + + if str(event_info.event) == "playback.stop": + # 停止播放消息,添加到过期字典 + self.__add_element(expiring_key) + if str(event_info.event) == "playback.start": + # 开始播放消息,删除过期字典 + self.__remove_element(expiring_key) + + # 发送消息 + self.post_message(mtype=NotificationType.MediaServer, + title=message_title, text=message_content, image=image_url, link=play_link) + + def __add_element(self, key, duration=600): + expiration_time = time.time() + duration + # 如果元素已经存在,更新其过期时间 + self._webhook_msg_keys[key] = expiration_time + + def __remove_element(self, key): + self._webhook_msg_keys = {k: v for k, v in self._webhook_msg_keys.items() if k != key} + + def __get_elements(self): + current_time = time.time() + # 过滤掉过期的元素 + self._webhook_msg_keys = {k: v for k, v in self._webhook_msg_keys.items() if v > current_time} + return list(self._webhook_msg_keys.keys()) + + def stop_service(self): + """ + 退出插件 + """ + pass diff --git a/plugins.v2/mediaserverrefresh/__init__.py b/plugins.v2/mediaserverrefresh/__init__.py new file mode 100644 index 0000000..cc6578b --- /dev/null +++ b/plugins.v2/mediaserverrefresh/__init__.py @@ -0,0 +1,170 @@ +import time +from typing import Any, List, Dict, Tuple + +from app.core.config import settings +from app.core.context import MediaInfo +from app.core.event import eventmanager, Event +from app.modules.emby import Emby +from app.modules.jellyfin import Jellyfin +from app.modules.plex import Plex +from app.plugins import _PluginBase +from app.schemas import TransferInfo, RefreshMediaItem +from app.schemas.types import EventType +from app.log import logger + + +class MediaServerRefresh(_PluginBase): + # 插件名称 + plugin_name = "媒体库服务器刷新" + # 插件描述 + plugin_desc = "入库后自动刷新Emby/Jellyfin/Plex服务器海报墙。" + # 插件图标 + plugin_icon = "refresh2.png" + # 插件版本 + plugin_version = "1.2" + # 插件作者 + plugin_author = "jxxghp" + # 作者主页 + author_url = "https://github.com/jxxghp" + # 插件配置项ID前缀 + plugin_config_prefix = "mediaserverrefresh_" + # 加载顺序 + plugin_order = 14 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled = False + _delay = 0 + _emby = None + _jellyfin = None + _plex = None + + def init_plugin(self, config: dict = None): + self._emby = Emby() + self._jellyfin = Jellyfin() + self._plex = Plex() + if config: + self._enabled = config.get("enabled") + self._delay = config.get("delay") or 0 + + def get_state(self) -> bool: + return self._enabled + + @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、数据结构 + """ + 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': 'delay', + 'label': '延迟时间(秒)', + 'placeholder': '0' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "delay": 0 + } + + def get_page(self) -> List[dict]: + pass + + @eventmanager.register(EventType.TransferComplete) + def refresh(self, event: Event): + """ + 发送通知消息 + """ + if not self._enabled: + return + + event_info: dict = event.event_data + if not event_info: + return + + # 刷新媒体库 + if not settings.MEDIASERVER: + return + + if self._delay: + logger.info(f"延迟 {self._delay} 秒后刷新媒体库... ") + time.sleep(float(self._delay)) + + # 入库数据 + transferinfo: TransferInfo = event_info.get("transferinfo") + mediainfo: MediaInfo = event_info.get("mediainfo") + items = [ + RefreshMediaItem( + title=mediainfo.title, + year=mediainfo.year, + type=mediainfo.type, + category=mediainfo.category, + target_path=transferinfo.target_path + ) + ] + # Emby + if "emby" in settings.MEDIASERVER: + self._emby.refresh_library_by_items(items) + + # Jeyllyfin + if "jellyfin" in settings.MEDIASERVER: + # FIXME Jellyfin未找到刷新单个项目的API + self._jellyfin.refresh_root_library() + + # Plex + if "plex" in settings.MEDIASERVER: + self._plex.refresh_library_by_items(items) + + def stop_service(self): + """ + 退出插件 + """ + pass diff --git a/plugins.v2/mediasyncdel/__init__.py b/plugins.v2/mediasyncdel/__init__.py new file mode 100644 index 0000000..41cb858 --- /dev/null +++ b/plugins.v2/mediasyncdel/__init__.py @@ -0,0 +1,1589 @@ +import datetime +import json +import os +import re +import time +from pathlib import Path +from typing import List, Tuple, Dict, Any, Optional + +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from app import schemas +from app.chain.transfer import TransferChain +from app.core.config import settings +from app.core.event import eventmanager, Event +from app.db.models.transferhistory import TransferHistory +from app.log import logger +from app.modules.emby import Emby +from app.modules.jellyfin import Jellyfin +from app.plugins import _PluginBase +from app.schemas.types import NotificationType, EventType, MediaType, MediaImageType + + +class MediaSyncDel(_PluginBase): + # 插件名称 + plugin_name = "媒体文件同步删除" + # 插件描述 + plugin_desc = "同步删除历史记录、源文件和下载任务。" + # 插件图标 + plugin_icon = "mediasyncdel.png" + # 插件版本 + plugin_version = "1.7" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "mediasyncdel_" + # 加载顺序 + plugin_order = 9 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _scheduler: Optional[BackgroundScheduler] = None + _enabled = False + _sync_type: str = "" + _cron: str = "" + _notify = False + _del_source = False + _del_history = False + _exclude_path = None + _library_path = None + _transferchain = None + _transferhis = None + _downloadhis = None + + def init_plugin(self, config: dict = None): + self._transferchain = TransferChain() + self._transferhis = self._transferchain.transferhis + self._downloadhis = self._transferchain.downloadhis + + # 停止现有任务 + self.stop_service() + + # 读取配置 + if config: + self._enabled = config.get("enabled") + self._sync_type = config.get("sync_type") + self._cron = config.get("cron") + self._notify = config.get("notify") + self._del_source = config.get("del_source") + self._del_history = config.get("del_history") + self._exclude_path = config.get("exclude_path") + self._library_path = config.get("library_path") + + # 清理插件历史 + if self._del_history: + self.del_data(key="history") + self.update_config({ + "enabled": self._enabled, + "sync_type": self._sync_type, + "cron": self._cron, + "notify": self._notify, + "del_source": self._del_source, + "del_history": False, + "exclude_path": self._exclude_path, + "library_path": self._library_path + }) + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + """ + 定义远程控制命令 + :return: 命令关键字、事件、描述、附带数据 + """ + pass + + def get_api(self) -> List[Dict[str, Any]]: + return [ + { + "path": "/delete_history", + "endpoint": self.delete_history, + "methods": ["GET"], + "summary": "删除订阅历史记录" + } + ] + + def delete_history(self, key: str, apikey: str): + """ + 删除历史记录 + """ + if apikey != settings.API_TOKEN: + return schemas.Response(success=False, message="API密钥错误") + # 历史记录 + historys = self.get_data('history') + if not historys: + return schemas.Response(success=False, message="未找到历史记录") + # 删除指定记录 + historys = [h for h in historys if h.get("unique") != key] + self.save_data('history', historys) + return schemas.Response(success=True, message="删除成功") + + 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 and str(self._sync_type) == "log": + # 媒体库同步删除日志方式 + if self._cron: + return [{ + "id": "MediaSyncDel", + "name": "媒体库同步删除服务", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self.sync_del_by_log, + "kwargs": {} + }] + else: + return [{ + "id": "MediaSyncDel", + "name": "媒体库同步删除服务", + "trigger": "interval", + "func": self.sync_del_by_log, + "kwargs": {"minutes": 30} + }] + return [] + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '发送通知', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'del_source', + 'label': '删除源文件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'del_history', + 'label': '删除历史', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'sync_type', + 'label': '媒体库同步方式', + 'items': [ + {'title': 'Webhook', 'value': 'webhook'}, + {'title': '日志', 'value': 'log'}, + {'title': 'Scripter X', 'value': 'plugin'} + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '日志检查周期', + 'placeholder': '5位cron表达式,留空自动' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'exclude_path', + 'label': '排除路径' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'library_path', + 'rows': '2', + 'label': '媒体库路径映射', + 'placeholder': '媒体服务器路径:MoviePilot路径(一行一个)' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '媒体库同步方式分为Webhook、日志同步和Scripter X:' + '1、Webhook需要Emby4.8.0.45及以上开启媒体删除的Webhook。' + '2、日志同步需要配置检查周期,默认30分钟执行一次。' + '3、Scripter X方式需要emby安装并配置Scripter X插件,无需配置执行周期。' + '4、启用该插件后,非媒体服务器触发的源文件删除,也会同步处理下载器中的下载任务。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '关于路径映射(转移后文件路径):' + 'emby:/data/A.mp4,' + 'moviepilot:/mnt/link/A.mp4。' + '路径映射填/data:/mnt/link。' + '不正确配置会导致查询不到转移记录!(路径一样可不填)' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '排除路径:命中排除路径后请求云盘删除插件删除云盘资源。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': 'Scripter X配置文档:' + 'https://github.com/thsrite/' + 'MediaSyncDel/blob/main/MoviePilot/MoviePilot.md' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '路径映射配置文档:' + 'https://github.com/thsrite/MediaSyncDel/blob/main/path.md' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "notify": True, + "del_source": False, + "del_history": False, + "library_path": "", + "sync_type": "webhook", + "cron": "*/30 * * * *", + "exclude_path": "", + } + + def get_page(self) -> List[dict]: + """ + 拼装插件详情页面,需要返回页面配置,同时附带数据 + """ + # 查询同步详情 + historys = self.get_data('history') + if not historys: + return [ + { + 'component': 'div', + 'text': '暂无数据', + 'props': { + 'class': 'text-center', + } + } + ] + # 数据按时间降序排序 + historys = sorted(historys, key=lambda x: x.get('del_time'), reverse=True) + # 拼装页面 + contents = [] + for history in historys: + htype = history.get("type") + title = history.get("title") + unique = history.get("unique") + year = history.get("year") + season = history.get("season") + episode = history.get("episode") + image = history.get("image") + del_time = history.get("del_time") + + if season: + sub_contents = [ + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'类型:{htype}' + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'标题:{title}' + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'年份:{year}' + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'季:{season}' + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'集:{episode}' + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'时间:{del_time}' + } + ] + else: + sub_contents = [ + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'类型:{htype}' + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'标题:{title}' + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'年份:{year}' + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'时间:{del_time}' + } + ] + + contents.append( + { + 'component': 'VCard', + 'content': [ + { + "component": "VDialogCloseBtn", + "props": { + 'innerClass': 'absolute top-0 right-0', + }, + 'events': { + 'click': { + 'api': 'plugin/MediaSyncDel/delete_history', + 'method': 'get', + 'params': { + 'key': unique, + 'apikey': settings.API_TOKEN + } + } + }, + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex justify-space-start flex-nowrap flex-row', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'VImg', + 'props': { + 'src': image, + 'height': 120, + 'width': 80, + 'aspect-ratio': '2/3', + 'class': 'object-cover shadow ring-gray-500', + 'cover': True + } + } + ] + }, + { + 'component': 'div', + 'content': sub_contents + } + ] + } + ] + } + ) + + return [ + { + 'component': 'div', + 'props': { + 'class': 'grid gap-3 grid-info-card', + }, + 'content': contents + } + ] + + @eventmanager.register(EventType.WebhookMessage) + def sync_del_by_webhook(self, event: Event): + """ + emby删除媒体库同步删除历史记录 + webhook + """ + if not self._enabled or str(self._sync_type) != "webhook": + return + + event_data = event.event_data + event_type = event_data.event + + # Emby Webhook event_type = library.deleted + if not event_type or str(event_type) != 'library.deleted': + return + + # 媒体类型 + media_type = event_data.media_type + # 媒体名称 + media_name = event_data.item_name + # 媒体路径 + media_path = event_data.item_path + # tmdb_id + tmdb_id = event_data.tmdb_id + # 季数 + season_num = event_data.season_id + # 集数 + episode_num = event_data.episode_id + + """ + 执行删除逻辑 + """ + if self._exclude_path and media_path and any( + os.path.abspath(media_path).startswith(os.path.abspath(path)) for path in + self._exclude_path.split(",")): + logger.info(f"媒体路径 {media_path} 已被排除,暂不处理") + # 发送消息通知网盘删除插件删除网盘资源 + self.eventmanager.send_event(EventType.PluginAction, + { + "action": "networkdisk_del", + "media_path": media_path, + "media_name": media_name, + "tmdb_id": tmdb_id, + "media_type": media_type, + "season_num": season_num, + "episode_num": episode_num, + }) + return + + # 兼容emby webhook season删除没有发送tmdbid + if not tmdb_id and str(media_type) != 'Season': + logger.error(f"{media_name} 同步删除失败,未获取到TMDB ID,请检查媒体库媒体是否刮削") + return + + self.__sync_del(media_type=media_type, + media_name=media_name, + media_path=media_path, + tmdb_id=tmdb_id, + season_num=season_num, + episode_num=episode_num) + + @eventmanager.register(EventType.WebhookMessage) + def sync_del_by_plugin(self, event): + """ + emby删除媒体库同步删除历史记录 + Scripter X插件 + """ + if not self._enabled or str(self._sync_type) != "plugin": + return + + event_data = event.event_data + event_type = event_data.event + + # Scripter X插件 event_type = media_del + if not event_type or str(event_type) != 'media_del': + return + + # Scripter X插件 需要是否虚拟标识 + item_isvirtual = event_data.item_isvirtual + if not item_isvirtual: + logger.error("Scripter X插件方式,item_isvirtual参数未配置,为防止误删除,暂停插件运行") + self.update_config({ + "enabled": False, + "del_source": self._del_source, + "exclude_path": self._exclude_path, + "library_path": self._library_path, + "notify": self._notify, + "cron": self._cron, + "sync_type": self._sync_type, + }) + return + + # 如果是虚拟item,则直接return,不进行删除 + if item_isvirtual == 'True': + return + + # 媒体类型 + media_type = event_data.item_type + # 媒体名称 + media_name = event_data.item_name + # 媒体路径 + media_path = event_data.item_path + # tmdb_id + tmdb_id = event_data.tmdb_id + # 季数 + season_num = event_data.season_id + # 集数 + episode_num = event_data.episode_id + + """ + 执行删除逻辑 + """ + if self._exclude_path and media_path and any( + os.path.abspath(media_path).startswith(os.path.abspath(path)) for path in + self._exclude_path.split(",")): + logger.info(f"媒体路径 {media_path} 已被排除,暂不处理") + # 发送消息通知网盘删除插件删除网盘资源 + self.eventmanager.send_event(EventType.PluginAction, + { + "action": "networkdisk_del", + "media_path": media_path, + "media_name": media_name, + "tmdb_id": tmdb_id, + "media_type": media_type, + "season_num": season_num, + "episode_num": episode_num, + }) + return + + if not tmdb_id or not str(tmdb_id).isdigit(): + logger.error(f"{media_name} 同步删除失败,未获取到TMDB ID,请检查媒体库媒体是否刮削") + return + + self.__sync_del(media_type=media_type, + media_name=media_name, + media_path=media_path, + tmdb_id=tmdb_id, + season_num=season_num, + episode_num=episode_num) + + def __sync_del(self, media_type: str, media_name: str, media_path: str, + tmdb_id: int, season_num: str, episode_num: str): + if not media_type: + logger.error(f"{media_name} 同步删除失败,未获取到媒体类型,请检查媒体是否刮削") + return + + # 处理路径映射 (处理同一媒体多分辨率的情况) + if self._library_path: + paths = self._library_path.split("\n") + for path in paths: + sub_paths = path.split(":") + if len(sub_paths) < 2: + continue + media_path = media_path.replace(sub_paths[0], sub_paths[1]).replace('\\', '/') + + # 兼容重新整理的场景 + if Path(media_path).exists(): + logger.warn(f"转移路径 {media_path} 未被删除或重新生成,跳过处理") + return + + # 查询转移记录 + msg, transfer_history = self.__get_transfer_his(media_type=media_type, + media_name=media_name, + media_path=media_path, + tmdb_id=tmdb_id, + season_num=season_num, + episode_num=episode_num) + + logger.info(f"正在同步删除{msg}") + + if not transfer_history: + logger.warn( + f"{media_type} {media_name} 未获取到可删除数据,请检查路径映射是否配置错误,请检查tmdbid获取是否正确") + return + + # 开始删除 + year = None + del_torrent_hashs = [] + stop_torrent_hashs = [] + error_cnt = 0 + image = 'https://emby.media/notificationicon.png' + for transferhis in transfer_history: + title = transferhis.title + if title not in media_name: + logger.warn( + f"当前转移记录 {transferhis.id} {title} {transferhis.tmdbid} 与删除媒体{media_name}不符,防误删,暂不自动删除") + continue + image = transferhis.image or image + year = transferhis.year + + # 0、删除转移记录 + self._transferhis.delete(transferhis.id) + + # 删除种子任务 + if self._del_source: + # 1、直接删除源文件 + if transferhis.src and Path(transferhis.src).suffix in settings.RMT_MEDIAEXT: + self._transferchain.delete_files(Path(transferhis.src)) + if transferhis.download_hash: + try: + # 2、判断种子是否被删除完 + delete_flag, success_flag, handle_torrent_hashs = self.handle_torrent( + type=transferhis.type, + src=transferhis.src, + torrent_hash=transferhis.download_hash) + if not success_flag: + error_cnt += 1 + else: + if delete_flag: + del_torrent_hashs += handle_torrent_hashs + else: + stop_torrent_hashs += handle_torrent_hashs + except Exception as e: + logger.error("删除种子失败:%s" % str(e)) + + logger.info(f"同步删除 {msg} 完成!") + + media_type = MediaType.MOVIE if media_type in ["Movie", "MOV"] else MediaType.TV + + # 发送消息 + if self._notify: + backrop_image = self.chain.obtain_specific_image( + mediaid=tmdb_id, + mtype=media_type, + image_type=MediaImageType.Backdrop, + season=season_num, + episode=episode_num + ) or image + + torrent_cnt_msg = "" + if del_torrent_hashs: + torrent_cnt_msg += f"删除种子{len(set(del_torrent_hashs))}个\n" + if stop_torrent_hashs: + stop_cnt = 0 + # 排除已删除 + for stop_hash in set(stop_torrent_hashs): + if stop_hash not in set(del_torrent_hashs): + stop_cnt += 1 + if stop_cnt > 0: + torrent_cnt_msg += f"暂停种子{stop_cnt}个\n" + if error_cnt: + torrent_cnt_msg += f"删种失败{error_cnt}个\n" + # 发送通知 + self.post_message( + mtype=NotificationType.MediaServer, + title="媒体库同步删除任务完成", + image=backrop_image, + text=f"{msg}\n" + f"删除记录{len(transfer_history)}个\n" + f"{torrent_cnt_msg}" + f"时间 {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}" + ) + + # 读取历史记录 + history = self.get_data('history') or [] + + # 获取poster + poster_image = self.chain.obtain_specific_image( + mediaid=tmdb_id, + mtype=media_type, + image_type=MediaImageType.Poster, + ) or image + history.append({ + "type": media_type.value, + "title": media_name, + "year": year, + "path": media_path, + "season": season_num if season_num and str(season_num).isdigit() else None, + "episode": episode_num if episode_num and str(episode_num).isdigit() else None, + "image": poster_image, + "del_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())), + "unique": f"{media_name}:{tmdb_id}:{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}" + }) + + # 保存历史 + self.save_data("history", history) + + def __get_transfer_his(self, media_type: str, media_name: str, media_path: str, + tmdb_id: int, season_num: str, episode_num: str): + """ + 查询转移记录 + """ + # 季数 + if season_num and str(season_num).isdigit(): + season_num = str(season_num).rjust(2, '0') + else: + season_num = None + # 集数 + if episode_num and str(episode_num).isdigit(): + episode_num = str(episode_num).rjust(2, '0') + else: + episode_num = None + + # 类型 + mtype = MediaType.MOVIE if media_type in ["Movie", "MOV"] else MediaType.TV + + # 删除电影 + if mtype == MediaType.MOVIE: + msg = f'电影 {media_name} {tmdb_id}' + transfer_history: List[TransferHistory] = self._transferhis.get_by(tmdbid=tmdb_id, + mtype=mtype.value, + dest=media_path) + # 删除电视剧 + elif mtype == MediaType.TV and not season_num and not episode_num: + msg = f'剧集 {media_name} {tmdb_id}' + transfer_history: List[TransferHistory] = self._transferhis.get_by(tmdbid=tmdb_id, + mtype=mtype.value) + # 删除季 S02 + elif mtype == MediaType.TV and season_num and not episode_num: + if not season_num or not str(season_num).isdigit(): + logger.error(f"{media_name} 季同步删除失败,未获取到具体季") + return + msg = f'剧集 {media_name} S{season_num} {tmdb_id}' + if tmdb_id and str(tmdb_id).isdigit(): + # 根据tmdb_id查询转移记录 + transfer_history: List[TransferHistory] = self._transferhis.get_by(tmdbid=tmdb_id, + mtype=mtype.value, + season=f'S{season_num}') + else: + # 兼容emby webhook不发送tmdb场景 + transfer_history: List[TransferHistory] = self._transferhis.get_by(mtype=mtype.value, + season=f'S{season_num}', + dest=media_path) + # 删除剧集S02E02 + elif mtype == MediaType.TV and season_num and episode_num: + if not season_num or not str(season_num).isdigit() or not episode_num or not str(episode_num).isdigit(): + logger.error(f"{media_name} 集同步删除失败,未获取到具体集") + return + msg = f'剧集 {media_name} S{season_num}E{episode_num} {tmdb_id}' + transfer_history: List[TransferHistory] = self._transferhis.get_by(tmdbid=tmdb_id, + mtype=mtype.value, + season=f'S{season_num}', + episode=f'E{episode_num}', + dest=media_path) + else: + return "", [] + + return msg, transfer_history + + def sync_del_by_log(self): + """ + emby删除媒体库同步删除历史记录 + 日志方式 + """ + # 读取历史记录 + history = self.get_data('history') or [] + last_time = self.get_data("last_time") or None + del_medias = [] + + # 媒体服务器类型,多个以,分隔 + if not settings.MEDIASERVER: + return + media_servers = settings.MEDIASERVER.split(',') + for media_server in media_servers: + if media_server == 'emby': + del_medias.extend(self.parse_emby_log(last_time)) + elif media_server == 'jellyfin': + del_medias.extend(self.parse_jellyfin_log(last_time)) + elif media_server == 'plex': + # TODO plex解析日志 + return + + if not del_medias: + logger.error("未解析到已删除媒体信息") + return + + # 遍历删除 + last_del_time = None + for del_media in del_medias: + # 删除时间 + del_time = del_media.get("time") + last_del_time = del_time or datetime.datetime.now() + # 媒体类型 Movie|Series|Season|Episode + media_type = del_media.get("type") + # 媒体名称 蜀山战纪 + media_name = del_media.get("name") + # 媒体年份 2015 + media_year = del_media.get("year") + # 媒体路径 /data/series/国产剧/蜀山战纪 (2015)/Season 2/蜀山战纪 - S02E01 - 第1集.mp4 + media_path = del_media.get("path") + # 季数 S02 + media_season = del_media.get("season") + # 集数 E02 + media_episode = del_media.get("episode") + + # 排除路径不处理 + if self._exclude_path and media_path and any( + os.path.abspath(media_path).startswith(os.path.abspath(path)) for path in + self._exclude_path.split(",")): + logger.info(f"媒体路径 {media_path} 已被排除,暂不处理") + self.save_data("last_time", last_del_time) + return + + # 处理路径映射 (处理同一媒体多分辨率的情况) + if self._library_path: + paths = self._library_path.split("\n") + for path in paths: + sub_paths = path.split(":") + if len(sub_paths) < 2: + continue + media_path = media_path.replace(sub_paths[0], sub_paths[1]).replace('\\', '/') + + # 获取删除的记录 + # 删除电影 + if media_type == "Movie": + msg = f'电影 {media_name}' + transfer_history: List[TransferHistory] = self._transferhis.get_by( + title=media_name, + year=media_year, + dest=media_path) + # 删除电视剧 + elif media_type == "Series": + msg = f'剧集 {media_name}' + transfer_history: List[TransferHistory] = self._transferhis.get_by( + title=media_name, + year=media_year) + # 删除季 S02 + elif media_type == "Season": + msg = f'剧集 {media_name} {media_season}' + transfer_history: List[TransferHistory] = self._transferhis.get_by( + title=media_name, + year=media_year, + season=media_season) + # 删除剧集S02E02 + elif media_type == "Episode": + msg = f'剧集 {media_name} {media_season}{media_episode}' + transfer_history: List[TransferHistory] = self._transferhis.get_by( + title=media_name, + year=media_year, + season=media_season, + episode=media_episode, + dest=media_path) + else: + self.save_data("last_time", last_del_time) + continue + + logger.info(f"正在同步删除 {msg}") + + if not transfer_history: + logger.info(f"未获取到 {msg} 转移记录,请检查路径映射是否配置错误,请检查tmdbid获取是否正确") + self.save_data("last_time", last_del_time) + continue + + logger.info(f"获取到删除历史记录数量 {len(transfer_history)}") + + # 开始删除 + image = 'https://emby.media/notificationicon.png' + del_torrent_hashs = [] + stop_torrent_hashs = [] + error_cnt = 0 + for transferhis in transfer_history: + title = transferhis.title + if title not in media_name: + logger.warn( + f"当前转移记录 {transferhis.id} {title} {transferhis.tmdbid} 与删除媒体{media_name}不符,防误删,暂不自动删除") + self.save_data("last_time", last_del_time) + continue + image = transferhis.image or image + # 0、删除转移记录 + self._transferhis.delete(transferhis.id) + + # 删除种子任务 + if self._del_source: + # 1、直接删除源文件 + if transferhis.src and Path(transferhis.src).suffix in settings.RMT_MEDIAEXT: + self._transferchain.delete_files(Path(transferhis.src)) + if transferhis.download_hash: + try: + # 2、判断种子是否被删除完 + delete_flag, success_flag, handle_torrent_hashs = self.handle_torrent( + type=transferhis.type, + src=transferhis.src, + torrent_hash=transferhis.download_hash) + if not success_flag: + error_cnt += 1 + else: + if delete_flag: + del_torrent_hashs += handle_torrent_hashs + else: + stop_torrent_hashs += handle_torrent_hashs + except Exception as e: + logger.error("删除种子失败:%s" % str(e)) + + logger.info(f"同步删除 {msg} 完成!") + + # 发送消息 + if self._notify: + torrent_cnt_msg = "" + if del_torrent_hashs: + torrent_cnt_msg += f"删除种子{len(set(del_torrent_hashs))}个\n" + if stop_torrent_hashs: + stop_cnt = 0 + # 排除已删除 + for stop_hash in set(stop_torrent_hashs): + if stop_hash not in set(del_torrent_hashs): + stop_cnt += 1 + if stop_cnt > 0: + torrent_cnt_msg += f"暂停种子{stop_cnt}个\n" + self.post_message( + mtype=NotificationType.MediaServer, + title="媒体库同步删除任务完成", + text=f"{msg}\n" + f"删除记录{len(transfer_history)}个\n" + f"{torrent_cnt_msg}" + f"时间 {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}", + image=image) + + history.append({ + "type": "电影" if media_type == "Movie" else "电视剧", + "title": media_name, + "year": media_year, + "path": media_path, + "season": media_season, + "episode": media_episode, + "image": image, + "del_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())) + }) + + # 保存历史 + self.save_data("history", history) + + self.save_data("last_time", last_del_time) + + def handle_torrent(self, type: str, src: str, torrent_hash: str): + """ + 判断种子是否局部删除 + 局部删除则暂停种子 + 全部删除则删除种子 + """ + download_id = torrent_hash + download = settings.DEFAULT_DOWNLOADER + history_key = "%s-%s" % (download, torrent_hash) + plugin_id = "TorrentTransfer" + transfer_history = self.get_data(key=history_key, + plugin_id=plugin_id) + logger.info(f"查询到 {history_key} 转种历史 {transfer_history}") + + handle_torrent_hashs = [] + try: + # 删除本次种子记录 + self._downloadhis.delete_file_by_fullpath(fullpath=src) + + # 根据种子hash查询所有下载器文件记录 + download_files = self._downloadhis.get_files_by_hash(download_hash=torrent_hash) + if not download_files: + logger.error( + f"未查询到种子任务 {torrent_hash} 存在文件记录,未执行下载器文件同步或该种子已被删除") + return False, False, 0 + + # 查询未删除数 + no_del_cnt = 0 + for download_file in download_files: + if download_file and download_file.state and int(download_file.state) == 1: + no_del_cnt += 1 + + if no_del_cnt > 0: + logger.info( + f"查询种子任务 {torrent_hash} 存在 {no_del_cnt} 个未删除文件,执行暂停种子操作") + delete_flag = False + else: + logger.info( + f"查询种子任务 {torrent_hash} 文件已全部删除,执行删除种子操作") + delete_flag = True + + # 如果有转种记录,则删除转种后的下载任务 + if transfer_history and isinstance(transfer_history, dict): + download = transfer_history['to_download'] + download_id = transfer_history['to_download_id'] + delete_source = transfer_history['delete_source'] + + # 删除种子 + if delete_flag: + # 删除转种记录 + self.del_data(key=history_key, plugin_id=plugin_id) + + # 转种后未删除源种时,同步删除源种 + if not delete_source: + logger.info(f"{history_key} 转种时未删除源下载任务,开始删除源下载任务…") + + # 删除源种子 + logger.info(f"删除源下载器下载任务:{settings.DEFAULT_DOWNLOADER} - {torrent_hash}") + self.chain.remove_torrents(torrent_hash) + handle_torrent_hashs.append(torrent_hash) + + # 删除转种后任务 + logger.info(f"删除转种后下载任务:{download} - {download_id}") + # 删除转种后下载任务 + self.chain.remove_torrents(hashs=torrent_hash, + downloader=download) + handle_torrent_hashs.append(download_id) + else: + # 暂停种子 + # 转种后未删除源种时,同步暂停源种 + if not delete_source: + logger.info(f"{history_key} 转种时未删除源下载任务,开始暂停源下载任务…") + + # 暂停源种子 + logger.info(f"暂停源下载器下载任务:{settings.DEFAULT_DOWNLOADER} - {torrent_hash}") + self.chain.stop_torrents(torrent_hash) + handle_torrent_hashs.append(torrent_hash) + + logger.info(f"暂停转种后下载任务:{download} - {download_id}") + # 删除转种后下载任务 + self.chain.stop_torrents(hashs=download_id, downloader=download) + handle_torrent_hashs.append(download_id) + else: + # 未转种de情况 + if delete_flag: + # 删除源种子 + logger.info(f"删除源下载器下载任务:{download} - {download_id}") + self.chain.remove_torrents(download_id) + else: + # 暂停源种子 + logger.info(f"暂停源下载器下载任务:{download} - {download_id}") + self.chain.stop_torrents(download_id) + handle_torrent_hashs.append(download_id) + + # 处理辅种 + handle_torrent_hashs = self.__del_seed(download_id=download_id, + delete_flag=delete_flag, + handle_torrent_hashs=handle_torrent_hashs) + # 处理合集 + if str(type) == "电视剧": + handle_torrent_hashs = self.__del_collection(src=src, + delete_flag=delete_flag, + torrent_hash=torrent_hash, + download_files=download_files, + handle_torrent_hashs=handle_torrent_hashs) + return delete_flag, True, handle_torrent_hashs + except Exception as e: + logger.error(f"删种失败: {str(e)}") + return False, False, 0 + + def __del_collection(self, src: str, delete_flag: bool, torrent_hash: str, download_files: list, + handle_torrent_hashs: list): + """ + 处理合集 + """ + try: + src_download_files = self._downloadhis.get_files_by_fullpath(fullpath=src) + if src_download_files: + for download_file in src_download_files: + # src查询记录 判断download_hash是否不一致 + if download_file and download_file.download_hash and str(download_file.download_hash) != str( + torrent_hash): + # 查询新download_hash对应files数量 + hash_download_files = self._downloadhis.get_files_by_hash( + download_hash=download_file.download_hash) + # 新download_hash对应files数量 > 删种download_hash对应files数量 = 合集种子 + if hash_download_files \ + and len(hash_download_files) > len(download_files) \ + and hash_download_files[0].id > download_files[-1].id: + # 查询未删除数 + no_del_cnt = 0 + for hash_download_file in hash_download_files: + if hash_download_file and hash_download_file.state and int( + hash_download_file.state) == 1: + no_del_cnt += 1 + if no_del_cnt > 0: + logger.info(f"合集种子 {download_file.download_hash} 文件未完全删除,执行暂停种子操作") + delete_flag = False + + # 删除合集种子 + if delete_flag: + self.chain.remove_torrents(hashs=download_file.download_hash, + downloader=download_file.downloader) + logger.info(f"删除合集种子 {download_file.downloader} {download_file.download_hash}") + else: + # 暂停合集种子 + self.chain.stop_torrents(hashs=download_file.download_hash, + downloader=download_file.downloader) + logger.info(f"暂停合集种子 {download_file.downloader} {download_file.download_hash}") + # 已处理种子+1 + handle_torrent_hashs.append(download_file.download_hash) + + # 处理合集辅种 + handle_torrent_hashs = self.__del_seed(download_id=download_file.download_hash, + delete_flag=delete_flag, + handle_torrent_hashs=handle_torrent_hashs) + except Exception as e: + logger.error(f"处理 {torrent_hash} 合集失败") + print(str(e)) + + return handle_torrent_hashs + + def __del_seed(self, download_id, delete_flag, handle_torrent_hashs): + """ + 删除辅种 + """ + # 查询是否有辅种记录 + history_key = download_id + plugin_id = "IYUUAutoSeed" + seed_history = self.get_data(key=history_key, + plugin_id=plugin_id) or [] + logger.info(f"查询到 {history_key} 辅种历史 {seed_history}") + + # 有辅种记录则处理辅种 + if seed_history and isinstance(seed_history, list): + for history in seed_history: + downloader = history.get("downloader") + torrents = history.get("torrents") + if not downloader or not torrents: + return + if not isinstance(torrents, list): + torrents = [torrents] + + # 删除辅种历史 + for torrent in torrents: + handle_torrent_hashs.append(torrent) + # 删除辅种 + if delete_flag: + logger.info(f"删除辅种:{downloader} - {torrent}") + self.chain.remove_torrents(hashs=torrent, + downloader=downloader) + # 暂停辅种 + else: + self.chain.stop_torrents(hashs=torrent, download=downloader) + logger.info(f"辅种:{downloader} - {torrent} 暂停") + + # 处理辅种的辅种 + handle_torrent_hashs = self.__del_seed(download_id=torrent, + delete_flag=delete_flag, + handle_torrent_hashs=handle_torrent_hashs) + + # 删除辅种历史 + if delete_flag: + self.del_data(key=history_key, + plugin_id=plugin_id) + return handle_torrent_hashs + + @staticmethod + def parse_emby_log(last_time): + """ + 获取emby日志列表、解析emby日志 + """ + + def __parse_log(file_name: str, del_list: list): + """ + 解析emby日志 + """ + log_url = f"[HOST]System/Logs/{file_name}?api_key=[APIKEY]" + log_res = Emby().get_data(log_url) + if not log_res or log_res.status_code != 200: + logger.error("获取emby日志失败,请检查服务器配置") + return del_list + + # 正则解析删除的媒体信息 + pattern = r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}.\d{3}) Info App: Removing item from database, Type: (\w+), Name: (.*), Path: (.*), Id: (\d+)' + matches = re.findall(pattern, log_res.text) + + # 循环获取媒体信息 + for match in matches: + mtime = match[0] + # 排除已处理的媒体信息 + if last_time and mtime < last_time: + continue + + mtype = match[1] + name = match[2] + path = match[3] + + year = None + year_pattern = r'\(\d+\)' + year_match = re.search(year_pattern, path) + if year_match: + year = year_match.group()[1:-1] + + season = None + episode = None + if mtype == 'Episode' or mtype == 'Season': + name_pattern = r"\/([\u4e00-\u9fa5]+)(?= \()" + season_pattern = r"Season\s*(\d+)" + episode_pattern = r"S\d+E(\d+)" + name_match = re.search(name_pattern, path) + season_match = re.search(season_pattern, path) + episode_match = re.search(episode_pattern, path) + + if name_match: + name = name_match.group(1) + + if season_match: + season = season_match.group(1) + if int(season) < 10: + season = f'S0{season}' + else: + season = f'S{season}' + else: + season = None + + if episode_match: + episode = episode_match.group(1) + episode = f'E{episode}' + else: + episode = None + + media = { + "time": mtime, + "type": mtype, + "name": name, + "year": year, + "path": path, + "season": season, + "episode": episode, + } + logger.debug(f"解析到删除媒体:{json.dumps(media)}") + del_list.append(media) + + return del_list + + log_files = [] + try: + # 获取所有emby日志 + log_list_url = "[HOST]System/Logs/Query?Limit=3&api_key=[APIKEY]" + log_list_res = Emby().get_data(log_list_url) + + if log_list_res and log_list_res.status_code == 200: + log_files_dict = json.loads(log_list_res.text) + for item in log_files_dict.get("Items"): + if str(item.get('Name')).startswith("embyserver"): + log_files.append(str(item.get('Name'))) + except Exception as e: + print(str(e)) + + if not log_files: + log_files.append("embyserver.txt") + + del_medias = [] + log_files.reverse() + for log_file in log_files: + del_medias = __parse_log(file_name=log_file, + del_list=del_medias) + + return del_medias + + @staticmethod + def parse_jellyfin_log(last_time: datetime): + """ + 获取jellyfin日志列表、解析jellyfin日志 + """ + + def __parse_log(file_name: str, del_list: list): + """ + 解析jellyfin日志 + """ + log_url = f"[HOST]System/Logs/Log?name={file_name}&api_key=[APIKEY]" + log_res = Jellyfin().get_data(log_url) + if not log_res or log_res.status_code != 200: + logger.error("获取jellyfin日志失败,请检查服务器配置") + return del_list + + # 正则解析删除的媒体信息 + pattern = r'\[(.*?)\].*?Removing item, Type: "(.*?)", Name: "(.*?)", Path: "(.*?)"' + matches = re.findall(pattern, log_res.text) + + # 循环获取媒体信息 + for match in matches: + mtime = match[0] + # 排除已处理的媒体信息 + if last_time and mtime < last_time: + continue + + mtype = match[1] + name = match[2] + path = match[3] + + year = None + year_pattern = r'\(\d+\)' + year_match = re.search(year_pattern, path) + if year_match: + year = year_match.group()[1:-1] + + season = None + episode = None + if mtype == 'Episode' or mtype == 'Season': + name_pattern = r"\/([\u4e00-\u9fa5]+)(?= \()" + season_pattern = r"Season\s*(\d+)" + episode_pattern = r"S\d+E(\d+)" + name_match = re.search(name_pattern, path) + season_match = re.search(season_pattern, path) + episode_match = re.search(episode_pattern, path) + + if name_match: + name = name_match.group(1) + + if season_match: + season = season_match.group(1) + if int(season) < 10: + season = f'S0{season}' + else: + season = f'S{season}' + else: + season = None + + if episode_match: + episode = episode_match.group(1) + episode = f'E{episode}' + else: + episode = None + + media = { + "time": mtime, + "type": mtype, + "name": name, + "year": year, + "path": path, + "season": season, + "episode": episode, + } + logger.debug(f"解析到删除媒体:{json.dumps(media)}") + del_list.append(media) + + return del_list + + log_files = [] + try: + # 获取所有jellyfin日志 + log_list_url = "[HOST]System/Logs?api_key=[APIKEY]" + log_list_res = Jellyfin().get_data(log_list_url) + + if log_list_res and log_list_res.status_code == 200: + log_files_dict = json.loads(log_list_res.text) + for item in log_files_dict: + if str(item.get('Name')).startswith("log_"): + log_files.append(str(item.get('Name'))) + except Exception as e: + print(str(e)) + + if not log_files: + log_files.append("log_%s.log" % datetime.date.today().strftime("%Y%m%d")) + + del_medias = [] + log_files.reverse() + for log_file in log_files: + del_medias = __parse_log(file_name=log_file, + del_list=del_medias) + + return del_medias + + def get_state(self): + return self._enabled + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) + + @eventmanager.register(EventType.DownloadFileDeleted) + def downloadfile_del_sync(self, event: Event): + """ + 下载文件删除处理事件 + """ + if not event: + return + event_data = event.event_data + src = event_data.get("src") + if not src: + return + # 查询下载hash + download_hash = self._downloadhis.get_hash_by_fullpath(src) + if download_hash: + download_history = self._downloadhis.get_by_hash(download_hash) + self.handle_torrent(type=download_history.type, src=src, torrent_hash=download_hash) + else: + logger.warn(f"未查询到文件 {src} 对应的下载记录") + + @staticmethod + def get_tmdbimage_url(path: str, prefix="w500"): + if not path: + return "" + tmdb_image_url = f"https://{settings.TMDB_IMAGE_DOMAIN}" + return tmdb_image_url + f"/t/p/{prefix}{path}" diff --git a/plugins.v2/messageforward/__init__.py b/plugins.v2/messageforward/__init__.py new file mode 100644 index 0000000..7a6b940 --- /dev/null +++ b/plugins.v2/messageforward/__init__.py @@ -0,0 +1,430 @@ +import json +import re +from datetime import datetime + +from app.core.config import settings +from app.plugins import _PluginBase +from app.core.event import eventmanager +from app.schemas.types import EventType, MessageChannel +from app.utils.http import RequestUtils +from typing import Any, List, Dict, Tuple, Optional +from app.log import logger + + +class MessageForward(_PluginBase): + # 插件名称 + plugin_name = "消息转发" + # 插件描述 + plugin_desc = "根据正则转发通知到其他WeChat应用。" + # 插件图标 + plugin_icon = "forward.png" + # 插件版本 + plugin_version = "1.1" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "messageforward_" + # 加载顺序 + plugin_order = 16 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled = False + _wechat = None + _pattern = None + _pattern_token = {} + + # 企业微信发送消息URL + _send_msg_url = f"{settings.WECHAT_PROXY}/cgi-bin/message/send?access_token=%s" + # 企业微信获取TokenURL + _token_url = f"{settings.WECHAT_PROXY}/cgi-bin/gettoken?corpid=%s&corpsecret=%s" + + def init_plugin(self, config: dict = None): + if config: + self._enabled = config.get("enabled") + self._wechat = config.get("wechat") + self._pattern = config.get("pattern") + + # 获取token存库 + if self._enabled and self._wechat: + self.__save_wechat_token() + + def get_state(self) -> bool: + return self._enabled + + @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、数据结构 + """ + 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': 'VTextarea', + 'props': { + 'model': 'wechat', + 'rows': '5', + 'label': '应用配置', + 'placeholder': 'appid:corpid:appsecret(一行一个配置)' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'pattern', + 'rows': '6', + 'label': '正则配置', + 'placeholder': '对应上方应用配置,一行一个,一一对应' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '根据正则表达式,把MoviePilot的消息转发到多个微信应用。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '应用配置可加注释:' + 'appid:corpid:appsecret#站点通知' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "wechat": "", + "pattern": "" + } + + def get_page(self) -> List[dict]: + pass + + @eventmanager.register(EventType.NoticeMessage) + def send(self, event): + """ + 消息转发 + """ + if not self._enabled: + return + + # 消息体 + data = event.event_data + channel = data['channel'] + if channel and channel != MessageChannel.Wechat: + return + + title = data['title'] + text = data['text'] + image = data['image'] + userid = data['userid'] + + # 正则匹配 + patterns = self._pattern.split("\n") + for index, pattern in enumerate(patterns): + msg_match = re.search(pattern, title) + if msg_match: + access_token, appid = self.__flush_access_token(index) + if not access_token: + logger.error("未获取到有效token,请检查配置") + continue + + # 发送消息 + if image: + self.__send_image_message(title, text, image, userid, access_token, appid, index) + else: + self.__send_message(title, text, userid, access_token, appid, index) + + def __save_wechat_token(self): + """ + 获取并存储wechat token + """ + # 解析配置 + wechats = self._wechat.split("\n") + for index, wechat in enumerate(wechats): + # 排除注释 + wechat = wechat.split("#")[0] + wechat_config = wechat.split(":") + if len(wechat_config) != 3: + logger.error(f"{wechat} 应用配置不正确") + continue + appid = wechat_config[0] + corpid = wechat_config[1] + appsecret = wechat_config[2] + + # 已过期,重新获取token + access_token, expires_in, access_token_time = self.__get_access_token(corpid=corpid, + appsecret=appsecret) + if not access_token: + # 没有token,获取token + logger.error(f"wechat配置 appid = {appid} 获取token失败,请检查配置") + continue + + self._pattern_token[index] = { + "appid": appid, + "corpid": corpid, + "appsecret": appsecret, + "access_token": access_token, + "expires_in": expires_in, + "access_token_time": access_token_time, + } + + def __flush_access_token(self, index: int, force: bool = False): + """ + 获取第i个配置wechat token + """ + wechat_token = self._pattern_token[index] + if not wechat_token: + logger.error(f"未获取到第 {index} 条正则对应的wechat应用token,请检查配置") + return None + access_token = wechat_token['access_token'] + expires_in = wechat_token['expires_in'] + access_token_time = wechat_token['access_token_time'] + appid = wechat_token['appid'] + corpid = wechat_token['corpid'] + appsecret = wechat_token['appsecret'] + + # 判断token有效期 + if force or (datetime.now() - access_token_time).seconds >= expires_in: + # 重新获取token + access_token, expires_in, access_token_time = self.__get_access_token(corpid=corpid, + appsecret=appsecret) + if not access_token: + logger.error(f"wechat配置 appid = {appid} 获取token失败,请检查配置") + return None, None + + self._pattern_token[index] = { + "appid": appid, + "corpid": corpid, + "appsecret": appsecret, + "access_token": access_token, + "expires_in": expires_in, + "access_token_time": access_token_time, + } + return access_token, appid + + def __send_message(self, title: str, text: str = None, userid: str = None, access_token: str = None, + appid: str = None, index: int = None) -> Optional[bool]: + """ + 发送文本消息 + :param title: 消息标题 + :param text: 消息内容 + :param userid: 消息发送对象的ID,为空则发给所有人 + :return: 发送状态,错误信息 + """ + if text: + conent = "%s\n%s" % (title, text.replace("\n\n", "\n")) + else: + conent = title + + if not userid: + userid = "@all" + req_json = { + "touser": userid, + "msgtype": "text", + "agentid": appid, + "text": { + "content": conent + }, + "safe": 0, + "enable_id_trans": 0, + "enable_duplicate_check": 0 + } + return self.__post_request(access_token=access_token, req_json=req_json, index=index, title=title) + + def __send_image_message(self, title: str, text: str, image_url: str, userid: str = None, + access_token: str = None, appid: str = None, index: int = None) -> Optional[bool]: + """ + 发送图文消息 + :param title: 消息标题 + :param text: 消息内容 + :param image_url: 图片地址 + :param userid: 消息发送对象的ID,为空则发给所有人 + :return: 发送状态,错误信息 + """ + if text: + text = text.replace("\n\n", "\n") + if not userid: + userid = "@all" + req_json = { + "touser": userid, + "msgtype": "news", + "agentid": appid, + "news": { + "articles": [ + { + "title": title, + "description": text, + "picurl": image_url, + "url": '' + } + ] + } + } + return self.__post_request(access_token=access_token, req_json=req_json, index=index, title=title) + + def __post_request(self, access_token: str, req_json: dict, index: int, title: str, retry: int = 0) -> bool: + message_url = self._send_msg_url % access_token + """ + 向微信发送请求 + """ + try: + res = RequestUtils(content_type='application/json').post( + message_url, + data=json.dumps(req_json, ensure_ascii=False).encode('utf-8') + ) + if res and res.status_code == 200: + ret_json = res.json() + if ret_json.get('errcode') == 0: + logger.info(f"转发消息 {title} 成功") + return True + else: + if ret_json.get('errcode') == 81013: + return False + + logger.error(f"转发消息 {title} 失败,错误信息:{ret_json}") + if ret_json.get('errcode') == 42001 or ret_json.get('errcode') == 40014: + logger.info("token已过期,正在重新刷新token重试") + # 重新获取token + access_token, appid = self.__flush_access_token(index=index, + force=True) + if access_token: + retry += 1 + # 重发请求 + if retry <= 3: + return self.__post_request(access_token=access_token, + req_json=req_json, + index=index, + title=title, + retry=retry) + return False + elif res is not None: + logger.error(f"转发消息 {title} 失败,错误码:{res.status_code},错误原因:{res.reason}") + return False + else: + logger.error(f"转发消息 {title} 失败,未获取到返回信息") + return False + except Exception as err: + logger.error(f"转发消息 {title} 异常,错误信息:{str(err)}") + return False + + def __get_access_token(self, corpid: str, appsecret: str): + """ + 获取微信Token + :return: 微信Token + """ + try: + token_url = self._token_url % (corpid, appsecret) + res = RequestUtils().get_res(token_url) + if res: + ret_json = res.json() + if ret_json.get('errcode') == 0: + access_token = ret_json.get('access_token') + expires_in = ret_json.get('expires_in') + access_token_time = datetime.now() + + return access_token, expires_in, access_token_time + else: + logger.error(f"{ret_json.get('errmsg')}") + return None, None, None + else: + logger.error(f"{corpid} {appsecret} 获取token失败") + return None, None, None + except Exception as e: + logger.error(f"获取微信access_token失败,错误信息:{str(e)}") + return None, None, None + + def stop_service(self): + """ + 退出插件 + """ + pass diff --git a/plugins.v2/personmeta/__init__.py b/plugins.v2/personmeta/__init__.py new file mode 100644 index 0000000..1c5978c --- /dev/null +++ b/plugins.v2/personmeta/__init__.py @@ -0,0 +1,1026 @@ +import base64 +import copy +import datetime +import json +import re +import threading +import time +from pathlib import Path +from typing import Any, List, Dict, Tuple, Optional + +import pytz +import zhconv +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from requests import RequestException + +from app import schemas +from app.chain.mediaserver import MediaServerChain +from app.chain.tmdb import TmdbChain +from app.core.config import settings +from app.core.event import eventmanager, Event +from app.core.meta import MetaBase +from app.log import logger +from app.modules.emby import Emby +from app.modules.jellyfin import Jellyfin +from app.modules.plex import Plex +from app.plugins import _PluginBase +from app.schemas import MediaInfo, MediaServerItem +from app.schemas.types import EventType, MediaType +from app.utils.common import retry +from app.utils.http import RequestUtils +from app.utils.string import StringUtils + + +class PersonMeta(_PluginBase): + # 插件名称 + plugin_name = "演职人员刮削" + # 插件描述 + plugin_desc = "刮削演职人员图片以及中文名称。" + # 插件图标 + plugin_icon = "actor.png" + # 插件版本 + plugin_version = "1.4" + # 插件作者 + plugin_author = "jxxghp" + # 作者主页 + author_url = "https://github.com/jxxghp" + # 插件配置项ID前缀 + plugin_config_prefix = "personmeta_" + # 加载顺序 + plugin_order = 24 + # 可使用的用户级别 + auth_level = 1 + + # 退出事件 + _event = threading.Event() + + # 私有属性 + _scheduler = None + tmdbchain = None + mschain = None + _enabled = False + _onlyonce = False + _cron = None + _delay = 0 + _type = "all" + _remove_nozh = False + + def init_plugin(self, config: dict = None): + self.tmdbchain = TmdbChain() + self.mschain = MediaServerChain() + if config: + self._enabled = config.get("enabled") + self._onlyonce = config.get("onlyonce") + self._cron = config.get("cron") + self._type = config.get("type") or "all" + self._delay = config.get("delay") or 0 + self._remove_nozh = config.get("remove_nozh") or False + + # 停止现有任务 + self.stop_service() + + # 启动服务 + if self._onlyonce: + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + self._scheduler.add_job(func=self.scrap_library, trigger='date', + run_date=datetime.datetime.now( + tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3) + ) + logger.info(f"演职人员刮削服务启动,立即运行一次") + # 关闭一次性开关 + self._onlyonce = False + # 保存配置 + self.__update_config() + # 启动服务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def __update_config(self): + """ + 更新配置 + """ + self.update_config({ + "enabled": self._enabled, + "onlyonce": self._onlyonce, + "cron": self._cron, + "type": self._type, + "delay": self._delay, + "remove_nozh": self._remove_nozh + }) + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + 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 and self._cron: + return [{ + "id": "PersonMeta", + "name": "演职人员刮削服务", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self.scrap_library, + "kwargs": {} + }] + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '媒体库扫描周期', + 'placeholder': '5位cron表达式' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'delay', + 'label': '入库延迟时间(秒)', + 'placeholder': '30' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'type', + 'label': '刮削条件', + 'items': [ + {'title': '全部', 'value': 'all'}, + {'title': '演员非中文', 'value': 'name'}, + {'title': '角色非中文', 'value': 'role'}, + ] + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'remove_nozh', + 'label': '删除非中文演员', + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "onlyonce": False, + "cron": "", + "type": "all", + "delay": 30, + "remove_nozh": False + } + + def get_page(self) -> List[dict]: + pass + + @eventmanager.register(EventType.TransferComplete) + def scrap_rt(self, event: Event): + """ + 根据事件实时刮削演员信息 + """ + if not self._enabled: + return + # 事件数据 + mediainfo: MediaInfo = event.event_data.get("mediainfo") + meta: MetaBase = event.event_data.get("meta") + if not mediainfo or not meta: + return + # 延迟 + if self._delay: + time.sleep(int(self._delay)) + # 查询媒体服务器中的条目 + existsinfo = self.chain.media_exists(mediainfo=mediainfo) + if not existsinfo or not existsinfo.itemid: + logger.warn(f"演职人员刮削 {mediainfo.title_year} 在媒体库中不存在") + return + # 查询条目详情 + iteminfo = self.mschain.iteminfo(server=existsinfo.server, item_id=existsinfo.itemid) + if not iteminfo: + logger.warn(f"演职人员刮削 {mediainfo.title_year} 条目详情获取失败") + return + # 刮削演职人员信息 + self.__update_item(server=existsinfo.server, item=iteminfo, + mediainfo=mediainfo, season=meta.begin_season) + + def scrap_library(self): + """ + 扫描整个媒体库,刮削演员信息 + """ + # 所有媒体服务器 + if not settings.MEDIASERVER: + return + for server in settings.MEDIASERVER.split(","): + # 扫描所有媒体库 + logger.info(f"开始刮削服务器 {server} 的演员信息 ...") + for library in self.mschain.librarys(server): + logger.info(f"开始刮削媒体库 {library.name} 的演员信息 ...") + for item in self.mschain.items(server, library.id): + if not item: + continue + if not item.item_id: + continue + if "Series" not in item.item_type \ + and "Movie" not in item.item_type: + continue + if self._event.is_set(): + logger.info(f"演职人员刮削服务停止") + return + # 处理条目 + logger.info(f"开始刮削 {item.title} 的演员信息 ...") + self.__update_item(server=server, item=item) + logger.info(f"{item.title} 的演员信息刮削完成") + logger.info(f"媒体库 {library.name} 的演员信息刮削完成") + logger.info(f"服务器 {server} 的演员信息刮削完成") + + def __update_peoples(self, server: str, itemid: str, iteminfo: dict, douban_actors): + # 处理媒体项中的人物信息 + """ + "People": [ + { + "Name": "丹尼尔·克雷格", + "Id": "33625", + "Role": "James Bond", + "Type": "Actor", + "PrimaryImageTag": "bef4f764540f10577f804201d8d27918" + } + ] + """ + peoples = [] + # 更新当前媒体项人物 + for people in iteminfo["People"] or []: + if self._event.is_set(): + logger.info(f"演职人员刮削服务停止") + return + if not people.get("Name"): + continue + if StringUtils.is_chinese(people.get("Name")) \ + and StringUtils.is_chinese(people.get("Role")): + peoples.append(people) + continue + info = self.__update_people(server=server, people=people, + douban_actors=douban_actors) + if info: + peoples.append(info) + elif not self._remove_nozh: + peoples.append(people) + # 保存媒体项信息 + if peoples: + iteminfo["People"] = peoples + self.set_iteminfo(server=server, itemid=itemid, iteminfo=iteminfo) + + def __update_item(self, server: str, item: MediaServerItem, + mediainfo: MediaInfo = None, season: int = None): + """ + 更新媒体服务器中的条目 + """ + + def __need_trans_actor(_item): + """ + 是否需要处理人物信息 + """ + if self._type == "name": + # 是否需要处理人物名称 + _peoples = [x for x in _item.get("People", []) if + (x.get("Name") and not StringUtils.is_chinese(x.get("Name")))] + elif self._type == "role": + # 是否需要处理人物角色 + _peoples = [x for x in _item.get("People", []) if + (x.get("Role") and not StringUtils.is_chinese(x.get("Role")))] + else: + _peoples = [x for x in _item.get("People", []) if + (x.get("Name") and not StringUtils.is_chinese(x.get("Name"))) + or (x.get("Role") and not StringUtils.is_chinese(x.get("Role")))] + if _peoples: + return True + return False + + # 识别媒体信息 + if not mediainfo: + if not item.tmdbid: + logger.warn(f"{item.title} 未找到tmdbid,无法识别媒体信息") + return + mtype = MediaType.TV if item.item_type in ['Series', 'show'] else MediaType.MOVIE + mediainfo = self.chain.recognize_media(mtype=mtype, tmdbid=item.tmdbid) + if not mediainfo: + logger.warn(f"{item.title} 未识别到媒体信息") + return + + # 获取媒体项 + iteminfo = self.get_iteminfo(server=server, itemid=item.item_id) + if not iteminfo: + logger.warn(f"{item.title} 未找到媒体项") + return + + if __need_trans_actor(iteminfo): + # 获取豆瓣演员信息 + logger.info(f"开始获取 {item.title} 的豆瓣演员信息 ...") + douban_actors = self.__get_douban_actors(mediainfo=mediainfo, season=season) + self.__update_peoples(server=server, itemid=item.item_id, iteminfo=iteminfo, douban_actors=douban_actors) + else: + logger.info(f"{item.title} 的人物信息已是中文,无需更新") + + # 处理季和集人物 + if iteminfo.get("Type") and "Series" in iteminfo["Type"]: + # 获取季媒体项 + seasons = self.get_items(server=server, parentid=item.item_id, mtype="Season") + if not seasons: + logger.warn(f"{item.title} 未找到季媒体项") + return + for season in seasons["Items"]: + # 获取豆瓣演员信息 + season_actors = self.__get_douban_actors(mediainfo=mediainfo, season=season.get("IndexNumber")) + # 如果是Jellyfin,更新季的人物,Emby/Plex季没有人物 + if server == "jellyfin": + seasoninfo = self.get_iteminfo(server=server, itemid=season.get("Id")) + if not seasoninfo: + logger.warn(f"{item.title} 未找到季媒体项:{season.get('Id')}") + continue + + if __need_trans_actor(seasoninfo): + # 更新季媒体项人物 + self.__update_peoples(server=server, itemid=season.get("Id"), iteminfo=seasoninfo, + douban_actors=season_actors) + logger.info(f"季 {seasoninfo.get('Id')} 的人物信息更新完成") + else: + logger.info(f"季 {seasoninfo.get('Id')} 的人物信息已是中文,无需更新") + # 获取集媒体项 + episodes = self.get_items(server=server, parentid=season.get("Id"), mtype="Episode") + if not episodes: + logger.warn(f"{item.title} 未找到集媒体项") + continue + # 更新集媒体项人物 + for episode in episodes["Items"]: + # 获取集媒体项详情 + episodeinfo = self.get_iteminfo(server=server, itemid=episode.get("Id")) + if not episodeinfo: + logger.warn(f"{item.title} 未找到集媒体项:{episode.get('Id')}") + continue + if __need_trans_actor(episodeinfo): + # 更新集媒体项人物 + self.__update_peoples(server=server, itemid=episode.get("Id"), iteminfo=episodeinfo, + douban_actors=season_actors) + logger.info(f"集 {episodeinfo.get('Id')} 的人物信息更新完成") + else: + logger.info(f"集 {episodeinfo.get('Id')} 的人物信息已是中文,无需更新") + + def __update_people(self, server: str, people: dict, douban_actors: list = None) -> Optional[dict]: + """ + 更新人物信息,返回替换后的人物信息 + """ + + def __get_peopleid(p: dict) -> Tuple[Optional[str], Optional[str]]: + """ + 获取人物的TMDBID、IMDBID + """ + if not p.get("ProviderIds"): + return None, None + peopletmdbid, peopleimdbid = None, None + if "Tmdb" in p["ProviderIds"]: + peopletmdbid = p["ProviderIds"]["Tmdb"] + if "tmdb" in p["ProviderIds"]: + peopletmdbid = p["ProviderIds"]["tmdb"] + if "Imdb" in p["ProviderIds"]: + peopleimdbid = p["ProviderIds"]["Imdb"] + if "imdb" in p["ProviderIds"]: + peopleimdbid = p["ProviderIds"]["imdb"] + return peopletmdbid, peopleimdbid + + # 返回的人物信息 + ret_people = copy.deepcopy(people) + + try: + # 查询媒体库人物详情 + personinfo = self.get_iteminfo(server=server, itemid=people.get("Id")) + if not personinfo: + logger.debug(f"未找到人物 {people.get('Name')} 的信息") + return None + + # 是否更新标志 + updated_name = False + updated_overview = False + update_character = False + profile_path = None + + # 从TMDB信息中更新人物信息 + person_tmdbid, person_imdbid = __get_peopleid(personinfo) + if person_tmdbid: + person_detail = self.tmdbchain.person_detail(int(person_tmdbid)) + if person_detail: + cn_name = self.__get_chinese_name(person_detail) + # 图片优先从TMDB获取 + profile_path = person_detail.profile_path + if profile_path: + logger.debug(f"{people.get('Name')} 从TMDB获取到图片:{profile_path}") + profile_path = f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{profile_path}" + if cn_name: + # 更新中文名 + logger.debug(f"{people.get('Name')} 从TMDB获取到中文名:{cn_name}") + personinfo["Name"] = cn_name + ret_people["Name"] = cn_name + updated_name = True + # 更新中文描述 + biography = person_detail.biography + if biography and StringUtils.is_chinese(biography): + logger.debug(f"{people.get('Name')} 从TMDB获取到中文描述") + personinfo["Overview"] = biography + updated_overview = True + + # 从豆瓣信息中更新人物信息 + """ + { + "name": "丹尼尔·克雷格", + "roles": [ + "演员", + "制片人", + "配音" + ], + "title": "丹尼尔·克雷格(同名)英国,英格兰,柴郡,切斯特影视演员", + "url": "https://movie.douban.com/celebrity/1025175/", + "user": null, + "character": "饰 詹姆斯·邦德 James Bond 007", + "uri": "douban://douban.com/celebrity/1025175?subject_id=27230907", + "avatar": { + "large": "https://qnmob3.doubanio.com/view/celebrity/raw/public/p42588.jpg?imageView2/2/q/80/w/600/h/3000/format/webp", + "normal": "https://qnmob3.doubanio.com/view/celebrity/raw/public/p42588.jpg?imageView2/2/q/80/w/200/h/300/format/webp" + }, + "sharing_url": "https://www.douban.com/doubanapp/dispatch?uri=/celebrity/1025175/", + "type": "celebrity", + "id": "1025175", + "latin_name": "Daniel Craig" + } + """ + if douban_actors and (not updated_name + or not updated_overview + or not update_character): + # 从豆瓣演员中匹配中文名称、角色和简介 + for douban_actor in douban_actors: + if douban_actor.get("latin_name") == people.get("Name") \ + or douban_actor.get("name") == people.get("Name"): + # 名称 + if not updated_name: + logger.debug(f"{people.get('Name')} 从豆瓣中获取到中文名:{douban_actor.get('name')}") + personinfo["Name"] = douban_actor.get("name") + ret_people["Name"] = douban_actor.get("name") + updated_name = True + # 描述 + if not updated_overview: + if douban_actor.get("title"): + logger.debug(f"{people.get('Name')} 从豆瓣中获取到中文描述:{douban_actor.get('title')}") + personinfo["Overview"] = douban_actor.get("title") + updated_overview = True + # 饰演角色 + if not update_character: + if douban_actor.get("character"): + # "饰 詹姆斯·邦德 James Bond 007" + character = re.sub(r"饰\s+", "", + douban_actor.get("character")) + character = re.sub("演员", "", + character) + if character: + logger.debug(f"{people.get('Name')} 从豆瓣中获取到饰演角色:{character}") + ret_people["Role"] = character + update_character = True + # 图片 + if not profile_path: + avatar = douban_actor.get("avatar") or {} + if avatar.get("large"): + logger.debug(f"{people.get('Name')} 从豆瓣中获取到图片:{avatar.get('large')}") + profile_path = avatar.get("large") + break + + # 更新人物图片 + if profile_path: + logger.debug(f"更新人物 {people.get('Name')} 的图片:{profile_path}") + self.set_item_image(server=server, itemid=people.get("Id"), imageurl=profile_path) + + # 锁定人物信息 + if updated_name: + if "Name" not in personinfo["LockedFields"]: + personinfo["LockedFields"].append("Name") + if updated_overview: + if "Overview" not in personinfo["LockedFields"]: + personinfo["LockedFields"].append("Overview") + + # 更新人物信息 + if updated_name or updated_overview or update_character: + logger.debug(f"更新人物 {people.get('Name')} 的信息:{personinfo}") + ret = self.set_iteminfo(server=server, itemid=people.get("Id"), iteminfo=personinfo) + if ret: + return ret_people + else: + logger.debug(f"人物 {people.get('Name')} 未找到中文数据") + except Exception as err: + logger.error(f"更新人物信息失败:{str(err)}") + return None + + def __get_douban_actors(self, mediainfo: MediaInfo, season: int = None) -> List[dict]: + """ + 获取豆瓣演员信息 + """ + # 随机休眠 3-10 秒 + sleep_time = 3 + int(time.time()) % 7 + logger.debug(f"随机休眠 {sleep_time}秒 ...") + time.sleep(sleep_time) + # 匹配豆瓣信息 + doubaninfo = self.chain.match_doubaninfo(name=mediainfo.title, + imdbid=mediainfo.imdb_id, + mtype=mediainfo.type, + year=mediainfo.year, + season=season) + # 豆瓣演员 + if doubaninfo: + doubanitem = self.chain.douban_info(doubaninfo.get("id")) or {} + return (doubanitem.get("actors") or []) + (doubanitem.get("directors") or []) + else: + logger.debug(f"未找到豆瓣信息:{mediainfo.title_year}") + return [] + + @staticmethod + def get_iteminfo(server: str, itemid: str) -> dict: + """ + 获得媒体项详情 + """ + + def __get_emby_iteminfo() -> dict: + """ + 获得Emby媒体项详情 + """ + try: + url = f'[HOST]emby/Users/[USER]/Items/{itemid}?' \ + f'Fields=ChannelMappingInfo&api_key=[APIKEY]' + res = Emby().get_data(url=url) + if res: + return res.json() + except Exception as err: + logger.error(f"获取Emby媒体项详情失败:{str(err)}") + return {} + + def __get_jellyfin_iteminfo() -> dict: + """ + 获得Jellyfin媒体项详情 + """ + try: + url = f'[HOST]Users/[USER]/Items/{itemid}?Fields=ChannelMappingInfo&api_key=[APIKEY]' + res = Jellyfin().get_data(url=url) + if res: + result = res.json() + if result: + result['FileName'] = Path(result['Path']).name + return result + except Exception as err: + logger.error(f"获取Jellyfin媒体项详情失败:{str(err)}") + return {} + + def __get_plex_iteminfo() -> dict: + """ + 获得Plex媒体项详情 + """ + iteminfo = {} + try: + plexitem = Plex().get_plex().library.fetchItem(ekey=itemid) + if 'movie' in plexitem.METADATA_TYPE: + iteminfo['Type'] = 'Movie' + iteminfo['IsFolder'] = False + elif 'episode' in plexitem.METADATA_TYPE: + iteminfo['Type'] = 'Series' + iteminfo['IsFolder'] = False + if 'show' in plexitem.TYPE: + iteminfo['ChildCount'] = plexitem.childCount + iteminfo['Name'] = plexitem.title + iteminfo['Id'] = plexitem.key + iteminfo['ProductionYear'] = plexitem.year + iteminfo['ProviderIds'] = {} + for guid in plexitem.guids: + idlist = str(guid.id).split(sep='://') + if len(idlist) < 2: + continue + iteminfo['ProviderIds'][idlist[0]] = idlist[1] + for location in plexitem.locations: + iteminfo['Path'] = location + iteminfo['FileName'] = Path(location).name + iteminfo['Overview'] = plexitem.summary + iteminfo['CommunityRating'] = plexitem.audienceRating + return iteminfo + except Exception as err: + logger.error(f"获取Plex媒体项详情失败:{str(err)}") + return {} + + if server == "emby": + return __get_emby_iteminfo() + elif server == "jellyfin": + return __get_jellyfin_iteminfo() + else: + return __get_plex_iteminfo() + + @staticmethod + def get_items(server: str, parentid: str, mtype: str = None) -> dict: + """ + 获得媒体的所有子媒体项 + """ + pass + + def __get_emby_items() -> dict: + """ + 获得Emby媒体的所有子媒体项 + """ + try: + if parentid: + url = f'[HOST]emby/Users/[USER]/Items?ParentId={parentid}&api_key=[APIKEY]' + else: + url = '[HOST]emby/Users/[USER]/Items?api_key=[APIKEY]' + res = Emby().get_data(url=url) + if res: + return res.json() + except Exception as err: + logger.error(f"获取Emby媒体的所有子媒体项失败:{str(err)}") + return {} + + def __get_jellyfin_items() -> dict: + """ + 获得Jellyfin媒体的所有子媒体项 + """ + try: + if parentid: + url = f'[HOST]Users/[USER]/Items?ParentId={parentid}&api_key=[APIKEY]' + else: + url = '[HOST]Users/[USER]/Items?api_key=[APIKEY]' + res = Jellyfin().get_data(url=url) + if res: + return res.json() + except Exception as err: + logger.error(f"获取Jellyfin媒体的所有子媒体项失败:{str(err)}") + return {} + + def __get_plex_items() -> dict: + """ + 获得Plex媒体的所有子媒体项 + """ + items = {} + try: + plex = Plex().get_plex() + items['Items'] = [] + if parentid: + if mtype and 'Season' in mtype: + plexitem = plex.library.fetchItem(ekey=parentid) + items['Items'] = [] + for season in plexitem.seasons(): + item = { + 'Name': season.title, + 'Id': season.key, + 'IndexNumber': season.seasonNumber, + 'Overview': season.summary + } + items['Items'].append(item) + elif mtype and 'Episode' in mtype: + plexitem = plex.library.fetchItem(ekey=parentid) + items['Items'] = [] + for episode in plexitem.episodes(): + item = { + 'Name': episode.title, + 'Id': episode.key, + 'IndexNumber': episode.episodeNumber, + 'Overview': episode.summary, + 'CommunityRating': episode.audienceRating + } + items['Items'].append(item) + else: + plexitems = plex.library.sectionByID(sectionID=parentid) + for plexitem in plexitems.all(): + item = {} + if 'movie' in plexitem.METADATA_TYPE: + item['Type'] = 'Movie' + item['IsFolder'] = False + elif 'episode' in plexitem.METADATA_TYPE: + item['Type'] = 'Series' + item['IsFolder'] = False + item['Name'] = plexitem.title + item['Id'] = plexitem.key + items['Items'].append(item) + else: + plexitems = plex.library.sections() + for plexitem in plexitems: + item = {} + if 'Directory' in plexitem.TAG: + item['Type'] = 'Folder' + item['IsFolder'] = True + elif 'movie' in plexitem.METADATA_TYPE: + item['Type'] = 'Movie' + item['IsFolder'] = False + elif 'episode' in plexitem.METADATA_TYPE: + item['Type'] = 'Series' + item['IsFolder'] = False + item['Name'] = plexitem.title + item['Id'] = plexitem.key + items['Items'].append(item) + return items + except Exception as err: + logger.error(f"获取Plex媒体的所有子媒体项失败:{str(err)}") + return {} + + if server == "emby": + return __get_emby_items() + elif server == "jellyfin": + return __get_jellyfin_items() + else: + return __get_plex_items() + + @staticmethod + def set_iteminfo(server: str, itemid: str, iteminfo: dict): + """ + 更新媒体项详情 + """ + + def __set_emby_iteminfo(): + """ + 更新Emby媒体项详情 + """ + try: + res = Emby().post_data( + url=f'[HOST]emby/Items/{itemid}?api_key=[APIKEY]&reqformat=json', + data=json.dumps(iteminfo), + headers={ + "Content-Type": "application/json" + } + ) + if res and res.status_code in [200, 204]: + return True + else: + logger.error(f"更新Emby媒体项详情失败,错误码:{res.status_code}") + return False + except Exception as err: + logger.error(f"更新Emby媒体项详情失败:{str(err)}") + return False + + def __set_jellyfin_iteminfo(): + """ + 更新Jellyfin媒体项详情 + """ + try: + res = Jellyfin().post_data( + url=f'[HOST]Items/{itemid}?api_key=[APIKEY]', + data=json.dumps(iteminfo), + headers={ + "Content-Type": "application/json" + } + ) + if res and res.status_code in [200, 204]: + return True + else: + logger.error(f"更新Jellyfin媒体项详情失败,错误码:{res.status_code}") + return False + except Exception as err: + logger.error(f"更新Jellyfin媒体项详情失败:{str(err)}") + return False + + def __set_plex_iteminfo(): + """ + 更新Plex媒体项详情 + """ + try: + plexitem = Plex().get_plex().library.fetchItem(ekey=itemid) + if 'CommunityRating' in iteminfo: + edits = { + 'audienceRating.value': iteminfo['CommunityRating'], + 'audienceRating.locked': 1 + } + plexitem.edit(**edits) + plexitem.editTitle(iteminfo['Name']).editSummary(iteminfo['Overview']).reload() + return True + except Exception as err: + logger.error(f"更新Plex媒体项详情失败:{str(err)}") + return False + + if server == "emby": + return __set_emby_iteminfo() + elif server == "jellyfin": + return __set_jellyfin_iteminfo() + else: + return __set_plex_iteminfo() + + @staticmethod + @retry(RequestException, logger=logger) + def set_item_image(server: str, itemid: str, imageurl: str): + """ + 更新媒体项图片 + """ + + def __download_image(): + """ + 下载图片 + """ + try: + if "doubanio.com" in imageurl: + r = RequestUtils(headers={ + 'Referer': "https://movie.douban.com/" + }, ua=settings.USER_AGENT).get_res(url=imageurl, raise_exception=True) + else: + r = RequestUtils().get_res(url=imageurl, raise_exception=True) + if r: + return base64.b64encode(r.content).decode() + else: + logger.warn(f"{imageurl} 图片下载失败,请检查网络连通性") + except Exception as err: + logger.error(f"下载图片失败:{str(err)}") + return None + + def __set_emby_item_image(_base64: str): + """ + 更新Emby媒体项图片 + """ + try: + url = f'[HOST]emby/Items/{itemid}/Images/Primary?api_key=[APIKEY]' + res = Emby().post_data( + url=url, + data=_base64, + headers={ + "Content-Type": "image/png" + } + ) + if res and res.status_code in [200, 204]: + return True + else: + logger.error(f"更新Emby媒体项图片失败,错误码:{res.status_code}") + return False + except Exception as result: + logger.error(f"更新Emby媒体项图片失败:{result}") + return False + + def __set_jellyfin_item_image(): + """ + 更新Jellyfin媒体项图片 + # FIXME 改为预下载图片 + """ + try: + url = f'[HOST]Items/{itemid}/RemoteImages/Download?' \ + f'Type=Primary&ImageUrl={imageurl}&ProviderName=TheMovieDb&api_key=[APIKEY]' + res = Jellyfin().post_data(url=url) + if res and res.status_code in [200, 204]: + return True + else: + logger.error(f"更新Jellyfin媒体项图片失败,错误码:{res.status_code}") + return False + except Exception as err: + logger.error(f"更新Jellyfin媒体项图片失败:{err}") + return False + + def __set_plex_item_image(): + """ + 更新Plex媒体项图片 + # FIXME 改为预下载图片 + """ + try: + plexitem = Plex().get_plex().library.fetchItem(ekey=itemid) + plexitem.uploadPoster(url=imageurl) + return True + except Exception as err: + logger.error(f"更新Plex媒体项图片失败:{err}") + return False + + if server == "emby": + # 下载图片获取base64 + image_base64 = __download_image() + if image_base64: + return __set_emby_item_image(image_base64) + elif server == "jellyfin": + return __set_jellyfin_item_image() + else: + return __set_plex_item_image() + return None + + @staticmethod + def __get_chinese_name(personinfo: schemas.MediaPerson) -> str: + """ + 获取TMDB别名中的中文名 + """ + try: + also_known_as = personinfo.also_known_as or [] + if also_known_as: + for name in also_known_as: + if name and StringUtils.is_chinese(name): + # 使用cn2an将繁体转化为简体 + return zhconv.convert(name, "zh-hans") + except Exception as err: + logger.error(f"获取人物中文名失败:{err}") + return "" + + def stop_service(self): + """ + 停止服务 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._event.set() + self._scheduler.shutdown() + self._event.clear() + self._scheduler = None + except Exception as e: + print(str(e)) diff --git a/plugins.v2/qbcommand/__init__.py b/plugins.v2/qbcommand/__init__.py new file mode 100644 index 0000000..78f6916 --- /dev/null +++ b/plugins.v2/qbcommand/__init__.py @@ -0,0 +1,1171 @@ +from typing import List, Tuple, Dict, Any +from enum import Enum +from urllib.parse import urlparse +import urllib +from app.log import logger +from app.modules.qbittorrent import Qbittorrent +from app.plugins import _PluginBase +from app.schemas import NotificationType +from app.schemas.types import EventType +from apscheduler.triggers.cron import CronTrigger +from app.core.event import eventmanager, Event +from apscheduler.schedulers.background import BackgroundScheduler +from app.core.config import settings +from app.helper.sites import SitesHelper +from app.db.site_oper import SiteOper +from app.utils.string import StringUtils +from datetime import datetime, timedelta +import pytz +import time + + +class QbCommand(_PluginBase): + # 插件名称 + plugin_name = "QB远程操作" + # 插件描述 + plugin_desc = "通过定时任务或交互命令远程操作QB暂停/开始/限速等" + # 插件图标 + plugin_icon = "Qbittorrent_A.png" + # 插件版本 + plugin_version = "1.5" + # 插件作者 + plugin_author = "DzAvril" + # 作者主页 + author_url = "https://github.com/DzAvril" + # 插件配置项ID前缀 + plugin_config_prefix = "qbcommand_" + # 加载顺序 + plugin_order = 1 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _sites = None + _siteoper = None + _qb = None + _enabled: bool = False + _notify: bool = False + _pause_cron = None + _resume_cron = None + _only_pause_once = False + _only_resume_once = False + _only_pause_upload = False + _only_pause_download = False + _only_pause_checking = False + _upload_limit = 0 + _enable_upload_limit = False + _download_limit = 0 + _enable_download_limit = False + _op_site_ids = [] + _op_sites = [] + _multi_level_root_domain = ["edu.cn", "com.cn", "net.cn", "org.cn"] + _scheduler = None + _exclude_dirs = "" + def init_plugin(self, config: dict = None): + self._sites = SitesHelper() + self._siteoper = SiteOper() + # 停止现有任务 + self.stop_service() + # 读取配置 + if config: + self._enabled = config.get("enabled") + self._notify = config.get("notify") + self._pause_cron = config.get("pause_cron") + self._resume_cron = config.get("resume_cron") + self._only_pause_once = config.get("onlypauseonce") + self._only_resume_once = config.get("onlyresumeonce") + self._only_pause_upload = config.get("onlypauseupload") + self._only_pause_download = config.get("onlypausedownload") + self._only_pause_checking = config.get("onlypausechecking") + self._download_limit = config.get("download_limit") + self._upload_limit = config.get("upload_limit") + self._enable_download_limit = config.get("enable_download_limit") + self._enable_upload_limit = config.get("enable_upload_limit") + self._qb = Qbittorrent() + self._op_site_ids = config.get("op_site_ids") or [] + # 查询所有站点 + all_sites = [site for site in self._sites.get_indexers() if not site.get("public")] + self.__custom_sites() + # 过滤掉没有选中的站点 + self._op_sites = [site for site in all_sites if site.get("id") in self._op_site_ids] + self._exclude_dirs = config.get("exclude_dirs") or "" + + if self._only_pause_once or self._only_resume_once: + if self._only_pause_once and self._only_resume_once: + logger.warning("只能选择一个: 立即暂停或立即开始所有任务") + elif self._only_pause_once: + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + logger.info(f"立即运行一次暂停所有任务") + self._scheduler.add_job( + self.pause_torrent, + "date", + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + + timedelta(seconds=3), + ) + elif self._only_resume_once: + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + logger.info(f"立即运行一次开始所有任务") + self._scheduler.add_job( + self.resume_torrent, + "date", + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + + timedelta(seconds=3), + ) + + self._only_resume_once = False + self._only_pause_once = False + self.update_config( + { + "onlypauseonce": False, + "onlyresumeonce": False, + "enabled": self._enabled, + "notify": self._notify, + "pause_cron": self._pause_cron, + "resume_cron": self._resume_cron, + "op_site_ids": self._op_site_ids, + "exclude_dirs": self._exclude_dirs, + } + ) + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + if ( + self._only_pause_upload + or self._only_pause_download + or self._only_pause_checking + ): + if self._only_pause_upload: + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + logger.info(f"立即运行一次暂停所有上传任务") + self._scheduler.add_job( + self.pause_torrent, + "date", + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + + timedelta(seconds=3), + kwargs={ + 'type': self.TorrentType.UPLOADING + } + ) + if self._only_pause_download: + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + logger.info(f"立即运行一次暂停所有下载任务") + self._scheduler.add_job( + self.pause_torrent, + "date", + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + + timedelta(seconds=3), + kwargs={ + 'type': self.TorrentType.DOWNLOADING + } + ) + if self._only_pause_checking: + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + logger.info(f"立即运行一次暂停所有检查任务") + self._scheduler.add_job( + self.pause_torrent, + "date", + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + + timedelta(seconds=3), + kwargs={ + 'type': self.TorrentType.CHECKING + } + ) + + self._only_pause_upload = False + self._only_pause_download = False + self._only_pause_checking = False + self.update_config( + { + "onlypauseupload": False, + "onlypausedownload": False, + "onlypausechecking": False, + "enabled": self._enabled, + "notify": self._notify, + "pause_cron": self._pause_cron, + "resume_cron": self._resume_cron, + "op_site_ids": self._op_site_ids, + } + ) + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + self.set_limit(self._upload_limit, self._download_limit) + + def get_state(self) -> bool: + return self._enabled + + class TorrentType(Enum): + ALL = 1 + DOWNLOADING = 2 + UPLOADING = 3 + CHECKING = 4 + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + """ + 定义远程控制命令 + :return: 命令关键字、事件、描述、附带数据 + """ + return [ + { + "cmd": "/pause_torrents", + "event": EventType.PluginAction, + "desc": "暂停QB所有任务", + "category": "QB", + "data": {"action": "pause_torrents"}, + }, + { + "cmd": "/pause_upload_torrents", + "event": EventType.PluginAction, + "desc": "暂停QB上传任务", + "category": "QB", + "data": {"action": "pause_upload_torrents"}, + }, + { + "cmd": "/pause_download_torrents", + "event": EventType.PluginAction, + "desc": "暂停QB下载任务", + "category": "QB", + "data": {"action": "pause_download_torrents"}, + }, + { + "cmd": "/pause_checking_torrents", + "event": EventType.PluginAction, + "desc": "暂停QB检查任务", + "category": "QB", + "data": {"action": "pause_checking_torrents"}, + }, + { + "cmd": "/resume_torrents", + "event": EventType.PluginAction, + "desc": "开始QB所有任务", + "category": "QB", + "data": {"action": "resume_torrents"}, + }, + { + "cmd": "/qb_status", + "event": EventType.PluginAction, + "desc": "QB当前任务状态", + "category": "QB", + "data": {"action": "qb_status"}, + }, + { + "cmd": "/toggle_upload_limit", + "event": EventType.PluginAction, + "desc": "QB切换上传限速状态", + "category": "QB", + "data": {"action": "toggle_upload_limit"}, + }, + { + "cmd": "/toggle_download_limit", + "event": EventType.PluginAction, + "desc": "QB切换下载限速状态", + "category": "QB", + "data": {"action": "toggle_download_limit"}, + }, + ] + + def __custom_sites(self) -> List[Any]: + custom_sites = [] + custom_sites_config = self.get_config("CustomSites") + if custom_sites_config and custom_sites_config.get("enabled"): + custom_sites = custom_sites_config.get("sites") + return custom_sites + + 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 and self._pause_cron and self._resume_cron: + return [ + { + "id": "QbPause", + "name": "暂停QB所有任务", + "trigger": CronTrigger.from_crontab(self._pause_cron), + "func": self.pause_torrent, + "kwargs": {}, + }, + { + "id": "QbResume", + "name": "开始QB所有任务", + "trigger": CronTrigger.from_crontab(self._resume_cron), + "func": self.resume_torrent, + "kwargs": {}, + }, + ] + if self._enabled and self._pause_cron: + return [ + { + "id": "QbPause", + "name": "暂停QB所有任务", + "trigger": CronTrigger.from_crontab(self._pause_cron), + "func": self.pause_torrent, + "kwargs": {}, + } + ] + if self._enabled and self._resume_cron: + return [ + { + "id": "QbResume", + "name": "开始QB所有任务", + "trigger": CronTrigger.from_crontab(self._resume_cron), + "func": self.resume_torrent, + "kwargs": {}, + } + ] + return [] + + def get_all_torrents(self): + all_torrents, error = self._qb.get_torrents() + if error: + logger.error(f"获取QB种子失败: {error}") + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【QB远程操作】", + text=f"获取QB种子失败,请检查QB配置", + ) + return [] + + if not all_torrents: + logger.warning("QB没有种子") + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【QB远程操作】", + text=f"QB中没有种子", + ) + return [] + return all_torrents + + @staticmethod + def get_torrents_status(torrents): + downloading_torrents = [] + uploading_torrents = [] + paused_torrents = [] + checking_torrents = [] + error_torrents = [] + for torrent in torrents: + if torrent.state_enum.is_uploading and not torrent.state_enum.is_paused: + uploading_torrents.append(torrent.get("hash")) + elif ( + torrent.state_enum.is_downloading + and not torrent.state_enum.is_paused + and not torrent.state_enum.is_checking + ): + downloading_torrents.append(torrent.get("hash")) + elif torrent.state_enum.is_checking: + checking_torrents.append(torrent.get("hash")) + elif torrent.state_enum.is_paused: + paused_torrents.append(torrent.get("hash")) + elif torrent.state_enum.is_errored: + error_torrents.append(torrent.get("hash")) + + return ( + downloading_torrents, + uploading_torrents, + paused_torrents, + checking_torrents, + error_torrents, + ) + + @eventmanager.register(EventType.PluginAction) + def handle_pause_torrent(self, event: Event): + if not self._enabled: + return + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "pause_torrents": + return + self.pause_torrent() + + @eventmanager.register(EventType.PluginAction) + def handle_pause_upload_torrent(self, event: Event): + if not self._enabled: + return + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "pause_upload_torrents": + return + self.pause_torrent(self.TorrentType.UPLOADING) + + @eventmanager.register(EventType.PluginAction) + def handle_pause_download_torrent(self, event: Event): + if not self._enabled: + return + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "pause_download_torrents": + return + self.pause_torrent(self.TorrentType.DOWNLOADING) + + @eventmanager.register(EventType.PluginAction) + def handle_pause_checking_torrent(self, event: Event): + if not self._enabled: + return + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "pause_checking_torrents": + return + self.pause_torrent(self.TorrentType.CHECKING) + + def pause_torrent(self, type: TorrentType = TorrentType.ALL): + if not self._enabled: + return + + all_torrents = self.get_all_torrents() + hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( + self.get_torrents_status(all_torrents) + ) + + logger.info( + f"暂定任务启动 \n" + f"种子总数: {len(all_torrents)} \n" + f"做种数量: {len(hash_uploading)}\n" + f"下载数量: {len(hash_downloading)}\n" + f"检查数量: {len(hash_checking)}\n" + f"暂停数量: {len(hash_paused)}\n" + f"错误数量: {len(hash_error)}\n" + f"暂停操作中请稍等...\n", + ) + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【QB暂停任务启动】", + text=f"种子总数: {len(all_torrents)} \n" + f"做种数量: {len(hash_uploading)}\n" + f"下载数量: {len(hash_downloading)}\n" + f"检查数量: {len(hash_checking)}\n" + f"暂停数量: {len(hash_paused)}\n" + f"错误数量: {len(hash_error)}\n" + f"暂停操作中请稍等...\n", + ) + pause_torrents = self.filter_pause_torrents(all_torrents) + hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( + self.get_torrents_status(pause_torrents) + ) + if type == self.TorrentType.DOWNLOADING: + to_be_paused = hash_downloading + elif type == self.TorrentType.UPLOADING: + to_be_paused = hash_uploading + elif type == self.TorrentType.CHECKING: + to_be_paused = hash_checking + else: + to_be_paused = hash_downloading + hash_uploading + hash_checking + + if len(to_be_paused) > 0: + if self._qb.stop_torrents(ids=to_be_paused): + logger.info(f"暂停了{len(to_be_paused)}个种子") + else: + logger.error(f"暂停种子失败") + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【QB远程操作】", + text=f"暂停种子失败", + ) + # 每个种子等待1ms以让状态切换成功,至少等待1S + wait_time = 0.001 * len(to_be_paused) + 1 + time.sleep(wait_time) + + all_torrents = self.get_all_torrents() + hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( + self.get_torrents_status(all_torrents) + ) + logger.info( + f"暂定任务完成 \n" + f"种子总数: {len(all_torrents)} \n" + f"做种数量: {len(hash_uploading)}\n" + f"下载数量: {len(hash_downloading)}\n" + f"检查数量: {len(hash_checking)}\n" + f"暂停数量: {len(hash_paused)}\n" + f"错误数量: {len(hash_error)}\n" + ) + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【QB暂停任务完成】", + text=f"种子总数: {len(all_torrents)} \n" + f"做种数量: {len(hash_uploading)}\n" + f"下载数量: {len(hash_downloading)}\n" + f"检查数量: {len(hash_checking)}\n" + f"暂停数量: {len(hash_paused)}\n" + f"错误数量: {len(hash_error)}\n", + ) + + def __is_excluded(self, file_path) -> bool: + """ + 是否排除目录 + """ + for exclude_dir in self._exclude_dirs.split("\n"): + if exclude_dir and exclude_dir in str(file_path): + return True + return False + def filter_pause_torrents(self, all_torrents): + torrents = [] + for torrent in all_torrents: + if self.__is_excluded(torrent.get("content_path")): + continue + torrents.append(torrent) + return torrents + + @eventmanager.register(EventType.PluginAction) + def handle_resume_torrent(self, event: Event): + if not self._enabled: + return + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "resume_torrents": + return + self.resume_torrent() + + def resume_torrent(self): + if not self._enabled: + return + + all_torrents = self.get_all_torrents() + hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( + self.get_torrents_status(all_torrents) + ) + logger.info( + f"QB开始任务启动 \n" + f"种子总数: {len(all_torrents)} \n" + f"做种数量: {len(hash_uploading)}\n" + f"下载数量: {len(hash_downloading)}\n" + f"检查数量: {len(hash_checking)}\n" + f"暂停数量: {len(hash_paused)}\n" + f"错误数量: {len(hash_error)}\n" + f"开始操作中请稍等...\n", + ) + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【QB开始任务启动】", + text=f"种子总数: {len(all_torrents)} \n" + f"做种数量: {len(hash_uploading)}\n" + f"下载数量: {len(hash_downloading)}\n" + f"检查数量: {len(hash_checking)}\n" + f"暂停数量: {len(hash_paused)}\n" + f"错误数量: {len(hash_error)}\n" + f"开始操作中请稍等...\n", + ) + + resume_torrents = self.filter_resume_torrents(all_torrents) + hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( + self.get_torrents_status(resume_torrents) + ) + if not self._qb.start_torrents(ids=hash_paused): + logger.error(f"开始种子失败") + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【QB远程操作】", + text=f"开始种子失败", + ) + # 每个种子等待1ms以让状态切换成功,至少等待1S + wait_time = 0.001 * len(hash_paused) + 1 + time.sleep(wait_time) + + all_torrents = self.get_all_torrents() + hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( + self.get_torrents_status(all_torrents) + ) + logger.info( + f"开始任务完成 \n" + f"种子总数: {len(all_torrents)} \n" + f"做种数量: {len(hash_uploading)}\n" + f"下载数量: {len(hash_downloading)}\n" + f"检查数量: {len(hash_checking)}\n" + f"暂停数量: {len(hash_paused)}\n" + f"错误数量: {len(hash_error)}\n" + ) + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【QB开始任务完成】", + text=f"种子总数: {len(all_torrents)} \n" + f"做种数量: {len(hash_uploading)}\n" + f"下载数量: {len(hash_downloading)}\n" + f"检查数量: {len(hash_checking)}\n" + f"暂停数量: {len(hash_paused)}\n" + f"错误数量: {len(hash_error)}\n", + ) + + def filter_resume_torrents(self, all_torrents): + """ + 过滤掉不参与保种的种子 + """ + if len(self._op_sites) == 0: + return all_torrents + + urls = [site.get("url") for site in self._op_sites] + op_sites_main_domains = [] + for url in urls: + domain = StringUtils.get_url_netloc(url) + main_domain = self.get_main_domain(domain[1]) + op_sites_main_domains.append(main_domain) + + torrents = [] + for torrent in all_torrents: + if torrent.get("state") == "pausedUP": + tracker_url = self.get_torrent_tracker(torrent) + if not tracker_url: + logger.info(f"获取种子 {torrent.name} Tracker失败,不过滤该种子") + torrents.append(torrent) + _, tracker_domain = StringUtils.get_url_netloc(tracker_url) + if not tracker_domain: + logger.info(f"获取种子 {torrent.name} Tracker失败,不过滤该种子") + torrents.append(torrent) + tracker_main_domain = self.get_main_domain(domain=tracker_domain) + if tracker_main_domain in op_sites_main_domains: + logger.info( + f"种子 {torrent.name} 属于站点{tracker_main_domain},不执行操作" + ) + continue + + torrents.append(torrent) + return torrents + + @eventmanager.register(EventType.PluginAction) + def handle_qb_status(self, event: Event): + if not self._enabled: + return + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "qb_status": + return + self.qb_status() + + def qb_status(self): + if not self._enabled: + return + + all_torrents = self.get_all_torrents() + hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( + self.get_torrents_status(all_torrents) + ) + logger.info( + f"QB任务状态 \n" + f"种子总数: {len(all_torrents)} \n" + f"做种数量: {len(hash_uploading)}\n" + f"下载数量: {len(hash_downloading)}\n" + f"检查数量: {len(hash_checking)}\n" + f"暂停数量: {len(hash_paused)}\n" + f"错误数量: {len(hash_error)}\n" + ) + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【QB任务状态】", + text=f"种子总数: {len(all_torrents)} \n" + f"做种数量: {len(hash_uploading)}\n" + f"下载数量: {len(hash_downloading)}\n" + f"检查数量: {len(hash_checking)}\n" + f"暂停数量: {len(hash_paused)}\n" + f"错误数量: {len(hash_error)}\n" + ) + + @eventmanager.register(EventType.PluginAction) + def handle_toggle_upload_limit(self, event: Event): + if not self._enabled: + return + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "toggle_upload_limit": + return + self.set_limit(self._upload_limit, self._download_limit) + + @eventmanager.register(EventType.PluginAction) + def handle_toggle_download_limit(self, event: Event): + if not self._enabled: + return + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "toggle_download_limit": + return + self.set_limit(self._upload_limit, self._download_limit) + + def set_both_limit(self, upload_limit, download_limit): + if not self._enable_upload_limit or not self._enable_upload_limit: + return True + + if ( + not upload_limit + or not upload_limit.isdigit() + or not download_limit + or not download_limit.isdigit() + ): + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【QB远程操作】", + text=f"设置QB限速失败,download_limit或upload_limit不是一个数值", + ) + return False + + return self._qb.set_speed_limit( + download_limit=int(download_limit), upload_limit=int(upload_limit) + ) + + def set_upload_limit(self, upload_limit): + if not self._enable_upload_limit: + return True + + if not upload_limit or not upload_limit.isdigit(): + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【QB远程操作】", + text=f"设置QB限速失败,upload_limit不是一个数值", + ) + return False + + download_limit_current_val, _ = self._qb.get_speed_limit() + return self._qb.set_speed_limit( + download_limit=int(download_limit_current_val), + upload_limit=int(upload_limit), + ) + + def set_download_limit(self, download_limit): + if not self._enable_download_limit: + return True + + if not download_limit or not download_limit.isdigit(): + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【QB远程操作】", + text=f"设置QB限速失败,download_limit不是一个数值", + ) + return False + + _, upload_limit_current_val = self._qb.get_speed_limit() + return self._qb.set_speed_limit( + download_limit=int(download_limit), + upload_limit=int(upload_limit_current_val), + ) + + def set_limit(self, upload_limit, download_limit): + # 限速,满足以下三种情况设置限速 + # 1. 插件启用 && download_limit启用 + # 2. 插件启用 && upload_limit启用 + # 3. 插件启用 && download_limit启用 && upload_limit启用 + + flag = None + if self._enabled and self._enable_download_limit and self._enable_upload_limit: + flag = self.set_both_limit(upload_limit, download_limit) + + elif flag is None and self._enabled and self._enable_download_limit: + flag = self.set_download_limit(download_limit) + + elif flag is None and self._enabled and self._enable_upload_limit: + flag = self.set_upload_limit(upload_limit) + + if flag == True: + logger.info(f"设置QB限速成功") + if self._notify: + if upload_limit == 0: + text = f"上传无限速" + else: + text = f"上传限速:{upload_limit} KB/s" + if download_limit == 0: + text += f"\n下载无限速" + else: + text += f"\n下载限速:{download_limit} KB/s" + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【QB远程操作】", + text=text, + ) + elif flag == False: + logger.error(f"QB设置限速失败") + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【QB远程操作】", + text=f"设置QB限速失败", + ) + + def get_torrent_tracker(self, torrent): + """ + qb解析 tracker + :return: tracker url + """ + if not torrent: + return None + tracker = torrent.get("tracker") + if tracker and len(tracker) > 0: + return tracker + magnet_uri = torrent.get("magnet_uri") + if not magnet_uri or len(magnet_uri) <= 0: + return None + magnet_uri_obj = urlparse(magnet_uri) + query = urllib.parse.parse_qs(magnet_uri_obj.query) + tr = query["tr"] + if not tr or len(tr) <= 0: + return None + return tr[0] + + def get_main_domain(self, domain): + """ + 获取域名的主域名 + :param domain: 原域名 + :return: 主域名 + """ + if not domain: + return None + domain_arr = domain.split(".") + 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]}" + + def match_multi_level_root_domain(self, domain): + """ + 匹配多级根域名 + :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 get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + customSites = self.__custom_sites() + + site_options = [ + {"title": site.name, "value": site.id} + for site in self._siteoper.list_order_by_pri() + ] + [ + {"title": site.get("name"), "value": site.get("id")} for site in customSites + ] + return [ + { + "component": "VForm", + "content": [ + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "enabled", + "label": "启用插件", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "notify", + "label": "发送通知", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "onlypauseonce", + "label": "立即暂停所有任务", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "onlyresumeonce", + "label": "立即开始所有任务", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "pause_cron", + "label": "暂停周期", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "resume_cron", + "label": "开始周期", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "enable_upload_limit", + "label": "上传限速", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "enable_download_limit", + "label": "下载限速", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "upload_limit", + "label": "上传限速 KB/s", + "placeholder": "KB/s", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "download_limit", + "label": "下载限速 KB/s", + "placeholder": "KB/s", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "onlypauseupload", + "label": "暂停上传任务", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "onlypausedownload", + "label": "暂停下载任务", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "onlypausechecking", + "label": "暂停检查任务", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VSelect", + "props": { + "chips": True, + "multiple": True, + "model": "op_site_ids", + "label": "停止保种站点(暂停保种后不会被恢复)", + "items": site_options, + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "exclude_dirs", + "label": "不暂停保种目录", + "rows": 5, + "placeholder": "该目录下的做种不会暂停,一行一个目录", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": { + "cols": 12, + }, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "开始周期和暂停周期使用Cron表达式,如:0 0 0 * *,仅针对开始/暂定全部任务", + }, + } + ], + }, + { + "component": "VCol", + "props": { + "cols": 12, + }, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "交互命令有暂停QB种子、开始QB种子、QB切换上传限速状态、QB切换下载限速状态", + }, + } + ], + }, + ], + }, + ], + } + ], { + "enabled": False, + "notify": True, + "onlypauseonce": False, + "onlyresumeonce": False, + "onlypauseupload": False, + "onlypausedownload": False, + "onlypausechecking": False, + "upload_limit": 0, + "download_limit": 0, + "enable_upload_limit": False, + "enable_download_limit": False, + "op_site_ids": [], + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) diff --git a/plugins.v2/rsssubscribe/__init__.py b/plugins.v2/rsssubscribe/__init__.py new file mode 100644 index 0000000..2fdc278 --- /dev/null +++ b/plugins.v2/rsssubscribe/__init__.py @@ -0,0 +1,775 @@ +import datetime +import re +import traceback +from pathlib import Path +from threading import Lock +from typing import Optional, Any, List, Dict, Tuple + +import pytz +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from app import schemas +from app.chain.download import DownloadChain +from app.chain.search import SearchChain +from app.chain.subscribe import SubscribeChain +from app.core.config import settings +from app.core.context import MediaInfo, TorrentInfo, Context +from app.core.metainfo import MetaInfo +from app.helper.rss import RssHelper +from app.log import logger +from app.plugins import _PluginBase +from app.schemas import ExistMediaInfo +from app.schemas.types import SystemConfigKey, MediaType + +lock = Lock() + + +class RssSubscribe(_PluginBase): + # 插件名称 + plugin_name = "自定义订阅" + # 插件描述 + plugin_desc = "定时刷新RSS报文,识别内容后添加订阅或直接下载。" + # 插件图标 + plugin_icon = "rss.png" + # 插件版本 + plugin_version = "1.5" + # 插件作者 + plugin_author = "jxxghp" + # 作者主页 + author_url = "https://github.com/jxxghp" + # 插件配置项ID前缀 + plugin_config_prefix = "rsssubscribe_" + # 加载顺序 + plugin_order = 19 + # 可使用的用户级别 + auth_level = 2 + + # 私有变量 + _scheduler: Optional[BackgroundScheduler] = None + _cache_path: Optional[Path] = None + rsshelper = None + downloadchain = None + searchchain = None + subscribechain = None + + # 配置属性 + _enabled: bool = False + _cron: str = "" + _notify: bool = False + _onlyonce: bool = False + _address: str = "" + _include: str = "" + _exclude: str = "" + _proxy: bool = False + _filter: bool = False + _clear: bool = False + _clearflag: bool = False + _action: str = "subscribe" + _save_path: str = "" + _size_range: str = "" + + def init_plugin(self, config: dict = None): + self.rsshelper = RssHelper() + self.downloadchain = DownloadChain() + self.searchchain = SearchChain() + self.subscribechain = SubscribeChain() + + # 停止现有任务 + self.stop_service() + + # 配置 + if config: + self.__validate_and_fix_config(config=config) + self._enabled = config.get("enabled") + self._cron = config.get("cron") + self._notify = config.get("notify") + self._onlyonce = config.get("onlyonce") + self._address = config.get("address") + self._include = config.get("include") + self._exclude = config.get("exclude") + self._proxy = config.get("proxy") + self._filter = config.get("filter") + self._clear = config.get("clear") + self._action = config.get("action") + self._save_path = config.get("save_path") + self._size_range = config.get("size_range") + + if self._onlyonce: + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + logger.info(f"自定义订阅服务启动,立即运行一次") + self._scheduler.add_job(func=self.check, trigger='date', + run_date=datetime.datetime.now( + tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3) + ) + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + if self._onlyonce or self._clear: + # 关闭一次性开关 + self._onlyonce = False + # 记录清理缓存设置 + self._clearflag = self._clear + # 关闭清理缓存开关 + self._clear = False + # 保存设置 + self.__update_config() + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + """ + 定义远程控制命令 + :return: 命令关键字、事件、描述、附带数据 + """ + pass + + def get_api(self) -> List[Dict[str, Any]]: + """ + 获取插件API + [{ + "path": "/xx", + "endpoint": self.xxx, + "methods": ["GET", "POST"], + "summary": "API说明" + }] + """ + return [ + { + "path": "/delete_history", + "endpoint": self.delete_history, + "methods": ["GET"], + "summary": "删除自定义订阅历史记录" + } + ] + + 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 and self._cron: + return [{ + "id": "RssSubscribe", + "name": "自定义订阅服务", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self.check, + "kwargs": {} + }] + elif self._enabled: + return [{ + "id": "RssSubscribe", + "name": "自定义订阅服务", + "trigger": "interval", + "func": self.check, + "kwargs": {"minutes": 30} + }] + return [] + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '发送通知', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '执行周期', + 'placeholder': '5位cron表达式,留空自动' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'action', + 'label': '动作', + 'items': [ + {'title': '订阅', 'value': 'subscribe'}, + {'title': '下载', 'value': 'download'} + ] + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'address', + 'label': 'RSS地址', + 'rows': 3, + 'placeholder': '每行一个RSS地址' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'include', + 'label': '包含', + 'placeholder': '支持正则表达式' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'exclude', + 'label': '排除', + 'placeholder': '支持正则表达式' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'size_range', + 'label': '种子大小(GB)', + 'placeholder': '如:3 或 3-5' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'save_path', + 'label': '保存目录', + 'placeholder': '下载时有效,留空自动' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'proxy', + 'label': '使用代理服务器', + } + } + ] + }, { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4, + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'filter', + 'label': '使用过滤规则', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'clear', + 'label': '清理历史记录', + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "notify": True, + "onlyonce": False, + "cron": "*/30 * * * *", + "address": "", + "include": "", + "exclude": "", + "proxy": False, + "clear": False, + "filter": False, + "action": "subscribe", + "save_path": "", + "size_range": "" + } + + def get_page(self) -> List[dict]: + """ + 拼装插件详情页面,需要返回页面配置,同时附带数据 + """ + # 查询同步详情 + historys = self.get_data('history') + if not historys: + return [ + { + 'component': 'div', + 'text': '暂无数据', + 'props': { + 'class': 'text-center', + } + } + ] + # 数据按时间降序排序 + historys = sorted(historys, key=lambda x: x.get('time'), reverse=True) + # 拼装页面 + contents = [] + for history in historys: + title = history.get("title") + poster = history.get("poster") + mtype = history.get("type") + time_str = history.get("time") + contents.append( + { + 'component': 'VCard', + 'content': [ + { + "component": "VDialogCloseBtn", + "props": { + 'innerClass': 'absolute top-0 right-0', + }, + 'events': { + 'click': { + 'api': 'plugin/RssSubscribe/delete_history', + 'method': 'get', + 'params': { + 'key': title, + 'apikey': settings.API_TOKEN + } + } + }, + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex justify-space-start flex-nowrap flex-row', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'VImg', + 'props': { + 'src': poster, + 'height': 120, + 'width': 80, + 'aspect-ratio': '2/3', + 'class': 'object-cover shadow ring-gray-500', + 'cover': True + } + } + ] + }, + { + 'component': 'div', + 'content': [ + { + 'component': 'VCardTitle', + 'props': { + 'class': 'pa-1 pe-5 break-words whitespace-break-spaces' + }, + 'text': title + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'类型:{mtype}' + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'时间:{time_str}' + } + ] + } + ] + } + ] + } + ) + + return [ + { + 'component': 'div', + 'props': { + 'class': 'grid gap-3 grid-info-card', + }, + 'content': contents + } + ] + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) + + def delete_history(self, key: str, apikey: str): + """ + 删除同步历史记录 + """ + if apikey != settings.API_TOKEN: + return schemas.Response(success=False, message="API密钥错误") + # 历史记录 + historys = self.get_data('history') + if not historys: + return schemas.Response(success=False, message="未找到历史记录") + # 删除指定记录 + historys = [h for h in historys if h.get("title") != key] + self.save_data('history', historys) + return schemas.Response(success=True, message="删除成功") + + def __update_config(self): + """ + 更新设置 + """ + self.update_config({ + "enabled": self._enabled, + "notify": self._notify, + "onlyonce": self._onlyonce, + "cron": self._cron, + "address": self._address, + "include": self._include, + "exclude": self._exclude, + "proxy": self._proxy, + "clear": self._clear, + "filter": self._filter, + "action": self._action, + "save_path": self._save_path, + "size_range": self._size_range + }) + + def check(self): + """ + 通过用户RSS同步豆瓣想看数据 + """ + if not self._address: + return + # 读取历史记录 + if self._clearflag: + history = [] + else: + history: List[dict] = self.get_data('history') or [] + for url in self._address.split("\n"): + # 处理每一个RSS链接 + if not url: + continue + logger.info(f"开始刷新RSS:{url} ...") + results = self.rsshelper.parse(url, proxy=self._proxy) + if not results: + logger.error(f"未获取到RSS数据:{url}") + return + # 过滤规则 + filter_rule = self.systemconfig.get(SystemConfigKey.SubscribeFilterRules) + # 解析数据 + for result in results: + try: + title = result.get("title") + description = result.get("description") + enclosure = result.get("enclosure") + link = result.get("link") + size = result.get("size") + pubdate: datetime.datetime = result.get("pubdate") + # 检查是否处理过 + if not title or title in [h.get("key") for h in history]: + continue + # 检查规则 + if self._include and not re.search(r"%s" % self._include, + f"{title} {description}", re.IGNORECASE): + logger.info(f"{title} - {description} 不符合包含规则") + continue + if self._exclude and re.search(r"%s" % self._exclude, + f"{title} {description}", re.IGNORECASE): + logger.info(f"{title} - {description} 不符合排除规则") + continue + if self._size_range: + sizes = [float(_size) * 1024 ** 3 for _size in self._size_range.split("-")] + if len(sizes) == 1 and float(size) < sizes[0]: + logger.info(f"{title} - 种子大小不符合条件") + continue + elif len(sizes) > 1 and not sizes[0] <= float(size) <= sizes[1]: + logger.info(f"{title} - 种子大小不在指定范围") + continue + # 识别媒体信息 + meta = MetaInfo(title=title, subtitle=description) + if not meta.name: + logger.warn(f"{title} 未识别到有效数据") + continue + mediainfo: MediaInfo = self.chain.recognize_media(meta=meta) + if not mediainfo: + logger.warn(f'未识别到媒体信息,标题:{title}') + continue + # 种子 + torrentinfo = TorrentInfo( + title=title, + description=description, + enclosure=enclosure, + page_url=link, + size=size, + pubdate=pubdate.strftime("%Y-%m-%d %H:%M:%S") if pubdate else None, + site_proxy=self._proxy, + ) + # 过滤种子 + if self._filter: + result = self.chain.filter_torrents( + rule_string=filter_rule, + torrent_list=[torrentinfo], + mediainfo=mediainfo + ) + if not result: + logger.info(f"{title} {description} 不匹配过滤规则") + continue + # 媒体库已存在的剧集 + exist_info: Optional[ExistMediaInfo] = self.chain.media_exists(mediainfo=mediainfo) + if mediainfo.type == MediaType.TV: + if exist_info: + exist_season = exist_info.seasons + if exist_season: + exist_episodes = exist_season.get(meta.begin_season) + if exist_episodes and set(meta.episode_list).issubset(set(exist_episodes)): + logger.info(f'{mediainfo.title_year} {meta.season_episode} 己存在') + continue + elif exist_info: + # 电影已存在 + logger.info(f'{mediainfo.title_year} 己存在') + continue + # 下载或订阅 + if self._action == "download": + # 添加下载 + result = self.downloadchain.download_single( + context=Context( + meta_info=meta, + media_info=mediainfo, + torrent_info=torrentinfo, + ), + save_path=self._save_path, + username="RSS订阅" + ) + if not result: + logger.error(f'{title} 下载失败') + continue + else: + # 检查是否在订阅中 + subflag = self.subscribechain.exists(mediainfo=mediainfo, meta=meta) + if subflag: + logger.info(f'{mediainfo.title_year} {meta.season} 正在订阅中') + continue + # 添加订阅 + self.subscribechain.add(title=mediainfo.title, + year=mediainfo.year, + mtype=mediainfo.type, + tmdbid=mediainfo.tmdb_id, + season=meta.begin_season, + exist_ok=True, + username="RSS订阅") + # 存储历史记录 + history.append({ + "title": f"{mediainfo.title} {meta.season}", + "key": f"{title}", + "type": mediainfo.type.value, + "year": mediainfo.year, + "poster": mediainfo.get_poster_image(), + "overview": mediainfo.overview, + "tmdbid": mediainfo.tmdb_id, + "time": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + }) + except Exception as err: + logger.error(f'刷新RSS数据出错:{str(err)} - {traceback.format_exc()}') + logger.info(f"RSS {url} 刷新完成") + # 保存历史记录 + self.save_data('history', history) + # 缓存只清理一次 + self._clearflag = False + + def __log_and_notify_error(self, message): + """ + 记录错误日志并发送系统通知 + """ + logger.error(message) + self.systemmessage.put(message, title="自定义订阅") + + def __validate_and_fix_config(self, config: dict = None) -> bool: + """ + 检查并修正配置值 + """ + size_range = config.get("size_range") + if size_range and not self.__is_number_or_range(str(size_range)): + self.__log_and_notify_error(f"自定义订阅出错,种子大小设置错误:{size_range}") + config["size_range"] = None + return False + return True + + @staticmethod + def __is_number_or_range(value): + """ + 检查字符串是否表示单个数字或数字范围(如'5', '5.5', '5-10' 或 '5.5-10.2') + """ + return bool(re.match(r"^\d+(\.\d+)?(-\d+(\.\d+)?)?$", value)) \ No newline at end of file diff --git a/plugins.v2/speedlimiter/__init__.py b/plugins.v2/speedlimiter/__init__.py new file mode 100644 index 0000000..517f683 --- /dev/null +++ b/plugins.v2/speedlimiter/__init__.py @@ -0,0 +1,660 @@ +import ipaddress +from typing import List, Tuple, Dict, Any + +from app.core.config import settings +from app.core.event import eventmanager, Event +from app.log import logger +from app.modules.emby import Emby +from app.modules.jellyfin import Jellyfin +from app.modules.plex import Plex +from app.modules.qbittorrent import Qbittorrent +from app.modules.transmission import Transmission +from app.plugins import _PluginBase +from app.schemas import NotificationType, WebhookEventInfo +from app.schemas.types import EventType +from app.utils.ip import IpUtils + + +class SpeedLimiter(_PluginBase): + # 插件名称 + plugin_name = "播放限速" + # 插件描述 + plugin_desc = "外网播放媒体库视频时,自动对下载器进行限速。" + # 插件图标 + plugin_icon = "Librespeed_A.png" + # 插件版本 + plugin_version = "1.2" + # 插件作者 + plugin_author = "Shurelol" + # 作者主页 + author_url = "https://github.com/Shurelol" + # 插件配置项ID前缀 + plugin_config_prefix = "speedlimit_" + # 加载顺序 + plugin_order = 11 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _scheduler = None + _qb = None + _tr = None + _enabled: bool = False + _notify: bool = False + _interval: int = 60 + _downloader: list = [] + _play_up_speed: float = 0 + _play_down_speed: float = 0 + _noplay_up_speed: float = 0 + _noplay_down_speed: float = 0 + _bandwidth: float = 0 + _allocation_ratio: str = "" + _auto_limit: bool = False + _limit_enabled: bool = False + # 不限速地址 + _unlimited_ips = {} + # 当前限速状态 + _current_state = "" + _exclude_path = "" + + def init_plugin(self, config: dict = None): + # 读取配置 + if config: + self._enabled = config.get("enabled") + self._notify = config.get("notify") + self._play_up_speed = float(config.get("play_up_speed")) if config.get("play_up_speed") else 0 + self._play_down_speed = float(config.get("play_down_speed")) if config.get("play_down_speed") else 0 + self._noplay_up_speed = float(config.get("noplay_up_speed")) if config.get("noplay_up_speed") else 0 + self._noplay_down_speed = float(config.get("noplay_down_speed")) if config.get("noplay_down_speed") else 0 + self._current_state = f"U:{self._noplay_up_speed},D:{self._noplay_down_speed}" + self._exclude_path = config.get("exclude_path") + + try: + # 总带宽 + self._bandwidth = int(float(config.get("bandwidth") or 0)) * 1000000 + # 自动限速开关 + if self._bandwidth > 0: + self._auto_limit = True + else: + self._auto_limit = False + except Exception as e: + logger.error(f"智能限速上行带宽设置错误:{str(e)}") + self._bandwidth = 0 + + # 限速服务开关 + self._limit_enabled = True if (self._play_up_speed + or self._play_down_speed + or self._auto_limit) else False + self._allocation_ratio = config.get("allocation_ratio") or "" + # 不限速地址 + self._unlimited_ips["ipv4"] = config.get("ipv4") or "" + self._unlimited_ips["ipv6"] = config.get("ipv6") or "" + + self._downloader = config.get("downloader") or [] + if self._downloader: + if 'qbittorrent' in self._downloader: + self._qb = Qbittorrent() + if 'transmission' in self._downloader: + self._tr = Transmission() + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + 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 and self._limit_enabled and self._interval: + return [ + { + "id": "SpeedLimiter", + "name": "播放限速检查服务", + "trigger": "interval", + "func": self.check_playing_sessions, + "kwargs": {"seconds": self._interval} + } + ] + return [] + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '发送通知', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'chips': True, + 'multiple': True, + 'model': 'downloader', + 'label': '下载器', + 'items': [ + {'title': 'Qbittorrent', 'value': 'qbittorrent'}, + {'title': 'Transmission', 'value': 'transmission'}, + ] + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'play_up_speed', + 'label': '播放限速(上传)', + 'placeholder': 'KB/s' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'play_down_speed', + 'label': '播放限速(下载)', + 'placeholder': 'KB/s' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'noplay_up_speed', + 'label': '未播放限速(上传)', + 'placeholder': 'KB/s' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'noplay_down_speed', + 'label': '未播放限速(下载)', + 'placeholder': 'KB/s' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'bandwidth', + 'label': '智能限速上行带宽', + 'placeholder': 'Mbps' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'allocation_ratio', + 'label': '智能限速分配比例', + 'items': [ + {'title': '平均', 'value': ''}, + {'title': '1:9', 'value': '1:9'}, + {'title': '2:8', 'value': '2:8'}, + {'title': '3:7', 'value': '3:7'}, + {'title': '4:6', 'value': '4:6'}, + {'title': '6:4', 'value': '6:4'}, + {'title': '7:3', 'value': '7:3'}, + {'title': '8:2', 'value': '8:2'}, + {'title': '9:1', 'value': '9:1'}, + ] + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'ipv4', + 'label': '不限速地址范围(ipv4)', + 'placeholder': '留空默认不限速内网ipv4' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'ipv6', + 'label': '不限速地址范围(ipv6)', + 'placeholder': '留空默认不限速内网ipv6' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'exclude_path', + 'label': '不限速路径', + 'placeholder': '包含该路径的媒体不限速,多个请换行' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "notify": True, + "downloader": [], + "play_up_speed": None, + "play_down_speed": None, + "noplay_up_speed": None, + "noplay_down_speed": None, + "bandwidth": None, + "allocation_ratio": "", + "ipv4": "", + "ipv6": "", + "exclude_path": "" + } + + def get_page(self) -> List[dict]: + pass + + @eventmanager.register(EventType.WebhookMessage) + def check_playing_sessions(self, event: Event = None): + """ + 检查播放会话 + """ + if not self._qb and not self._tr: + return + if not self._enabled: + return + if event: + event_data: WebhookEventInfo = event.event_data + if event_data.event not in [ + "playback.start", + "PlaybackStart", + "media.play", + "media.stop", + "PlaybackStop", + "playback.stop" + ]: + return + # 当前播放的总比特率 + total_bit_rate = 0 + # 媒体服务器类型,多个以,分隔 + if not settings.MEDIASERVER: + return + media_servers = settings.MEDIASERVER.split(',') + # 查询所有媒体服务器状态 + for media_server in media_servers: + # 查询播放中会话 + playing_sessions = [] + if media_server == "emby": + req_url = "[HOST]emby/Sessions?api_key=[APIKEY]" + try: + res = Emby().get_data(req_url) + if res and res.status_code == 200: + sessions = res.json() + for session in sessions: + if session.get("NowPlayingItem") and not session.get("PlayState", {}).get("IsPaused"): + if not self.__path_execluded(session.get("NowPlayingItem").get("Path")): + playing_sessions.append(session) + + except Exception as e: + logger.error(f"获取Emby播放会话失败:{str(e)}") + continue + # 计算有效比特率 + for session in playing_sessions: + # 设置了不限速范围则判断session ip是否在不限速范围内 + if self._unlimited_ips["ipv4"] or self._unlimited_ips["ipv6"]: + if not self.__allow_access(self._unlimited_ips, session.get("RemoteEndPoint")) \ + and session.get("NowPlayingItem", {}).get("MediaType") == "Video": + total_bit_rate += int(session.get("NowPlayingItem", {}).get("Bitrate") or 0) + # 未设置不限速范围,则默认不限速内网ip + elif not IpUtils.is_private_ip(session.get("RemoteEndPoint")) \ + and session.get("NowPlayingItem", {}).get("MediaType") == "Video": + total_bit_rate += int(session.get("NowPlayingItem", {}).get("Bitrate") or 0) + elif media_server == "jellyfin": + req_url = "[HOST]Sessions?api_key=[APIKEY]" + try: + res = Jellyfin().get_data(req_url) + if res and res.status_code == 200: + sessions = res.json() + for session in sessions: + if session.get("NowPlayingItem") and not session.get("PlayState", {}).get("IsPaused"): + if not self.__path_execluded(session.get("NowPlayingItem").get("Path")): + playing_sessions.append(session) + except Exception as e: + logger.error(f"获取Jellyfin播放会话失败:{str(e)}") + continue + # 计算有效比特率 + for session in playing_sessions: + # 设置了不限速范围则判断session ip是否在不限速范围内 + if self._unlimited_ips["ipv4"] or self._unlimited_ips["ipv6"]: + if not self.__allow_access(self._unlimited_ips, session.get("RemoteEndPoint")) \ + and session.get("NowPlayingItem", {}).get("MediaType") == "Video": + media_streams = session.get("NowPlayingItem", {}).get("MediaStreams") or [] + for media_stream in media_streams: + total_bit_rate += int(media_stream.get("BitRate") or 0) + # 未设置不限速范围,则默认不限速内网ip + elif not IpUtils.is_private_ip(session.get("RemoteEndPoint")) \ + and session.get("NowPlayingItem", {}).get("MediaType") == "Video": + media_streams = session.get("NowPlayingItem", {}).get("MediaStreams") or [] + for media_stream in media_streams: + total_bit_rate += int(media_stream.get("BitRate") or 0) + elif media_server == "plex": + _plex = Plex().get_plex() + if _plex: + sessions = _plex.sessions() + for session in sessions: + bitrate = sum([m.bitrate or 0 for m in session.media]) + playing_sessions.append({ + "type": session.TAG, + "bitrate": bitrate, + "address": session.player.address + }) + # 计算有效比特率 + for session in playing_sessions: + # 设置了不限速范围则判断session ip是否在不限速范围内 + if self._unlimited_ips["ipv4"] or self._unlimited_ips["ipv6"]: + if not self.__allow_access(self._unlimited_ips, session.get("address")) \ + and session.get("type") == "Video": + total_bit_rate += int(session.get("bitrate") or 0) + # 未设置不限速范围,则默认不限速内网ip + elif not IpUtils.is_private_ip(session.get("address")) \ + and session.get("type") == "Video": + total_bit_rate += int(session.get("bitrate") or 0) + + if total_bit_rate: + # 开启智能限速计算上传限速 + if self._auto_limit: + play_up_speed = self.__calc_limit(total_bit_rate) + else: + play_up_speed = self._play_up_speed + + # 当前正在播放,开始限速 + self.__set_limiter(limit_type="播放", upload_limit=play_up_speed, + download_limit=self._play_down_speed) + else: + # 当前没有播放,取消限速 + self.__set_limiter(limit_type="未播放", upload_limit=self._noplay_up_speed, + download_limit=self._noplay_down_speed) + + def __path_execluded(self, path: str) -> bool: + """ + 判断是否在不限速路径内 + """ + if self._exclude_path: + exclude_paths = self._exclude_path.split("\n") + for exclude_path in exclude_paths: + if exclude_path in path: + logger.info(f"{path} 在不限速路径:{exclude_path} 内,跳过限速") + return True + return False + + def __calc_limit(self, total_bit_rate: float) -> float: + """ + 计算智能上传限速 + """ + if not self._bandwidth: + return 10 + return round((self._bandwidth - total_bit_rate) / 8 / 1024, 2) + + def __set_limiter(self, limit_type: str, upload_limit: float, download_limit: float): + """ + 设置限速 + """ + if not self._qb and not self._tr: + return + state = f"U:{upload_limit},D:{download_limit}" + if self._current_state == state: + # 限速状态没有改变 + return + else: + self._current_state = state + + try: + cnt = 0 + for download in self._downloader: + if self._auto_limit and limit_type == "播放": + # 开启了播放智能限速 + if len(self._downloader) == 1: + # 只有一个下载器 + upload_limit = int(upload_limit) + else: + # 多个下载器 + if not self._allocation_ratio: + # 平均 + upload_limit = int(upload_limit / len(self._downloader)) + else: + # 按比例 + allocation_count = sum([int(i) for i in self._allocation_ratio.split(":")]) + upload_limit = int(upload_limit * int(self._allocation_ratio.split(":")[cnt]) / allocation_count) + cnt += 1 + if upload_limit: + text = f"上传:{upload_limit} KB/s" + else: + text = f"上传:未限速" + if download_limit: + text = f"{text}\n下载:{download_limit} KB/s" + else: + text = f"{text}\n下载:未限速" + if str(download) == 'qbittorrent': + if self._qb: + self._qb.set_speed_limit(download_limit=download_limit, upload_limit=upload_limit) + # 发送通知 + if self._notify: + title = "【播放限速】" + if upload_limit or download_limit: + subtitle = f"Qbittorrent 开始{limit_type}限速" + self.post_message( + mtype=NotificationType.MediaServer, + title=title, + text=f"{subtitle}\n{text}" + ) + else: + self.post_message( + mtype=NotificationType.MediaServer, + title=title, + text=f"Qbittorrent 已取消限速" + ) + else: + if self._tr: + self._tr.set_speed_limit(download_limit=download_limit, upload_limit=upload_limit) + # 发送通知 + if self._notify: + title = "【播放限速】" + if upload_limit or download_limit: + subtitle = f"Transmission 开始{limit_type}限速" + self.post_message( + mtype=NotificationType.MediaServer, + title=title, + text=f"{subtitle}\n{text}" + ) + else: + self.post_message( + mtype=NotificationType.MediaServer, + title=title, + text=f"Transmission 已取消限速" + ) + except Exception as e: + logger.error(f"设置限速失败:{str(e)}") + + @staticmethod + def __allow_access(allow_ips: dict, ip: str) -> bool: + """ + 判断IP是否合法 + :param allow_ips: 充许的IP范围 {"ipv4":, "ipv6":} + :param ip: 需要检查的ip + """ + if not allow_ips: + return True + try: + ipaddr = ipaddress.ip_address(ip) + if ipaddr.version == 4: + if not allow_ips.get('ipv4'): + return True + allow_ipv4s = allow_ips.get('ipv4').split(",") + for allow_ipv4 in allow_ipv4s: + if ipaddr in ipaddress.ip_network(allow_ipv4, strict=False): + return True + elif ipaddr.ipv4_mapped: + if not allow_ips.get('ipv4'): + return True + allow_ipv4s = allow_ips.get('ipv4').split(",") + for allow_ipv4 in allow_ipv4s: + if ipaddr.ipv4_mapped in ipaddress.ip_network(allow_ipv4, strict=False): + return True + else: + if not allow_ips.get('ipv6'): + return True + allow_ipv6s = allow_ips.get('ipv6').split(",") + for allow_ipv6 in allow_ipv6s: + if ipaddr in ipaddress.ip_network(allow_ipv6, strict=False): + return True + except Exception as err: + print(str(err)) + return False + return False + + def stop_service(self): + pass diff --git a/plugins.v2/syncdownloadfiles/__init__.py b/plugins.v2/syncdownloadfiles/__init__.py new file mode 100644 index 0000000..15c8a42 --- /dev/null +++ b/plugins.v2/syncdownloadfiles/__init__.py @@ -0,0 +1,579 @@ +import time +from datetime import datetime +from pathlib import Path +from typing import Any, List, Dict, Tuple, Optional + +from apscheduler.schedulers.background import BackgroundScheduler + +from app.core.config import settings +from app.db.downloadhistory_oper import DownloadHistoryOper +from app.db.transferhistory_oper import TransferHistoryOper +from app.log import logger +from app.modules.qbittorrent import Qbittorrent +from app.modules.transmission import Transmission +from app.plugins import _PluginBase + + +class SyncDownloadFiles(_PluginBase): + # 插件名称 + plugin_name = "下载器文件同步" + # 插件描述 + plugin_desc = "同步下载器的文件信息到数据库,删除文件时联动删除下载任务。" + # 插件图标 + plugin_icon = "Youtube-dl_A.png" + # 插件版本 + plugin_version = "1.1.1" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "syncdownloadfiles_" + # 加载顺序 + plugin_order = 20 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled = False + # 任务执行间隔 + _time = None + qb = None + tr = None + _onlyonce = False + _history = False + _clear = False + _downloaders = [] + _dirs = None + downloadhis = None + transferhis = None + + # 定时器 + _scheduler: Optional[BackgroundScheduler] = None + + def init_plugin(self, config: dict = None): + # 停止现有任务 + self.stop_service() + + self.qb = Qbittorrent() + self.tr = Transmission() + self.downloadhis = DownloadHistoryOper() + self.transferhis = TransferHistoryOper() + + if config: + self._enabled = config.get('enabled') + self._time = config.get('time') or 6 + self._history = config.get('history') + self._clear = config.get('clear') + self._onlyonce = config.get("onlyonce") + self._downloaders = config.get('downloaders') or [] + self._dirs = config.get("dirs") or "" + + if self._clear: + # 清理下载器文件记录 + self.downloadhis.truncate_files() + # 清理下载器最后处理记录 + for downloader in self._downloaders: + # 获取最后同步时间 + self.del_data(f"last_sync_time_{downloader}") + # 关闭clear + self._clear = False + self.__update_config() + + if self._onlyonce: + # 执行一次 + # 关闭onlyonce + self._onlyonce = False + self.__update_config() + + self.sync() + + def sync(self): + """ + 同步所选下载器种子记录 + """ + start_time = datetime.now() + logger.info("开始同步下载器任务文件记录") + + if not self._downloaders: + logger.error("未选择同步下载器,停止运行") + return + + # 遍历下载器同步记录 + for downloader in self._downloaders: + # 获取最后同步时间 + last_sync_time = self.get_data(f"last_sync_time_{downloader}") + + logger.info(f"开始扫描下载器 {downloader} ...") + downloader_obj = self.__get_downloader(downloader) + # 获取下载器中已完成的种子 + torrents = downloader_obj.get_completed_torrents() + if torrents: + logger.info(f"下载器 {downloader} 已完成种子数:{len(torrents)}") + else: + logger.info(f"下载器 {downloader} 没有已完成种子") + continue + + # 把种子按照名称和种子大小分组,获取添加时间最早的一个,认定为是源种子,其余为辅种 + torrents = self.__get_origin_torrents(torrents, downloader) + logger.info(f"下载器 {downloader} 去除辅种,获取到源种子数:{len(torrents)}") + + for torrent in torrents: + # 返回false,标识后续种子已被同步 + sync_flag = self.__compare_time(torrent, downloader, last_sync_time) + + if not sync_flag: + logger.info(f"最后同步时间{last_sync_time}, 之前种子已被同步,结束当前下载器 {downloader} 任务") + break + + # 获取种子hash + hash_str = self.__get_hash(torrent, downloader) + + # 判断是否是mp下载,判断download_hash是否在downloadhistory表中,是则不处理 + downloadhis = self.downloadhis.get_by_hash(hash_str) + if downloadhis: + downlod_files = self.downloadhis.get_files_by_hash(hash_str) + if downlod_files: + logger.info(f"种子 {hash_str} 通过MoviePilot下载,跳过处理") + continue + + # 获取种子download_dir + download_dir = self.__get_download_dir(torrent, downloader) + + # 处理路径映射 + if self._dirs: + paths = self._dirs.split("\n") + for path in paths: + sub_paths = path.split(":") + download_dir = download_dir.replace(sub_paths[0], sub_paths[1]).replace('\\', '/') + + # 获取种子name + torrent_name = self.__get_torrent_name(torrent, downloader) + # 种子保存目录 + save_path = Path(download_dir).joinpath(torrent_name) + # 获取种子文件 + torrent_files = self.__get_torrent_files(torrent, downloader, downloader_obj) + logger.info(f"开始同步种子 {hash_str}, 文件数 {len(torrent_files)}") + + download_files = [] + for file in torrent_files: + # 过滤掉没下载的文件 + if not self.__is_download(file, downloader): + continue + # 种子文件路径 + file_path_str = self.__get_file_path(file, downloader) + file_path = Path(file_path_str) + # 只处理视频格式 + if not file_path.suffix \ + or file_path.suffix not in settings.RMT_MEDIAEXT: + continue + # 种子文件根路程 + root_path = file_path.parts[0] + # 不含种子名称的种子文件相对路径 + if root_path == torrent_name: + rel_path = str(file_path.relative_to(root_path)) + else: + rel_path = str(file_path) + # 完整路径 + full_path = save_path.joinpath(rel_path) + if self._history: + transferhis = self.transferhis.get_by_src(str(full_path)) + if transferhis and not transferhis.download_hash: + logger.info(f"开始补充转移记录:{transferhis.id} download_hash {hash_str}") + self.transferhis.update_download_hash(historyid=transferhis.id, + download_hash=hash_str) + + # 种子文件记录 + download_files.append( + { + "download_hash": hash_str, + "downloader": downloader, + "fullpath": str(full_path), + "savepath": str(save_path), + "filepath": rel_path, + "torrentname": torrent_name, + } + ) + + if download_files: + # 登记下载文件 + self.downloadhis.add_files(download_files) + logger.info(f"种子 {hash_str} 同步完成") + + logger.info(f"下载器种子文件同步完成!") + self.save_data(f"last_sync_time_{downloader}", + time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))) + + # 计算耗时 + end_time = datetime.now() + + logger.info(f"下载器任务文件记录已同步完成。总耗时 {(end_time - start_time).seconds} 秒") + + def __update_config(self): + self.update_config({ + "enabled": self._enabled, + "time": self._time, + "history": self._history, + "clear": self._clear, + "onlyonce": self._onlyonce, + "downloaders": self._downloaders, + "dirs": self._dirs + }) + + @staticmethod + def __get_origin_torrents(torrents: Any, dl_tpe: str): + # 把种子按照名称和种子大小分组,获取添加时间最早的一个,认定为是源种子,其余为辅种 + grouped_data = {} + + # 排序种子,根据种子添加时间倒序 + if dl_tpe == "qbittorrent": + torrents = sorted(torrents, key=lambda x: x.get("added_on"), reverse=True) + # 遍历原始数组,按照size和name进行分组 + for torrent in torrents: + size = torrent.get('size') + name = torrent.get('name') + key = (size, name) # 使用元组作为字典的键 + + # 如果分组键不存在,则将当前元素作为最小元素添加到字典中 + if key not in grouped_data: + grouped_data[key] = torrent + else: + # 如果分组键已存在,则比较当前元素的time是否更小,如果更小则更新字典中的元素 + if torrent.get('added_on') < grouped_data[key].get('added_on'): + grouped_data[key] = torrent + else: + torrents = sorted(torrents, key=lambda x: x.added_date, reverse=True) + # 遍历原始数组,按照size和name进行分组 + for torrent in torrents: + size = torrent.total_size + name = torrent.name + key = (size, name) # 使用元组作为字典的键 + + # 如果分组键不存在,则将当前元素作为最小元素添加到字典中 + if key not in grouped_data: + grouped_data[key] = torrent + else: + # 如果分组键已存在,则比较当前元素的time是否更小,如果更小则更新字典中的元素 + if torrent.added_date < grouped_data[key].added_date: + grouped_data[key] = torrent + + # 新的数组 + return list(grouped_data.values()) + + @staticmethod + def __compare_time(torrent: Any, dl_tpe: str, last_sync_time: str = None): + if last_sync_time: + # 获取种子时间 + if dl_tpe == "qbittorrent": + torrent_date = time.localtime(torrent.get("added_on")) # 将时间戳转换为时间元组 + torrent_date = time.strftime("%Y-%m-%d %H:%M:%S", torrent_date) # 格式化时间 + else: + torrent_date = torrent.added_date + + # 之后的种子已经同步了 + if last_sync_time > str(torrent_date): + return False + + return True + + @staticmethod + def __is_download(file: Any, dl_type: str): + """ + 判断文件是否被下载 + """ + try: + if dl_type == "qbittorrent": + return True + else: + return file.completed and file.completed > 0 + except Exception as e: + print(str(e)) + return True + + @staticmethod + def __get_file_path(file: Any, dl_type: str): + """ + 获取文件路径 + """ + try: + return file.get("name") if dl_type == "qbittorrent" else file.name + except Exception as e: + print(str(e)) + return "" + + @staticmethod + def __get_torrent_files(torrent: Any, dl_type: str, downloader_obj): + """ + 获取种子文件 + """ + try: + return torrent.files if dl_type == "qbittorrent" else downloader_obj.get_files(tid=torrent.id) + except Exception as e: + print(str(e)) + return "" + + @staticmethod + def __get_torrent_name(torrent: Any, dl_type: str): + """ + 获取种子name + """ + try: + return torrent.get("name") if dl_type == "qbittorrent" else torrent.name + except Exception as e: + print(str(e)) + return "" + + @staticmethod + def __get_download_dir(torrent: Any, dl_type: str): + """ + 获取种子download_dir + """ + try: + return torrent.get("save_path") if dl_type == "qbittorrent" else torrent.download_dir + except Exception as e: + print(str(e)) + return "" + + @staticmethod + def __get_hash(torrent: Any, dl_type: str): + """ + 获取种子hash + """ + try: + return torrent.get("hash") if dl_type == "qbittorrent" else torrent.hashString + except Exception as e: + print(str(e)) + return "" + + def __get_downloader(self, dtype: str): + """ + 根据类型返回下载器实例 + """ + if dtype == "qbittorrent": + return self.qb + elif dtype == "transmission": + return self.tr + else: + return None + + def get_state(self) -> bool: + return True if self._enabled and self._time else False + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + 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.get_state(): + return [{ + "id": "SyncDownloadFiles", + "name": "同步下载器文件记录服务", + "trigger": "interval", + "func": self.sync, + "kwargs": {"seconds": float(str(self._time).strip()) * 3600} + }] + return [] + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '开启插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'history', + 'label': '补充整理历史记录', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'clear', + 'label': '清理数据', + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'time', + 'label': '同步时间间隔(小时)' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'chips': True, + 'multiple': True, + 'model': 'downloaders', + 'label': '同步下载器', + 'items': [ + {'title': 'Qbittorrent', 'value': 'qbittorrent'}, + {'title': 'Transmission', 'value': 'transmission'} + ] + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'dirs', + 'label': '目录映射', + 'rows': 5, + 'placeholder': '每一行一个目录,下载器保存目录:MoviePilot映射目录' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '适用于非MoviePilot下载的任务;下载器种子数据较多时,同步时间将会较长,请耐心等候,可查看实时日志了解同步进度;时间间隔建议最少每6小时执行一次,防止上次任务没处理完。' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "onlyonce": False, + "history": False, + "clear": False, + "time": 6, + "dirs": "", + "downloaders": [] + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) diff --git a/plugins.v2/torrentremover/__init__.py b/plugins.v2/torrentremover/__init__.py new file mode 100644 index 0000000..73848e0 --- /dev/null +++ b/plugins.v2/torrentremover/__init__.py @@ -0,0 +1,816 @@ +import re +import threading +import time +from datetime import datetime, timedelta +from typing import List, Tuple, Dict, Any, Optional + +import pytz +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from app.core.config import settings +from app.log import logger +from app.modules.qbittorrent import Qbittorrent +from app.modules.transmission import Transmission +from app.plugins import _PluginBase +from app.schemas import NotificationType +from app.utils.string import StringUtils + +lock = threading.Lock() + + +class TorrentRemover(_PluginBase): + # 插件名称 + plugin_name = "自动删种" + # 插件描述 + plugin_desc = "自动删除下载器中的下载任务。" + # 插件图标 + plugin_icon = "delete.jpg" + # 插件版本 + plugin_version = "1.2.2" + # 插件作者 + plugin_author = "jxxghp" + # 作者主页 + author_url = "https://github.com/jxxghp" + # 插件配置项ID前缀 + plugin_config_prefix = "torrentremover_" + # 加载顺序 + plugin_order = 8 + # 可使用的用户级别 + auth_level = 2 + + # 私有属性 + qb = None + tr = None + _event = threading.Event() + _scheduler = None + _enabled = False + _onlyonce = False + _notify = False + # pause/delete + _downloaders = [] + _action = "pause" + _cron = None + _samedata = False + _mponly = False + _size = None + _ratio = None + _time = None + _upspeed = None + _labels = None + _pathkeywords = None + _trackerkeywords = None + _errorkeywords = None + _torrentstates = None + _torrentcategorys = None + + def init_plugin(self, config: dict = None): + if config: + self._enabled = config.get("enabled") + self._onlyonce = config.get("onlyonce") + self._notify = config.get("notify") + self._downloaders = config.get("downloaders") or [] + self._action = config.get("action") + self._cron = config.get("cron") + self._samedata = config.get("samedata") + self._mponly = config.get("mponly") + self._size = config.get("size") or "" + self._ratio = config.get("ratio") + self._time = config.get("time") + self._upspeed = config.get("upspeed") + self._labels = config.get("labels") or "" + self._pathkeywords = config.get("pathkeywords") or "" + self._trackerkeywords = config.get("trackerkeywords") or "" + self._errorkeywords = config.get("errorkeywords") or "" + self._torrentstates = config.get("torrentstates") or "" + self._torrentcategorys = config.get("torrentcategorys") or "" + + self.stop_service() + + if self.get_state() or self._onlyonce: + self.qb = Qbittorrent() + self.tr = Transmission() + if self._onlyonce: + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + logger.info(f"自动删种服务启动,立即运行一次") + self._scheduler.add_job(func=self.delete_torrents, trigger='date', + run_date=datetime.now( + tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3) + ) + # 关闭一次性开关 + self._onlyonce = False + # 保存设置 + self.update_config({ + "enabled": self._enabled, + "notify": self._notify, + "onlyonce": self._onlyonce, + "action": self._action, + "cron": self._cron, + "downloaders": self._downloaders, + "samedata": self._samedata, + "mponly": self._mponly, + "size": self._size, + "ratio": self._ratio, + "time": self._time, + "upspeed": self._upspeed, + "labels": self._labels, + "pathkeywords": self._pathkeywords, + "trackerkeywords": self._trackerkeywords, + "errorkeywords": self._errorkeywords, + "torrentstates": self._torrentstates, + "torrentcategorys": self._torrentcategorys + + }) + if self._scheduler.get_jobs(): + # 启动服务 + self._scheduler.print_jobs() + self._scheduler.start() + + def get_state(self) -> bool: + return True if self._enabled and self._cron and self._downloaders else False + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + 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.get_state(): + return [{ + "id": "TorrentRemover", + "name": "自动删种服务", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self.delete_torrents, + "kwargs": {} + }] + return [] + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '发送通知', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '执行周期', + 'placeholder': '0 */12 * * *' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'action', + 'label': '动作', + 'items': [ + {'title': '暂停', 'value': 'pause'}, + {'title': '删除种子', 'value': 'delete'}, + {'title': '删除种子和文件', 'value': 'deletefile'} + ] + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'chips': True, + 'multiple': True, + 'model': 'downloaders', + 'label': '下载器', + 'items': [ + {'title': 'Qbittorrent', 'value': 'qbittorrent'}, + {'title': 'Transmission', 'value': 'transmission'} + ] + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'size', + 'label': '种子大小(GB)', + 'placeholder': '例如1-10' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'ratio', + 'label': '分享率', + 'placeholder': '' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'time', + 'label': '做种时间(小时)', + 'placeholder': '' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'upspeed', + 'label': '平均上传速度', + 'placeholder': '' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'labels', + 'label': '标签', + 'placeholder': '用,分隔多个标签' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'pathkeywords', + 'label': '保存路径关键词', + 'placeholder': '支持正式表达式' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'trackerkeywords', + 'label': 'Tracker关键词', + 'placeholder': '支持正式表达式' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'errorkeywords', + 'label': '错误信息关键词(TR)', + 'placeholder': '支持正式表达式,仅适用于TR' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'torrentstates', + 'label': '任务状态(QB)', + 'placeholder': '用,分隔多个状态,仅适用于QB' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'torrentcategorys', + 'label': '任务分类', + 'placeholder': '用,分隔多个分类' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'samedata', + 'label': '处理辅种', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'mponly', + 'label': '仅MoviePilot任务', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '自动删种存在风险,如设置不当可能导致数据丢失!建议动作先选择暂停,确定条件正确后再改成删除。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '任务状态(QB)字典:' + 'downloading:正在下载-传输数据,' + 'stalledDL:正在下载_未建立连接,' + 'uploading:正在上传-传输数据,' + 'stalledUP:正在上传-未建立连接,' + 'error:暂停-发生错误,' + 'pausedDL:暂停-下载未完成,' + 'pausedUP:暂停-下载完成,' + 'missingFiles:暂停-文件丢失,' + 'checkingDL:检查中-下载未完成,' + 'checkingUP:检查中-下载完成,' + 'checkingResumeData:检查中-启动时恢复数据,' + 'forcedDL:强制下载-忽略队列,' + 'queuedDL:等待下载-排队,' + 'forcedUP:强制上传-忽略队列,' + 'queuedUP:等待上传-排队,' + 'allocating:分配磁盘空间,' + 'metaDL:获取元数据,' + 'moving:移动文件,' + 'unknown:未知状态' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "notify": False, + "onlyonce": False, + "action": 'pause', + 'downloaders': [], + "cron": '0 */12 * * *', + "samedata": False, + "mponly": False, + "size": "", + "ratio": "", + "time": "", + "upspeed": "", + "labels": "", + "pathkeywords": "", + "trackerkeywords": "", + "errorkeywords": "", + "torrentstates": "", + "torrentcategorys": "" + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._event.set() + self._scheduler.shutdown() + self._event.clear() + self._scheduler = None + except Exception as e: + print(str(e)) + + def __get_downloader(self, dtype: str): + """ + 根据类型返回下载器实例 + """ + if dtype == "qbittorrent": + return self.qb + elif dtype == "transmission": + return self.tr + else: + return None + + def delete_torrents(self): + """ + 定时删除下载器中的下载任务 + """ + for downloader in self._downloaders: + try: + with lock: + # 获取需删除种子列表 + torrents = self.get_remove_torrents(downloader) + logger.info(f"自动删种任务 获取符合处理条件种子数 {len(torrents)}") + # 下载器 + downlader_obj = self.__get_downloader(downloader) + if self._action == "pause": + message_text = f"{downloader.title()} 共暂停{len(torrents)}个种子" + for torrent in torrents: + if self._event.is_set(): + logger.info(f"自动删种服务停止") + return + text_item = f"{torrent.get('name')} " \ + f"来自站点:{torrent.get('site')} " \ + f"大小:{StringUtils.str_filesize(torrent.get('size'))}" + # 暂停种子 + downlader_obj.stop_torrents(ids=[torrent.get("id")]) + logger.info(f"自动删种任务 暂停种子:{text_item}") + message_text = f"{message_text}\n{text_item}" + elif self._action == "delete": + message_text = f"{downloader.title()} 共删除{len(torrents)}个种子" + for torrent in torrents: + if self._event.is_set(): + logger.info(f"自动删种服务停止") + return + text_item = f"{torrent.get('name')} " \ + f"来自站点:{torrent.get('site')} " \ + f"大小:{StringUtils.str_filesize(torrent.get('size'))}" + # 删除种子 + downlader_obj.delete_torrents(delete_file=False, + ids=[torrent.get("id")]) + logger.info(f"自动删种任务 删除种子:{text_item}") + message_text = f"{message_text}\n{text_item}" + elif self._action == "deletefile": + message_text = f"{downloader.title()} 共删除{len(torrents)}个种子及文件" + for torrent in torrents: + if self._event.is_set(): + logger.info(f"自动删种服务停止") + return + text_item = f"{torrent.get('name')} " \ + f"来自站点:{torrent.get('site')} " \ + f"大小:{StringUtils.str_filesize(torrent.get('size'))}" + # 删除种子 + downlader_obj.delete_torrents(delete_file=True, + ids=[torrent.get("id")]) + logger.info(f"自动删种任务 删除种子及文件:{text_item}") + message_text = f"{message_text}\n{text_item}" + else: + continue + if torrents and message_text and self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【自动删种任务完成】", + text=message_text + ) + except Exception as e: + logger.error(f"自动删种任务异常:{str(e)}") + + def __get_qb_torrent(self, torrent: Any) -> Optional[dict]: + """ + 检查QB下载任务是否符合条件 + """ + # 完成时间 + date_done = torrent.completion_on if torrent.completion_on > 0 else torrent.added_on + # 现在时间 + date_now = int(time.mktime(datetime.now().timetuple())) + # 做种时间 + torrent_seeding_time = date_now - date_done if date_done else 0 + # 平均上传速度 + torrent_upload_avs = torrent.uploaded / torrent_seeding_time if torrent_seeding_time else 0 + # 大小 单位:GB + sizes = self._size.split('-') if self._size else [] + minsize = float(sizes[0]) * 1024 * 1024 * 1024 if sizes else 0 + maxsize = float(sizes[-1]) * 1024 * 1024 * 1024 if sizes else 0 + # 分享率 + if self._ratio and torrent.ratio <= float(self._ratio): + return None + # 做种时间 单位:小时 + if self._time and torrent_seeding_time <= float(self._time) * 3600: + return None + # 文件大小 + if self._size and (torrent.size >= int(maxsize) or torrent.size <= int(minsize)): + return None + if self._upspeed and torrent_upload_avs >= float(self._upspeed) * 1024: + return None + if self._pathkeywords and not re.findall(self._pathkeywords, torrent.save_path, re.I): + return None + if self._trackerkeywords and not re.findall(self._trackerkeywords, torrent.tracker, re.I): + return None + if self._torrentstates and torrent.state not in self._torrentstates: + return None + if self._torrentcategorys and (not torrent.category or torrent.category not in self._torrentcategorys): + return None + return { + "id": torrent.hash, + "name": torrent.name, + "site": StringUtils.get_url_sld(torrent.tracker), + "size": torrent.size + } + + def __get_tr_torrent(self, torrent: Any) -> Optional[dict]: + """ + 检查TR下载任务是否符合条件 + """ + # 完成时间 + date_done = torrent.date_done or torrent.date_added + # 现在时间 + date_now = int(time.mktime(datetime.now().timetuple())) + # 做种时间 + torrent_seeding_time = date_now - int(time.mktime(date_done.timetuple())) if date_done else 0 + # 上传量 + torrent_uploaded = torrent.ratio * torrent.total_size + # 平均上传速茺 + torrent_upload_avs = torrent_uploaded / torrent_seeding_time if torrent_seeding_time else 0 + # 大小 单位:GB + sizes = self._size.split('-') if self._size else [] + minsize = float(sizes[0]) * 1024 * 1024 * 1024 if sizes else 0 + maxsize = float(sizes[-1]) * 1024 * 1024 * 1024 if sizes else 0 + # 分享率 + if self._ratio and torrent.ratio <= float(self._ratio): + return None + if self._time and torrent_seeding_time <= float(self._time) * 3600: + return None + if self._size and (torrent.total_size >= int(maxsize) or torrent.total_size <= int(minsize)): + return None + if self._upspeed and torrent_upload_avs >= float(self._upspeed) * 1024: + return None + if self._pathkeywords and not re.findall(self._pathkeywords, torrent.download_dir, re.I): + return None + if self._trackerkeywords: + if not torrent.trackers: + return None + else: + tacker_key_flag = False + for tracker in torrent.trackers: + if re.findall(self._trackerkeywords, tracker.get("announce", ""), re.I): + tacker_key_flag = True + break + if not tacker_key_flag: + return None + if self._errorkeywords and not re.findall(self._errorkeywords, torrent.error_string, re.I): + return None + return { + "id": torrent.hashString, + "name": torrent.name, + "site": torrent.trackers[0].get("sitename") if torrent.trackers else "", + "size": torrent.total_size + } + + def get_remove_torrents(self, downloader: str): + """ + 获取自动删种任务种子 + """ + remove_torrents = [] + # 下载器对象 + downloader_obj = self.__get_downloader(downloader) + # 标题 + if self._labels: + tags = self._labels.split(',') + else: + tags = [] + if self._mponly: + tags.append(settings.TORRENT_TAG) + # 查询种子 + torrents, error_flag = downloader_obj.get_torrents(tags=tags or None) + if error_flag: + return [] + # 处理种子 + for torrent in torrents: + if downloader == "qbittorrent": + item = self.__get_qb_torrent(torrent) + else: + item = self.__get_tr_torrent(torrent) + if not item: + continue + remove_torrents.append(item) + # 处理辅种 + if self._samedata and remove_torrents: + remove_ids = [t.get("id") for t in remove_torrents] + remove_torrents_plus = [] + for remove_torrent in remove_torrents: + name = remove_torrent.get("name") + size = remove_torrent.get("size") + for torrent in torrents: + if downloader == "qbittorrent": + plus_id = torrent.hash + plus_name = torrent.name + plus_size = torrent.size + plus_site = StringUtils.get_url_sld(torrent.tracker) + else: + plus_id = torrent.hashString + plus_name = torrent.name + plus_size = torrent.total_size + plus_site = torrent.trackers[0].get("sitename") if torrent.trackers else "" + # 比对名称和大小 + if plus_name == name \ + and plus_size == size \ + and plus_id not in remove_ids: + remove_torrents_plus.append( + { + "id": plus_id, + "name": plus_name, + "site": plus_site, + "size": plus_size + } + ) + if remove_torrents_plus: + remove_torrents.extend(remove_torrents_plus) + return remove_torrents diff --git a/plugins.v2/torrenttransfer/__init__.py b/plugins.v2/torrenttransfer/__init__.py new file mode 100644 index 0000000..fa22714 --- /dev/null +++ b/plugins.v2/torrenttransfer/__init__.py @@ -0,0 +1,932 @@ +import os +from datetime import datetime, timedelta +from pathlib import Path +from threading import Event +from typing import Any, List, Dict, Tuple, Optional + +import pytz +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from bencode import bdecode, bencode + +from app.core.config import settings +from app.helper.torrent import TorrentHelper +from app.log import logger +from app.modules.qbittorrent import Qbittorrent +from app.modules.transmission import Transmission +from app.plugins import _PluginBase +from app.schemas import NotificationType +from app.utils.string import StringUtils + + +class TorrentTransfer(_PluginBase): + # 插件名称 + plugin_name = "自动转移做种" + # 插件描述 + plugin_desc = "定期转移下载器中的做种任务到另一个下载器。" + # 插件图标 + plugin_icon = "seed.png" + # 插件版本 + plugin_version = "1.5" + # 插件作者 + plugin_author = "jxxghp" + # 作者主页 + author_url = "https://github.com/jxxghp" + # 插件配置项ID前缀 + plugin_config_prefix = "torrenttransfer_" + # 加载顺序 + plugin_order = 18 + # 可使用的用户级别 + auth_level = 2 + + # 私有属性 + _scheduler = None + qb = None + tr = None + torrent = None + # 开关 + _enabled = False + _cron = None + _onlyonce = False + _fromdownloader = None + _todownloader = None + _frompath = None + _topath = None + _notify = False + _nolabels = None + _includelabels = None + _nopaths = None + _deletesource = False + _deleteduplicate = False + _fromtorrentpath = None + _autostart = False + _transferemptylabel = False + # 退出事件 + _event = Event() + # 待检查种子清单 + _recheck_torrents = {} + _is_recheck_running = False + # 任务标签 + _torrent_tags = ["已整理", "转移做种"] + + def init_plugin(self, config: dict = None): + self.torrent = TorrentHelper() + # 读取配置 + if config: + self._enabled = config.get("enabled") + self._onlyonce = config.get("onlyonce") + self._cron = config.get("cron") + self._notify = config.get("notify") + self._nolabels = config.get("nolabels") + self._includelabels = config.get("includelabels") + self._frompath = config.get("frompath") + self._topath = config.get("topath") + self._fromdownloader = config.get("fromdownloader") + self._todownloader = config.get("todownloader") + self._deletesource = config.get("deletesource") + self._deleteduplicate = config.get("deleteduplicate") + self._fromtorrentpath = config.get("fromtorrentpath") + self._nopaths = config.get("nopaths") + self._autostart = config.get("autostart") + self._transferemptylabel = config.get("transferemptylabel") + + # 停止现有任务 + self.stop_service() + + # 启动定时任务 & 立即运行一次 + if self.get_state() or self._onlyonce: + self.qb = Qbittorrent() + self.tr = Transmission() + # 检查配置 + if self._fromtorrentpath and not Path(self._fromtorrentpath).exists(): + logger.error(f"源下载器种子文件保存路径不存在:{self._fromtorrentpath}") + self.systemmessage.put(f"源下载器种子文件保存路径不存在:{self._fromtorrentpath}", title="自动转移做种") + return + if self._fromdownloader == self._todownloader: + logger.error(f"源下载器和目的下载器不能相同") + self.systemmessage.put(f"源下载器和目的下载器不能相同", title="自动转移做种") + return + + # 定时服务 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + if self._autostart: + # 追加种子校验服务 + self._scheduler.add_job(self.check_recheck, 'interval', minutes=3) + + if self._onlyonce: + logger.info(f"转移做种服务启动,立即运行一次") + self._scheduler.add_job(self.transfer, 'date', + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta( + seconds=3)) + # 关闭一次性开关 + self._onlyonce = False + self.update_config({ + "enabled": self._enabled, + "onlyonce": self._onlyonce, + "cron": self._cron, + "notify": self._notify, + "nolabels": self._nolabels, + "includelabels": self._includelabels, + "frompath": self._frompath, + "topath": self._topath, + "fromdownloader": self._fromdownloader, + "todownloader": self._todownloader, + "deletesource": self._deletesource, + "deleteduplicate": self._deleteduplicate, + "fromtorrentpath": self._fromtorrentpath, + "nopaths": self._nopaths, + "autostart": self._autostart, + "transferemptylabel": self._transferemptylabel + }) + + # 启动服务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def get_state(self): + return True if self._enabled \ + and self._cron \ + and self._fromdownloader \ + and self._todownloader \ + and self._fromtorrentpath else False + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + 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.get_state(): + return [ + { + "id": "TorrentTransfer", + "name": "转移做种服务", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self.transfer, + "kwargs": {} + } + ] + return [] + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '发送通知', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'transferemptylabel', + 'label': '转移无标签种子', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '执行周期', + 'placeholder': '0 0 0 ? *' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'nolabels', + 'label': '不转移种子标签', + } + } + ] + }, { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'includelabels', + 'label': '转移种子标签', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'fromdownloader', + 'label': '源下载器', + 'items': [ + {'title': 'Qbittorrent', 'value': 'qbittorrent'}, + {'title': 'Transmission', 'value': 'transmission'} + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'fromtorrentpath', + 'label': '源下载器种子文件路径', + 'placeholder': 'BT_backup、torrents' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'frompath', + 'label': '源数据文件根路径', + 'placeholder': '根路径,留空不进行路径转换' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'todownloader', + 'label': '目的下载器', + 'items': [ + {'title': 'Qbittorrent', 'value': 'qbittorrent'}, + {'title': 'Transmission', 'value': 'transmission'} + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'topath', + 'label': '目的数据文件根路径', + 'placeholder': '根路径,留空不进行路径转换' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'nopaths', + 'label': '不转移数据文件目录', + 'rows': 3, + 'placeholder': '每一行一个目录' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'autostart', + 'label': '校验完成后自动开始', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'deletesource', + 'label': '删除源种子', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'deleteduplicate', + 'label': '删除重复种子', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "notify": False, + "onlyonce": False, + "cron": "", + "nolabels": "", + "includelabels": "", + "frompath": "", + "topath": "", + "fromdownloader": "", + "todownloader": "", + "deletesource": False, + "deleteduplicate": False, + "fromtorrentpath": "", + "nopaths": "", + "autostart": True, + "transferemptylabel": False + } + + def get_page(self) -> List[dict]: + pass + + def __get_downloader(self, dtype: str): + """ + 根据类型返回下载器实例 + """ + if dtype == "qbittorrent": + return self.qb + elif dtype == "transmission": + return self.tr + else: + return None + + def __download(self, downloader: str, content: bytes, + save_path: str) -> Optional[str]: + """ + 添加下载任务 + """ + if downloader == "qbittorrent": + # 生成随机Tag + tag = StringUtils.generate_random_str(10) + state = self.qb.add_torrent(content=content, + download_dir=save_path, + is_paused=True, + tag=["已整理", "转移做种", tag]) + if not state: + return None + else: + # 获取种子Hash + torrent_hash = self.qb.get_torrent_id_by_tag(tags=tag) + if not torrent_hash: + logger.error(f"{downloader} 下载任务添加成功,但获取任务信息失败!") + return None + return torrent_hash + elif downloader == "transmission": + # 添加任务 + torrent = self.tr.add_torrent(content=content, + download_dir=save_path, + is_paused=True, + labels=["已整理", "转移做种"]) + if not torrent: + return None + else: + return torrent.hashString + + logger.error(f"不支持的下载器:{downloader}") + return None + + def transfer(self): + """ + 开始转移做种 + """ + logger.info("开始转移做种任务 ...") + + # 源下载器 + downloader = self._fromdownloader + # 目的下载器 + todownloader = self._todownloader + + # 获取下载器中已完成的种子 + downloader_obj = self.__get_downloader(downloader) + torrents = downloader_obj.get_completed_torrents() + if torrents: + logger.info(f"下载器 {downloader} 已完成种子数:{len(torrents)}") + else: + logger.info(f"下载器 {downloader} 没有已完成种子") + return + + # 过滤种子,记录保存目录 + trans_torrents = [] + for torrent in torrents: + if self._event.is_set(): + logger.info(f"转移服务停止") + return + + # 获取种子hash + hash_str = self.__get_hash(torrent, downloader) + # 获取保存路径 + save_path = self.__get_save_path(torrent, downloader) + + if self._nopaths and save_path: + # 过滤不需要转移的路径 + nopath_skip = False + for nopath in self._nopaths.split('\n'): + if os.path.normpath(save_path).startswith(os.path.normpath(nopath)): + logger.info(f"种子 {hash_str} 保存路径 {save_path} 不需要转移,跳过 ...") + nopath_skip = True + break + if nopath_skip: + continue + + # 获取种子标签 + torrent_labels = self.__get_label(torrent, downloader) + + # 种子为无标签,则进行规范化 + is_torrent_labels_empty = torrent_labels == [''] or torrent_labels == [] or torrent_labels is None + if is_torrent_labels_empty: + torrent_labels = [] + + #根据设置决定是否转移无标签的种子 + if is_torrent_labels_empty: + if not self._transferemptylabel: + continue + else: + # 排除含有不转移的标签 + if self._nolabels: + is_skip = False + for label in self._nolabels.split(','): + if label in torrent_labels: + logger.info(f"种子 {hash_str} 含有不转移标签 {label},跳过 ...") + is_skip = True + break + if is_skip: + continue + # 排除不含有转移标签的种子 + if self._includelabels: + is_skip = False + for label in self._includelabels.split(','): + if label not in torrent_labels: + logger.info(f"种子 {hash_str} 不含有转移标签 {label},跳过 ...") + is_skip = True + break + if is_skip: + continue + + # 添加转移数据 + trans_torrents.append({ + "hash": hash_str, + "save_path": save_path, + "torrent": torrent + }) + + # 开始转移任务 + if trans_torrents: + logger.info(f"需要转移的种子数:{len(trans_torrents)}") + # 记数 + total = len(trans_torrents) + # 总成功数 + success = 0 + # 总失败数 + fail = 0 + # 跳过数 + skip = 0 + # 删除重复数 + del_dup = 0 + + for torrent_item in trans_torrents: + # 检查种子文件是否存在 + torrent_file = Path(self._fromtorrentpath) / f"{torrent_item.get('hash')}.torrent" + if not torrent_file.exists(): + logger.error(f"种子文件不存在:{torrent_file}") + # 失败计数 + fail += 1 + continue + + # 查询hash值是否已经在目的下载器中 + todownloader_obj = self.__get_downloader(todownloader) + torrent_info, _ = todownloader_obj.get_torrents(ids=[torrent_item.get('hash')]) + if torrent_info: + # 删除重复的源种子,不能删除文件! + if self._deleteduplicate: + logger.info(f"删除重复的源下载器任务(不含文件):{torrent_item.get('hash')} ...") + downloader_obj.delete_torrents(delete_file=False, ids=[torrent_item.get('hash')]) + del_dup += 1 + else: + logger.info(f"{torrent_item.get('hash')} 已在目的下载器中,跳过 ...") + # 跳过计数 + skip += 1 + continue + + # 转换保存路径 + download_dir = self.__convert_save_path(torrent_item.get('save_path'), + self._frompath, + self._topath) + if not download_dir: + logger.error(f"转换保存路径失败:{torrent_item.get('save_path')}") + # 失败计数 + fail += 1 + continue + + # 如果源下载器是QB检查是否有Tracker,没有的话额外获取 + if downloader == "qbittorrent": + # 读取种子内容、解析种子文件 + content = torrent_file.read_bytes() + if not content: + logger.warn(f"读取种子文件失败:{torrent_file}") + fail += 1 + continue + # 读取trackers + try: + torrent_main = bdecode(content) + main_announce = torrent_main.get('announce') + except Exception as err: + logger.warn(f"解析种子文件 {torrent_file} 失败:{str(err)}") + fail += 1 + continue + + if not main_announce: + logger.info(f"{torrent_item.get('hash')} 未发现tracker信息,尝试补充tracker信息...") + # 读取fastresume文件 + fastresume_file = Path(self._fromtorrentpath) / f"{torrent_item.get('hash')}.fastresume" + if not fastresume_file.exists(): + logger.warn(f"fastresume文件不存在:{fastresume_file}") + fail += 1 + continue + # 尝试补充trackers + try: + # 解析fastresume文件 + fastresume = fastresume_file.read_bytes() + torrent_fastresume = bdecode(fastresume) + # 读取trackers + fastresume_trackers = torrent_fastresume.get('trackers') + if isinstance(fastresume_trackers, list) \ + and len(fastresume_trackers) > 0 \ + and fastresume_trackers[0]: + # 重新赋值 + torrent_main['announce'] = fastresume_trackers[0][0] + # 保留其他tracker,避免单一tracker无法连接 + if len(fastresume_trackers) > 1 or len(fastresume_trackers[0]) > 1: + torrent_main['announce-list'] = fastresume_trackers + # 替换种子文件路径 + torrent_file = settings.TEMP_PATH / f"{torrent_item.get('hash')}.torrent" + # 编码并保存到临时文件 + torrent_file.write_bytes(bencode(torrent_main)) + except Exception as err: + logger.error(f"解析fastresume文件 {fastresume_file} 出错:{str(err)}") + fail += 1 + continue + + # 发送到另一个下载器中下载:默认暂停、传输下载路径、关闭自动管理模式 + logger.info(f"添加转移做种任务到下载器 {todownloader}:{torrent_file}") + download_id = self.__download(downloader=todownloader, + content=torrent_file.read_bytes(), + save_path=download_dir) + if not download_id: + # 下载失败 + fail += 1 + logger.error(f"添加下载任务失败:{torrent_file}") + continue + else: + # 下载成功 + logger.info(f"成功添加转移做种任务,种子文件:{torrent_file}") + + # TR会自动校验,QB需要手动校验 + if todownloader == "qbittorrent": + logger.info(f"qbittorrent 开始校验 {download_id} ...") + todownloader_obj.recheck_torrents(ids=[download_id]) + + # 追加校验任务 + logger.info(f"添加校验检查任务:{download_id} ...") + if not self._recheck_torrents.get(todownloader): + self._recheck_torrents[todownloader] = [] + self._recheck_torrents[todownloader].append(download_id) + + # 删除源种子,不能删除文件! + if self._deletesource: + logger.info(f"删除源下载器任务(不含文件):{torrent_item.get('hash')} ...") + downloader_obj.delete_torrents(delete_file=False, ids=[torrent_item.get('hash')]) + + # 成功计数 + success += 1 + # 插入转种记录 + history_key = "%s-%s" % (self._fromdownloader, torrent_item.get('hash')) + self.save_data(key=history_key, + value={ + "to_download": self._todownloader, + "to_download_id": download_id, + "delete_source": self._deletesource, + "delete_duplicate": self._deleteduplicate, + }) + # 触发校验任务 + if success > 0 and self._autostart: + self.check_recheck() + + # 发送通知 + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title="【转移做种任务执行完成】", + text=f"总数:{total},成功:{success},失败:{fail},跳过:{skip},删除重复:{del_dup}" + ) + else: + logger.info(f"没有需要转移的种子") + logger.info("转移做种任务执行完成") + + def check_recheck(self): + """ + 定时检查下载器中种子是否校验完成,校验完成且完整的自动开始辅种 + """ + if not self._recheck_torrents: + return + if not self._todownloader: + return + if self._is_recheck_running: + return + + # 校验下载器 + downloader = self._todownloader + + # 需要检查的种子 + recheck_torrents = self._recheck_torrents.get(downloader, []) + if not recheck_torrents: + return + + logger.info(f"开始检查下载器 {downloader} 的校验任务 ...") + + # 运行状态 + self._is_recheck_running = True + + # 获取任务 + downloader_obj = self.__get_downloader(downloader) + torrents, _ = downloader_obj.get_torrents(ids=recheck_torrents) + if torrents: + # 可做种的种子 + can_seeding_torrents = [] + for torrent in torrents: + # 获取种子hash + hash_str = self.__get_hash(torrent, downloader) + # 判断是否可做种 + if self.__can_seeding(torrent, downloader): + can_seeding_torrents.append(hash_str) + + if can_seeding_torrents: + logger.info(f"共 {len(can_seeding_torrents)} 个任务校验完成,开始做种") + # 开始做种 + downloader_obj.start_torrents(ids=can_seeding_torrents) + # 去除已经处理过的种子 + self._recheck_torrents[downloader] = list( + set(recheck_torrents).difference(set(can_seeding_torrents))) + else: + logger.info(f"没有新的任务校验完成,将在下次个周期继续检查 ...") + + elif torrents is None: + logger.info(f"下载器 {downloader} 查询校验任务失败,将在下次继续查询 ...") + else: + logger.info(f"下载器 {downloader} 中没有需要检查的校验任务,清空待处理列表") + self._recheck_torrents[downloader] = [] + + self._is_recheck_running = False + + @staticmethod + def __get_hash(torrent: Any, dl_type: str): + """ + 获取种子hash + """ + try: + return torrent.get("hash") if dl_type == "qbittorrent" else torrent.hashString + except Exception as e: + print(str(e)) + return "" + + @staticmethod + def __get_label(torrent: Any, dl_type: str): + """ + 获取种子标签 + """ + try: + return [str(tag).strip() for tag in torrent.get("tags").split(',')] \ + if dl_type == "qbittorrent" else torrent.labels or [] + except Exception as e: + print(str(e)) + return [] + + @staticmethod + def __get_save_path(torrent: Any, dl_type: str): + """ + 获取种子保存路径 + """ + try: + return torrent.get("save_path") if dl_type == "qbittorrent" else torrent.download_dir + except Exception as e: + print(str(e)) + return "" + + @staticmethod + def __can_seeding(torrent: Any, dl_type: str): + """ + 判断种子是否可以做种并处于暂停状态 + """ + try: + return (torrent.get("state") == "pausedUP") if dl_type == "qbittorrent" \ + else (torrent.status.stopped and torrent.percent_done == 1) + except Exception as e: + print(str(e)) + return False + + @staticmethod + def __convert_save_path(save_path: str, from_root: str, to_root: str): + """ + 转换保存路径 + """ + try: + # 没有保存目录,以目的根目录为准 + if not save_path: + return to_root + # 没有设置根目录时返回save_path + if not to_root or not from_root: + return save_path + # 统一目录格式 + save_path = os.path.normpath(save_path).replace("\\", "/") + from_root = os.path.normpath(from_root).replace("\\", "/") + to_root = os.path.normpath(to_root).replace("\\", "/") + # 替换根目录 + if save_path.startswith(from_root): + return save_path.replace(from_root, to_root, 1) + except Exception as e: + print(str(e)) + return None + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._event.set() + self._scheduler.shutdown() + self._event.clear() + self._scheduler = None + except Exception as e: + print(str(e)) diff --git a/plugins.v2/trackereditor/__init__.py b/plugins.v2/trackereditor/__init__.py new file mode 100644 index 0000000..872e657 --- /dev/null +++ b/plugins.v2/trackereditor/__init__.py @@ -0,0 +1,454 @@ +from typing import List, Tuple, Dict, Any, Union, Optional + +from apscheduler.triggers.cron import CronTrigger + +from app.log import logger +from app.modules.qbittorrent import Qbittorrent +from qbittorrentapi.torrents import TorrentInfoList +from app.modules.transmission import Transmission +from transmission_rpc.torrent import Torrent +from app.plugins import _PluginBase +from app.schemas import NotificationType + + +class TrackerEditor(_PluginBase): + # 插件名称 + plugin_name = "Tracker替换" + # 插件描述 + plugin_desc = "批量替换种子tracker,支持周期性巡检(如为TR,仅支持4.0以上版本)" + # 插件图标 + plugin_icon = "trackereditor_A.png" + # 插件版本 + plugin_version = "1.5" + # 插件作者 + plugin_author = "honue" + # 作者主页 + author_url = "https://github.com/honue" + # 插件配置项ID前缀 + plugin_config_prefix = "trackereditor_" + # 加载顺序 + plugin_order = 30 + # 可使用的用户级别 + auth_level = 1 + + _downloader_type: str = None + _username: str = None + _password: str = None + _host: str = None + _port: int = None + _target_domain: str = None + _replace_domain: str = None + + _onlyonce: bool = False + _downloader: Union[Qbittorrent, Transmission] = None + + _run_con_enable: bool = False + _run_con: Optional[str] = None + _notify: bool = False + + def init_plugin(self, config: dict = None): + if config: + self._onlyonce = config.get("onlyonce") + self._downloader_type = config.get("downloader_type") + self._host = config.get("host") + self._port = config.get("port") + self._username = config.get("username") + self._password = config.get("password") + self._target_domain = config.get("target_domain") + self._replace_domain = config.get("replace_domain") + self._run_con_enable = config.get("run_con_enable") + self._run_con = config.get("run_con") + self._notify = config.get("notify") + + if self._onlyonce: + # 执行替换 + self.task() + self._onlyonce = False + # 更新onlyonce属性 + self.__update_config() + + def task(self): + logger.info(f"{'*' * 30}TrackerEditor: 开始执行Tracker替换{'*' * 30}") + torrent_total_cnt: int = 0 + torrent_update_cnt: int = 0 + if self._downloader_type == "qbittorrent": + self._downloader = Qbittorrent(self._host, self._port, self._username, self._password) + torrent_info_list: TorrentInfoList + torrent_info_list, error = self._downloader.get_torrents() + torrent_total_cnt = len(torrent_info_list) + if error: + return + for torrent in torrent_info_list: + for tracker in torrent.trackers: + if self._target_domain in tracker.url: + original_url = tracker.url + new_url = tracker.url.replace(self._target_domain, self._replace_domain) + logger.info(f"{original_url} 替换为\n {new_url}") + torrent.edit_tracker(orig_url=original_url, new_url=new_url) + torrent_update_cnt += 1 + + elif self._downloader_type == "transmission": + self._downloader = Transmission(self._host, self._port, self._username, self._password) + tr_version = self._downloader.get_session().get('version') + # "4.0.3 (6b0e49bbb2)" "3.00 (bb6b5a062e)" + torrent_list: List[Torrent] + torrent_list, error = self._downloader.get_torrents() + torrent_total_cnt = len(torrent_list) + if error: + return + for torrent in torrent_list: + new_tracker_list = [] + for tracker in torrent.tracker_list: + if self._target_domain in tracker: + new_url = tracker.replace(self._target_domain, self._replace_domain) + new_tracker_list.append(new_url) + logger.info(f"{tracker} 替换为\n {new_url}") + torrent_update_cnt += 1 + else: + new_tracker_list.append(tracker) + if int(tr_version[0]) >= 4: + # 版本大于等于4.x + __tracker_list = [new_tracker_list] + else: + __tracker_list = new_tracker_list + if torrent_update_cnt > 0: + update_result = self._downloader.update_tracker(hash_string=torrent.hashString, tracker_list=__tracker_list) + if not update_result: + logger.error(f"执行tracker修改出错,中止本次执行") + torrent_update_cnt = 0 + break + if torrent_update_cnt == 0: + logger.info(f"tracker修改条数为0") + logger.info(f"{'*' * 30}TrackerEditor: Tracker替换完成{'*' * 30}") + if (self._run_con_enable and self._notify) or (self._onlyonce and self._notify): + title = '【Tracker替换】' + msg = f'''扫描下载器{self._downloader_type}\n总的种子数: {torrent_total_cnt}\n已修改种子数: {torrent_update_cnt}''' + self.send_site_message(title, msg) + + def __update_config(self): + self.update_config({ + "onlyonce": self._onlyonce, + "downloader_type": self._downloader_type, + "username": self._username, + "password": self._password, + "host": self._host, + "port": self._port, + "target_domain": self._target_domain, + "replace_domain": self._replace_domain, + "run_cron_enable": self._run_con_enable, + "run_cron": self._run_con, + "notify": self._notify + }) + + @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]]: + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'run_con_enable', + 'label': '启用周期性巡检 (注: 请开启时,务必填写cron表达式)', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + }] + }, { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '发送通知', + } + } + ] + }] + }, + { + 'component': 'VRow', + 'content': [ + + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'run_con', + 'label': 'cron表达式', + 'placeholder': '* * * * *' + } + } + ] + }, { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'downloader_type', + 'label': '下载器类型', + 'items': [ + {'title': 'Qbittorrent', 'value': 'qbittorrent'}, + {'title': 'Transmission', 'value': 'transmission'} + ] + } + } + ] + }] + }, { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'host', + 'label': 'host主机ip', + 'placeholder': '192.168.2.100' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'port', + 'label': 'qb/tr端口', + 'placeholder': '8989' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'username', + 'label': '用户名', + 'placeholder': 'username' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'password', + 'label': '密码', + 'placeholder': 'password' + } + } + ] + } + ] + }, { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'target_domain', + 'label': '待替换文本', + 'placeholder': 'target.com' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'replace_domain', + 'label': '替换的文本', + 'placeholder': 'replace.net' + } + } + ] + } + ] + }, { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '对下载器中所有符合代替换文本的tacker进行字符串replace替换' + '\n' + + '现有tracker: https://baidu.com/announce.php?passkey=xxxx' + '\n' + + '待替换 baidu.com 或 https://baidu.com' + '\n' + + '用于替换的文本 qq.com 或 https://qq.com' + '\n' + + '结果为 https://qq.com/announce.php?passkey=xxxx', + 'style': 'white-space: pre-line;' + } + }, + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '强烈建议自己先添加一个tracker测试替换是否符合预期,程序是否正常运行', + 'style': 'white-space: pre-line;' + } + }, + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '周期性巡检时指的是允许设置间隔一段进行巡检下载器中的种子Tracker' + '\n' + '当匹配到等待替换的tracker时,进行替换,其中cron表达式是5位,例如:* * * * * 指的是每过一分钟轮训一次', + 'style': 'white-space: pre-line;' + } + } + ] + } + ] + } + ] + } + ], { + "onlyonce": False, + "downloader_type": "qbittorrent", + "host": "192.168.2.100", + "port": 8989, + "username": "username", + "password": "password", + "target_domain": "", + "replace_domain": "", + "run_con_enable": False, + "run_con": "", + "notify": True + } + + def get_page(self) -> List[dict]: + pass + + def get_state(self) -> bool: + return True + + def stop_service(self): + pass + + def get_service(self) -> List[Dict[str, Any]]: + if self._run_con_enable and self._run_con: + logger.info(f"{'*' * 30}TrackerEditor: 注册公共调度服务{'*' * 30}") + return [ + { + "id": "TrackerChangeRun", + "name": "启用周期性Tracker替换", + "trigger": CronTrigger.from_crontab(self._run_con), + "func": self.task, + "kwargs": {} + }] + + return [] + + def send_site_message(self, title, message): + self.post_message( + mtype=NotificationType.SiteMessage, + title=title, + text=message + ) diff --git a/plugins.v2/trcommand/__init__.py b/plugins.v2/trcommand/__init__.py new file mode 100644 index 0000000..ff8af5c --- /dev/null +++ b/plugins.v2/trcommand/__init__.py @@ -0,0 +1,732 @@ +from typing import List, Tuple, Dict, Any + +from app.log import logger +from app.modules.transmission import Transmission +from app.plugins import _PluginBase +from app.schemas import NotificationType +from app.schemas.types import EventType +from apscheduler.triggers.cron import CronTrigger +from app.core.event import eventmanager, Event +import time + + +class TrCommand(_PluginBase): + # 插件名称 + plugin_name = "TR远程操作" + # 插件描述 + plugin_desc = "通过定时任务或交互命令远程操作TR暂停/开始/限速等。" + # 插件图标 + plugin_icon = "Transmission_A.png" + # 插件版本 + plugin_version = "1.1" + # 插件作者 + plugin_author = "Hoey" + # 作者主页 + author_url = "https://github.com/hoey94" + # 插件配置项ID前缀 + plugin_config_prefix = "trcommand_" + # 加载顺序 + plugin_order = 1 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _tr = None + _enabled: bool = False + _notify: bool = False + _pause_cron = None + _resume_cron = None + _only_pause_once = False + _only_resume_once = False + _upload_limit = 0 + _enable_upload_limit = False + _download_limit = 0 + _enable_download_limit = False + + def init_plugin(self, config: dict = None): + # 停止现有任务 + self.stop_service() + # 读取配置 + if config: + self._enabled = config.get("enabled") + self._notify = config.get("notify") + self._pause_cron = config.get("pause_cron") + self._resume_cron = config.get("resume_cron") + self._only_pause_once = config.get("onlypauseonce") + self._only_resume_once = config.get("onlyresumeonce") + self._download_limit = config.get("download_limit") + self._upload_limit = config.get("upload_limit") + self._enable_download_limit = config.get("enable_download_limit") + self._enable_upload_limit = config.get("enable_upload_limit") + self._tr = Transmission() + + if self._only_pause_once or self._only_resume_once: + if self._only_pause_once and self._only_resume_once: + logger.warning("只能选择一个: 立即暂停或立即开始所有任务") + elif self._only_pause_once: + self.pause_torrent() + elif self._only_resume_once: + self.resume_torrent() + + self._only_resume_once = False + self._only_pause_once = False + self.update_config( + { + "onlypauseonce": False, + "onlyresumeonce": False, + "enabled": self._enabled, + "notify": self._notify, + "pause_cron": self._pause_cron, + "resume_cron": self._resume_cron, + } + ) + + # 限速 + self.set_limit(self._upload_limit, self._download_limit) + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + """ + 定义远程控制命令 + :return: 命令关键字、事件、描述、附带数据 + """ + return [ + { + "cmd": "/pause_torrents", + "event": EventType.PluginAction, + "desc": "暂停TR种子", + "category": "TR", + "data": {"action": "pause_torrents"}, + }, + { + "cmd": "/resume_torrents", + "event": EventType.PluginAction, + "desc": "开始TR种子", + "category": "TR", + "data": {"action": "resume_torrents"}, + }, + { + "cmd": "/toggle_upload_limit", + "event": EventType.PluginAction, + "desc": "TR切换上传限速状态", + "category": "TR", + "data": {"action": "toggle_upload_limit"}, + }, + { + "cmd": "/toggle_download_limit", + "event": EventType.PluginAction, + "desc": "TR切换下载限速状态", + "category": "TR", + "data": {"action": "toggle_download_limit"}, + }, + ] + + 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 and self._pause_cron and self._resume_cron: + return [ + { + "id": "TrPause", + "name": "暂停TR所有任务", + "trigger": CronTrigger.from_crontab(self._pause_cron), + "func": self.pause_torrent, + "kwargs": {}, + }, + { + "id": "TrResume", + "name": "开始TR所有任务", + "trigger": CronTrigger.from_crontab(self._resume_cron), + "func": self.resume_torrent, + "kwargs": {}, + }, + ] + if self._enabled and self._pause_cron: + return [ + { + "id": "TrPause", + "name": "暂停TR所有任务", + "trigger": CronTrigger.from_crontab(self._pause_cron), + "func": self.pause_torrent, + "kwargs": {}, + } + ] + if self._enabled and self._resume_cron: + return [ + { + "id": "TrResume", + "name": "开始TR所有任务", + "trigger": CronTrigger.from_crontab(self._resume_cron), + "func": self.resume_torrent, + "kwargs": {}, + } + ] + return [] + + def get_all_torrents(self): + all_torrents, error = self._tr.get_torrents() + if error: + logger.error(f"获取TR种子失败: {error}") + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【TR远程操作】", + text=f"获取TR种子失败,请检查TR配置", + ) + return [] + + if not all_torrents: + logger.warning("TR没有种子") + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【TR远程操作】", + text=f"TR中没有种子", + ) + return [] + return all_torrents + + @staticmethod + def get_torrents_status(torrents): + downloading_torrents = [] + uploading_torrents = [] + paused_torrents = [] + checking_torrents = [] + error_torrents = [] + for torrent in torrents: + match torrent.status.lower(): + case 'stopped': + paused_torrents.append(torrent.id) + case 'check_pending': + checking_torrents.append(torrent.id) + case 'checking': + checking_torrents.append(torrent.id) + case 'download_pending': + downloading_torrents.append(torrent.id) + case 'downloading': + downloading_torrents.append(torrent.id) + case 'seed_pending': + uploading_torrents.append(torrent.id) + case 'seeding': + uploading_torrents.append(torrent.id) + + return ( + downloading_torrents, + uploading_torrents, + paused_torrents, + checking_torrents, + error_torrents, + ) + + @eventmanager.register(EventType.PluginAction) + def handle_pause_torrent(self, event: Event): + if not self._enabled: + return + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "pause_torrents": + return + self.pause_torrent() + + def pause_torrent(self): + if not self._enabled: + return + + all_torrents = self.get_all_torrents() + hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( + self.get_torrents_status(all_torrents) + ) + to_be_paused = hash_downloading + hash_uploading + hash_checking + logger.info( + f"暂定任务启动 \n" + f"种子总数: {len(all_torrents)} \n" + f"做种数量: {len(hash_uploading)}\n" + f"下载数量: {len(hash_downloading)}\n" + f"检查数量: {len(hash_checking)}\n" + f"暂停数量: {len(hash_paused)}\n" + f"错误数量: {len(hash_error)}\n" + f"暂停操作中请稍等...\n", + ) + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【TR暂停任务启动】", + text=f"种子总数: {len(all_torrents)} \n" + f"做种数量: {len(hash_uploading)}\n" + f"下载数量: {len(hash_downloading)}\n" + f"检查数量: {len(hash_checking)}\n" + f"暂停数量: {len(hash_paused)}\n" + f"错误数量: {len(hash_error)}\n" + f"暂停操作中请稍等...\n", + ) + if len(to_be_paused) > 0: + if self._tr.stop_torrents(ids=to_be_paused): + logger.info(f"暂停了{len(to_be_paused)}个种子") + else: + logger.error(f"暂停种子失败") + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【TR远程操作】", + text=f"暂停种子失败", + ) + # 每个种子等待1ms以让状态切换成功,至少等待1S + wait_time = 0.001 * len(to_be_paused) + 1 + time.sleep(wait_time) + + all_torrents = self.get_all_torrents() + hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( + self.get_torrents_status(all_torrents) + ) + logger.info( + f"暂定任务完成 \n" + f"种子总数: {len(all_torrents)} \n" + f"做种数量: {len(hash_uploading)}\n" + f"下载数量: {len(hash_downloading)}\n" + f"检查数量: {len(hash_checking)}\n" + f"暂停数量: {len(hash_paused)}\n" + f"错误数量: {len(hash_error)}\n" + ) + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【TR暂停任务完成】", + text=f"种子总数: {len(all_torrents)} \n" + f"做种数量: {len(hash_uploading)}\n" + f"下载数量: {len(hash_downloading)}\n" + f"检查数量: {len(hash_checking)}\n" + f"暂停数量: {len(hash_paused)}\n" + f"错误数量: {len(hash_error)}\n", + ) + + @eventmanager.register(EventType.PluginAction) + def handle_resume_torrent(self, event: Event): + if not self._enabled: + return + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "resume_torrents": + return + self.resume_torrent() + + def resume_torrent(self): + if not self._enabled: + return + + all_torrents = self.get_all_torrents() + hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( + self.get_torrents_status(all_torrents) + ) + logger.info( + f"TR开始任务启动 \n" + f"种子总数: {len(all_torrents)} \n" + f"做种数量: {len(hash_uploading)}\n" + f"下载数量: {len(hash_downloading)}\n" + f"检查数量: {len(hash_checking)}\n" + f"暂停数量: {len(hash_paused)}\n" + f"错误数量: {len(hash_error)}\n" + f"开始操作中请稍等...\n", + ) + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【TR开始任务启动】", + text=f"种子总数: {len(all_torrents)} \n" + f"做种数量: {len(hash_uploading)}\n" + f"下载数量: {len(hash_downloading)}\n" + f"检查数量: {len(hash_checking)}\n" + f"暂停数量: {len(hash_paused)}\n" + f"错误数量: {len(hash_error)}\n" + f"开始操作中请稍等...\n", + ) + if not self._tr.start_torrents(ids=hash_paused): + logger.error(f"开始种子失败") + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【TR远程操作】", + text=f"开始种子失败", + ) + # 每个种子等待1ms以让状态切换成功,至少等待1S + wait_time = 0.001 * len(hash_paused) + 1 + time.sleep(wait_time) + + all_torrents = self.get_all_torrents() + hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( + self.get_torrents_status(all_torrents) + ) + logger.info( + f"开始任务完成 \n" + f"种子总数: {len(all_torrents)} \n" + f"做种数量: {len(hash_uploading)}\n" + f"下载数量: {len(hash_downloading)}\n" + f"检查数量: {len(hash_checking)}\n" + f"暂停数量: {len(hash_paused)}\n" + f"错误数量: {len(hash_error)}\n" + ) + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【TR开始任务完成】", + text=f"种子总数: {len(all_torrents)} \n" + f"做种数量: {len(hash_uploading)}\n" + f"下载数量: {len(hash_downloading)}\n" + f"检查数量: {len(hash_checking)}\n" + f"暂停数量: {len(hash_paused)}\n" + f"错误数量: {len(hash_error)}\n", + ) + + @eventmanager.register(EventType.PluginAction) + def handle_toggle_upload_limit(self, event: Event): + if not self._enabled: + return + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "toggle_upload_limit": + return + + self.set_limit(self._upload_limit, self._download_limit) + + @eventmanager.register(EventType.PluginAction) + def handle_toggle_download_limit(self, event: Event): + if not self._enabled: + return + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "toggle_download_limit": + return + self.set_limit(self._upload_limit, self._download_limit) + + def set_both_limit(self, upload_limit, download_limit): + if not self._enable_upload_limit or not self._enable_upload_limit: + return True + + if not upload_limit or not upload_limit.isdigit() or not download_limit or not download_limit.isdigit(): + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【TR远程操作】", + text=f"设置TR限速失败,download_limit或upload_limit不是一个数值", + ) + return False + + return self._tr.set_speed_limit( + download_limit=int(download_limit), upload_limit=int(upload_limit) + ) + + def set_upload_limit(self, upload_limit): + if not self._enable_upload_limit: + return True + + if not upload_limit or not upload_limit.isdigit(): + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【TR远程操作】", + text=f"设置TR限速失败,upload_limit不是一个数值", + ) + return False + + download_limit_current_val, _ = self._tr.get_speed_limit() + return self._tr.set_speed_limit( + download_limit=int(download_limit_current_val), upload_limit=int(upload_limit) + ) + + def set_download_limit(self, download_limit): + if not self._enable_download_limit: + return True + + if not download_limit or not download_limit.isdigit(): + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【TR远程操作】", + text=f"设置TR限速失败,download_limit不是一个数值", + ) + return False + + _, upload_limit_current_val = self._tr.get_speed_limit() + return self._tr.set_speed_limit( + download_limit=int(download_limit), upload_limit=int(upload_limit_current_val) + ) + + def set_limit(self, upload_limit, download_limit): + # 限速,满足以下三种情况设置限速 + # 1. 插件启用 && download_limit启用 + # 2. 插件启用 && upload_limit启用 + # 3. 插件启用 && download_limit启用 && upload_limit启用 + + flag = None + if self._enabled and self._enable_download_limit and self._enable_upload_limit: + flag = self.set_both_limit(upload_limit, download_limit) + + elif flag is None and self._enabled and self._enable_download_limit: + flag = self.set_download_limit(download_limit) + + elif flag is None and self._enabled and self._enable_upload_limit: + flag = self.set_upload_limit(upload_limit) + + if flag: + logger.info(f"设置TR限速成功") + if self._notify: + if upload_limit == 0: + text = f"上传无限速" + else: + text = f"上传限速:{upload_limit} KB/s" + if download_limit == 0: + text += f"\n下载无限速" + else: + text += f"\n下载限速:{download_limit} KB/s" + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【TR远程操作】", + text=text, + ) + elif not flag: + logger.error(f"TR设置限速失败") + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【TR远程操作】", + text=f"设置TR限速失败", + ) + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + return [ + { + "component": "VForm", + "content": [ + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "enabled", + "label": "启用插件", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "notify", + "label": "发送通知", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "onlypauseonce", + "label": "立即暂停所有任务", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "onlyresumeonce", + "label": "立即开始所有任务", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "pause_cron", + "label": "暂停周期", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "resume_cron", + "label": "开始周期", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "enable_upload_limit", + "label": "上传限速", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "enable_download_limit", + "label": "下载限速", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "upload_limit", + "label": "上传限速 KB/s", + "placeholder": "KB/s", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "download_limit", + "label": "下载限速 KB/s", + "placeholder": "KB/s", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": { + "cols": 12, + }, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "开始周期和暂停周期使用Cron表达式,如:0 0 0 * *", + }, + } + ], + }, + { + "component": "VCol", + "props": { + "cols": 12, + }, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "交互命令有暂停TR种子、开始TR种子、TR切换上传限速状态、TR切换下载限速状态", + }, + } + ], + }, + { + "component": "VCol", + "props": { + "cols": 12, + }, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "PT精神重在分享,请勿恶意限速,因此导致账号被封禁作者概不负责", + }, + } + ], + } + ], + }, + ], + } + ], { + "enabled": False, + "notify": True, + "onlypauseonce": False, + "onlyresumeonce": False, + "upload_limit": 0, + "download_limit": 0, + "enable_upload_limit": False, + "enable_download_limit": False, + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + pass diff --git a/plugins.v2/vcbanimemonitor/__init__.py b/plugins.v2/vcbanimemonitor/__init__.py new file mode 100644 index 0000000..f81f257 --- /dev/null +++ b/plugins.v2/vcbanimemonitor/__init__.py @@ -0,0 +1,1124 @@ +import datetime +import re +import shutil +import threading +import time +import traceback +from pathlib import Path +from time import sleep +from typing import List, Tuple, Dict, Any, Optional +import pytz +import qbittorrentapi +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from watchdog.events import FileSystemEventHandler +from watchdog.observers import Observer +from watchdog.observers.polling import PollingObserver +from app import schemas +from app.chain.media import MediaChain +from app.chain.tmdb import TmdbChain +from app.chain.transfer import TransferChain +from app.core.config import settings +from app.core.context import MediaInfo +from app.db.downloadhistory_oper import DownloadHistoryOper +from app.db.transferhistory_oper import TransferHistoryOper +from app.log import logger +from app.modules.qbittorrent import Qbittorrent +from app.plugins import _PluginBase +from app.plugins.vcbanimemonitor.remeta import ReMeta +from app.schemas import Notification, NotificationType, TransferInfo +from app.schemas.types import EventType, MediaType, SystemConfigKey +from app.utils.string import StringUtils +from app.utils.system import SystemUtils + +lock = threading.Lock() + + +class FileMonitorHandler(FileSystemEventHandler): + """ + 目录监控响应类 + """ + + def __init__(self, monpath: str, sync: Any, **kwargs): + super(FileMonitorHandler, self).__init__(**kwargs) + self._watch_path = monpath + self.sync = sync + + def on_created(self, event): + self.sync.event_handler(event=event, text="创建", + mon_path=self._watch_path, event_path=event.src_path) + + def on_moved(self, event): + self.sync.event_handler(event=event, text="移动", + mon_path=self._watch_path, event_path=event.dest_path) + + +class TorrentHandler(FileSystemEventHandler): + def __init__(self, monpath: str, sync: Any, **kwargs): + super(TorrentHandler, self).__init__(**kwargs) + self._watch_path = monpath + self.sync = sync + + def on_created(self, event): + self.sync.torrent_event(event=event, text="创建", + mon_path=self._watch_path) + + def on_moved(self, event): + self.sync.torrent_event(event=event, text="移动", + mon_path=self._watch_path) + + +class VCBAnimeMonitor(_PluginBase): + # 插件名称 + plugin_name = "整理VCB动漫压制组作品" + # 插件描述 + plugin_desc = "一款辅助整理&提高识别VCB-Stuido动漫压制组作品的插件" + # 插件图标 + plugin_icon = "vcbmonitor.png" + # 插件版本 + plugin_version = "1.8.2.2" + # 插件作者 + plugin_author = "pixel@qingwa" + # 作者主页 + author_url = "https://github.com/Pixel-LH" + # 插件配置项ID前缀 + plugin_config_prefix = "vcbanimemonitor_" + # 加载顺序 + plugin_order = 4 + # 可使用的用户级别 + auth_level = 2 + + # 私有属性 + _switch_ova = False + _torrents_path = None + new_save_path = None + qb = None + _scheduler = None + transferhis = None + downloadhis = None + transferchian = None + tmdbchain = None + mediaChain = None + _observer = [] + _enabled = False + _notify = False + _onlyonce = False + _cron = None + _size = 0 + _scrape = True + # 模式 compatibility/fast + _mode = "fast" + # 转移方式 + _transfer_type = settings.TRANSFER_TYPE + _monitor_dirs = "" + _exclude_keywords = "" + _interval: int = 10 + # 存储源目录与目的目录关系 + _dirconf: Dict[str, Optional[Path]] = {} + # 存储源目录转移方式 + _transferconf: Dict[str, Optional[str]] = {} + _medias = {} + # 退出事件 + _event = threading.Event() + + def init_plugin(self, config: dict = None): + self.transferhis = TransferHistoryOper() + self.downloadhis = DownloadHistoryOper() + self.transferchian = TransferChain() + self.mediaChain = MediaChain() + self.tmdbchain = TmdbChain() + # 清空配置 + self._dirconf = {} + self._transferconf = {} + + # 读取配置 + if config: + self._enabled = config.get("enabled") + self._notify = config.get("notify") + self._onlyonce = config.get("onlyonce") + self._mode = config.get("mode") + self._transfer_type = config.get("transfer_type") + self._monitor_dirs = config.get("monitor_dirs") or "" + self._exclude_keywords = config.get("exclude_keywords") or "" + self._interval = config.get("interval") or 10 + self._cron = config.get("cron") + self._size = config.get("size") or 0 + self._scrape = config.get("scrape") + self._switch_ova = config.get("ova") + self._torrents_path = config.get("torrents_path") or "" + + # 停止现有任务 + self.stop_service() + + if self._enabled or self._onlyonce: + # 定时服务管理器 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + # 追加入库消息统一发送服务 + self._scheduler.add_job(self.send_msg, trigger='interval', seconds=15) + self.qb = Qbittorrent() + + # 读取目录配置 + monitor_dirs = self._monitor_dirs.split("\n") + if not monitor_dirs: + return + + # 启用种子目录监控 + if self._torrents_path and Path(self._torrents_path).exists() and self._enabled: + # 只取第一个目录作为新的保存 + try: + first_path = monitor_dirs[0] + if SystemUtils.is_windows(): + self.new_save_path = first_path.split(':')[0] + ":" + first_path.split(':')[1] + else: + self.new_save_path = first_path.split(':')[0] + except Exception: + logger.error(f"目录保存失败,请检查输入目录是否合法") + # print(self.new_save_path) + try: + observer = Observer() + self._observer.append(observer) + observer.schedule(TorrentHandler(monpath=self._torrents_path, sync=self), path=self._torrents_path, + recursive=True) + observer.daemon = True + observer.start() + logger.info(f"{self._torrents_path} 的种子目录监控服务启动,开启监控新增的VCB-Studio种子文件") + except Exception as e: + logger.debug(f"{self._torrents_path} 启动种子目录监控失败:{str(e)}") + else: + logger.info("种子目录为空,不转移qb中正在下载的VCB-Studio文件") + + for mon_path in monitor_dirs: + # 格式源目录:目的目录 + if not mon_path: + continue + + # 自定义转移方式 + _transfer_type = self._transfer_type + if mon_path.count("#") == 1: + _transfer_type = mon_path.split("#")[1] + mon_path = mon_path.split("#")[0] + + # 存储目的目录 + if SystemUtils.is_windows(): + if mon_path.count(":") > 1: + paths = [mon_path.split(":")[0] + ":" + mon_path.split(":")[1], + mon_path.split(":")[2] + ":" + mon_path.split(":")[3]] + else: + paths = [mon_path] + else: + paths = mon_path.split(":") + + # 目的目录 + target_path = None + if len(paths) > 1: + mon_path = paths[0] + target_path = Path(paths[1]) + self._dirconf[mon_path] = target_path + else: + self._dirconf[mon_path] = None + + # 转移方式 + self._transferconf[mon_path] = _transfer_type + + # 启用目录监控 + if self._enabled: + # 检查媒体库目录是不是下载目录的子目录 + try: + if target_path and target_path.is_relative_to(Path(mon_path)): + logger.warn(f"{target_path} 是监控目录 {mon_path} 的子目录,无法监控") + self.systemmessage.put(f"{target_path} 是下载目录 {mon_path} 的子目录,无法监控", + title="整理VCB动漫压制组作品") + continue + except Exception as e: + logger.debug(str(e)) + pass + + try: + if self._mode == "compatibility": + # 兼容模式,目录同步性能降低且NAS不能休眠,但可以兼容挂载的远程共享目录如SMB + observer = PollingObserver(timeout=10) + else: + # 内部处理系统操作类型选择最优解 + observer = Observer(timeout=10) + self._observer.append(observer) + observer.schedule(FileMonitorHandler(mon_path, self), path=mon_path, recursive=True) + observer.daemon = True + observer.start() + logger.info(f"{mon_path} 的目录监控服务启动") + except Exception as e: + err_msg = str(e) + if "inotify" in err_msg and "reached" in err_msg: + logger.warn( + f"目录监控服务启动出现异常:{err_msg},请在宿主机上(不是docker容器内)执行以下命令并重启:" + + """ + echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf + echo fs.inotify.max_user_instances=524288 | sudo tee -a /etc/sysctl.conf + sudo sysctl -p + """) + else: + logger.error(f"{mon_path} 启动目录监控失败:{err_msg}") + self.systemmessage.put(f"{mon_path} 启动目录监控失败:{err_msg}", title="整理VCB动漫压制组作品") + + # 运行一次定时服务 + if self._onlyonce: + logger.info("目录监控服务启动,立即运行一次") + self._scheduler.add_job(func=self.sync_all, trigger='date', + run_date=datetime.datetime.now( + tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3) + ) + # 关闭一次性开关 + self._onlyonce = False + # 保存配置 + self.__update_config() + + # 启动定时服务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def __update_config(self): + """ + 更新配置 + """ + self.update_config({ + "enabled": self._enabled, + "notify": self._notify, + "onlyonce": self._onlyonce, + "mode": self._mode, + "transfer_type": self._transfer_type, + "monitor_dirs": self._monitor_dirs, + "exclude_keywords": self._exclude_keywords, + "interval": self._interval, + "cron": self._cron, + "size": self._size, + "scrape": self._scrape, + "ova": self._switch_ova, + "torrents_path": self._torrents_path + }) + + def __save_data(self, key: str, value: Any): + self.save_data(key, value) + + def __get_data(self, key: str): + return self.get_data(key) + + def sync_all(self): + """ + 立即运行一次,全量同步目录中所有文件 + """ + logger.info("开始全量同步监控目录 ...") + # 清空历史的ova记录 + self.plugindata.truncate() + + # 遍历所有监控目录 + for mon_path in self._dirconf.keys(): + # 遍历目录下所有文件 + for file_path in SystemUtils.list_files(Path(mon_path), settings.RMT_MEDIAEXT): + self.__handle_file(event_path=str(file_path), mon_path=mon_path) + + logger.info("全量同步监控目录完成!") + + def event_handler(self, event, mon_path: str, text: str, event_path: str): + """ + 处理文件变化 + :param event: 事件 + :param mon_path: 监控目录 + :param text: 事件描述 + :param event_path: 事件文件路径 + """ + if not event.is_directory: + # 文件发生变化 + logger.debug("文件%s:%s" % (text, event_path)) + self.__handle_file(event_path=event_path, mon_path=mon_path) + + def __handle_file(self, event_path: str, mon_path: str): + """ + 同步一个文件 + :param event_path: 事件文件路径 + :param mon_path: 监控目录 + """ + file_path = Path(event_path) + try: + if not file_path.exists(): + return + # 全程加锁 + with lock: + transfer_history = self.transferhis.get_by_src(event_path) + if transfer_history: + logger.debug("文件已处理过:%s" % event_path) + return + + # 回收站及隐藏的文件不处理 + if event_path.find('/@Recycle/') != -1 \ + or event_path.find('/#recycle/') != -1 \ + or event_path.find('/.') != -1 \ + or event_path.find('/@eaDir') != -1: + logger.debug(f"{event_path} 是回收站或隐藏的文件") + return + + # 命中过滤关键字不处理 + if self._exclude_keywords: + for keyword in self._exclude_keywords.split("\n"): + if keyword and re.findall(keyword, event_path): + logger.info(f"{event_path} 命中过滤关键字 {keyword},不处理") + return + + # 整理屏蔽词不处理 + transfer_exclude_words = self.systemconfig.get(SystemConfigKey.TransferExcludeWords) + if transfer_exclude_words: + for keyword in transfer_exclude_words: + if not keyword: + continue + if keyword and re.search(r"%s" % keyword, event_path, re.IGNORECASE): + logger.info(f"{event_path} 命中整理屏蔽词 {keyword},不处理") + return + + # 不是媒体文件不处理 + if file_path.suffix not in settings.RMT_MEDIAEXT: + logger.debug(f"{event_path} 不是媒体文件") + return + + # 判断是不是蓝光目录 + bluray_flag = False + if re.search(r"BDMV[/\\]STREAM", event_path, re.IGNORECASE): + bluray_flag = True + # 截取BDMV前面的路径 + blurray_dir = event_path[:event_path.find("BDMV")] + file_path = Path(blurray_dir) + logger.info(f"{event_path} 是蓝光目录,更正文件路径为:{str(file_path)}") + + # 查询历史记录,已转移的不处理 + if self.transferhis.get_by_src(str(file_path)): + logger.info(f"{file_path} 已整理过") + return + + # 元数据 + if file_path.parent.name.lower() in ["sps", "scans", "cds", "previews", "extras"]: + logger.warn("位于特典或其他特殊目录下,跳过处理") + return + + if 'VCB-Studio' not in file_path.stem.strip(): + logger.warn("不属于VCB的作品,不处理!") + return + + remeta = ReMeta(ova_switch=self._switch_ova) + file_meta = remeta.handel_file(file_path=file_path) + if file_meta: + if not file_meta.name: + logger.error(f"{file_path.name} 无法识别有效信息") + return + if remeta.is_ova and not self._switch_ova: + logger.warn(f"{file_path.name} 为OVA资源,未开启OVA开关,不处理") + return + if remeta.is_ova and self._switch_ova: + logger.info(f"{file_path.name} 为OVA资源,开始历史记录处理") + ova_history_ep_list = self.get_data(file_meta.title) + if ova_history_ep_list and isinstance(ova_history_ep_list, list): + ep = file_meta.begin_episode + if ep in ova_history_ep_list: + for i in range(1, 100): + if ep + i not in ova_history_ep_list: + ova_history_ep_list.append(ep + i) + file_meta.begin_episode = ep + i + logger.info( + f"{file_path.name} 为OVA资源,历史记录中已存在,自动识别为第{ep + i}集") + break + else: + ova_history_ep_list.append(ep) + self.save_data(file_meta.title, ova_history_ep_list) + else: + self.save_data(file_meta.title, [file_meta.begin_episode]) + else: + return + + # 判断文件大小 + if self._size and float(self._size) > 0 and file_path.stat().st_size < float(self._size) * 1024 ** 3: + logger.info(f"{file_path} 文件大小小于监控文件大小,不处理") + return + + # 查询转移目的目录 + target: Path = self._dirconf.get(mon_path) + # 查询转移方式 + transfer_type = self._transferconf.get(mon_path) + + # 根据父路径获取下载历史 + download_history = None + if bluray_flag: + # 蓝光原盘,按目录名查询 + # FIXME 理论上DownloadHistory表中的path应该是全路径,但实际表中登记的数据只有目录名,暂按目录名查询 + download_history = self.downloadhis.get_by_path(file_path.name) + else: + # 按文件全路径查询 + download_file = self.downloadhis.get_file_by_fullpath(str(file_path)) + if download_file: + download_history = self.downloadhis.get_by_hash(download_file.download_hash) + + # 识别媒体信息 + if download_history and download_history.tmdbid: + mediainfo: MediaInfo = self.mediaChain.recognize_media(mtype=MediaType(download_history.type), + tmdbid=download_history.tmdbid, + doubanid=download_history.doubanid) + else: + mediainfo: MediaInfo = self.mediaChain.recognize_by_meta(file_meta) + + if not mediainfo: + logger.warn(f'未识别到媒体信息,标题:{file_meta.name}') + # self.save_data(plugin_id="vcbanimemonitor", key=file_meta.title, value="null") + # 新增转移成功历史记录 + his = self.transferhis.add_fail( + src_path=file_path, + mode=transfer_type, + meta=file_meta + ) + if self._notify: + self.chain.post_message(Notification( + mtype=NotificationType.Manual, + title=f"{file_path.name} 未识别到媒体信息,无法入库!\n" + f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别转移。" + )) + return + + # 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title + if not settings.SCRAP_FOLLOW_TMDB: + transfer_history = self.transferhis.get_by_type_tmdbid(tmdbid=mediainfo.tmdb_id, + mtype=mediainfo.type.value) + if transfer_history: + mediainfo.title = transfer_history.title + logger.info(f"{file_path.name} 识别为:{mediainfo.type.value} {mediainfo.title_year}") + + # 更新媒体图片 + self.chain.obtain_images(mediainfo=mediainfo) + + # 获取集数据 + if mediainfo.type == MediaType.TV: + episodes_info = self.tmdbchain.tmdb_episodes(tmdbid=mediainfo.tmdb_id, + season=file_meta.begin_season or 1) + else: + episodes_info = None + + # 获取下载Hash + download_hash = None + if download_history: + download_hash = download_history.download_hash + + # 转移 + transferinfo: TransferInfo = self.chain.transfer(mediainfo=mediainfo, + path=file_path, + transfer_type=transfer_type, + target=target, + meta=file_meta, + episodes_info=episodes_info) + + if not transferinfo: + logger.error("文件转移模块运行失败") + return + + if not transferinfo.success: + # 转移失败 + logger.warn(f"{file_path.name} 入库失败:{transferinfo.message}") + # 新增转移失败历史记录 + self.transferhis.add_fail( + src_path=file_path, + mode=transfer_type, + download_hash=download_hash, + meta=file_meta, + mediainfo=mediainfo, + transferinfo=transferinfo + ) + if self._notify: + self.chain.post_message(Notification( + mtype=NotificationType.Manual, + title=f"{mediainfo.title_year}{file_meta.season_episode} 入库失败!", + text=f"原因:{transferinfo.message or '未知'}", + image=mediainfo.get_message_image() + )) + return + + # 新增转移成功历史记录 + self.transferhis.add_success( + src_path=file_path, + mode=transfer_type, + download_hash=download_hash, + meta=file_meta, + mediainfo=mediainfo, + transferinfo=transferinfo + ) + + # 刮削单个文件 + if self._scrape: + self.chain.scrape_metadata(path=transferinfo.target_path, + mediainfo=mediainfo, + transfer_type=transfer_type) + + """ + { + "title_year season": { + "files": [ + { + "path":, + "mediainfo":, + "file_meta":, + "transferinfo": + } + ], + "time": "2023-08-24 23:23:23.332" + } + } + """ + # 发送消息汇总 + media_list = self._medias.get(mediainfo.title_year + " " + file_meta.season) or {} + if media_list: + media_files = media_list.get("files") or [] + if media_files: + file_exists = False + for file in media_files: + if str(file_path) == file.get("path"): + file_exists = True + break + if not file_exists: + media_files.append({ + "path": str(file_path), + "mediainfo": mediainfo, + "file_meta": file_meta, + "transferinfo": transferinfo + }) + else: + media_files = [ + { + "path": str(file_path), + "mediainfo": mediainfo, + "file_meta": file_meta, + "transferinfo": transferinfo + } + ] + media_list = { + "files": media_files, + "time": datetime.datetime.now() + } + else: + media_list = { + "files": [ + { + "path": str(file_path), + "mediainfo": mediainfo, + "file_meta": file_meta, + "transferinfo": transferinfo + } + ], + "time": datetime.datetime.now() + } + self._medias[mediainfo.title_year + " " + file_meta.season] = media_list + + # 广播事件 + self.eventmanager.send_event(EventType.TransferComplete, { + 'meta': file_meta, + 'mediainfo': mediainfo, + 'transferinfo': transferinfo + }) + + # 移动模式删除空目录 + if transfer_type == "move": + for file_dir in file_path.parents: + if len(str(file_dir)) <= len(str(Path(mon_path))): + # 重要,删除到监控目录为止 + break + files = SystemUtils.list_files(file_dir, settings.RMT_MEDIAEXT) + if not files: + logger.warn(f"移动模式,删除空目录:{file_dir}") + shutil.rmtree(file_dir, ignore_errors=True) + + except Exception as e: + logger.error("目录监控发生错误:%s - %s" % (str(e), traceback.format_exc())) + + def torrent_event(self, event, mon_path: str, text: str): + """ + 处理种子文件 + :param mon_path: 种子目录 + """ + evc_path = Path(event.src_path) + if not event.is_directory and (evc_path.suffix == ".torrent" or str(evc_path).split('.')[1] == "torrent"): + # 文件发生变化 + logger.debug("文件%s:%s" % (text, mon_path)) + self.__handle_torrent(torrent_path=self._torrents_path) + else: + logger.debug("不是种子文件:%s" % mon_path) + + def __handle_torrent(self, torrent_path: str): + torrent_path = Path(torrent_path) + try: + if not torrent_path.exists(): + return + # 只处理刚刚添加的种子也就是获取正在下载的种子 + # 等待种子文件下载完成 + time.sleep(5) + with lock: + torrents = self.qb.get_downloading_torrents() + for torrent in torrents: + if "VCB-Studio" in torrent.name: + logger.info(f"开始转移qb中正在下载的VCB资源,转移目录为:{self.new_save_path}") + # 原本存在的暂停的种子不处理 + if torrent.state_enum == qbittorrentapi.TorrentState.PAUSED_DOWNLOAD: + continue + if torrent.save_path == self.new_save_path: + continue + torrent.pause() + torrent.set_save_path(save_path=self.new_save_path) + torrent.resume() + else: + continue + except qbittorrentapi.exceptions.APIError as e: + logger.error(f"VCB辅助整理模块转移qb文件移动失败:{e}") + + def send_msg(self): + """ + 定时检查是否有媒体处理完,发送统一消息 + """ + if not self._medias or not self._medias.keys(): + return + + # 遍历检查是否已刮削完,发送消息 + for medis_title_year_season in list(self._medias.keys()): + media_list = self._medias.get(medis_title_year_season) + logger.info(f"开始处理媒体 {medis_title_year_season} 消息") + + if not media_list: + continue + + # 获取最后更新时间 + last_update_time = media_list.get("time") + media_files = media_list.get("files") + if not last_update_time or not media_files: + continue + + transferinfo = media_files[0].get("transferinfo") + file_meta = media_files[0].get("file_meta") + mediainfo = media_files[0].get("mediainfo") + # 判断剧集最后更新时间距现在是已超过10秒或者电影,发送消息 + if (datetime.datetime.now() - last_update_time).total_seconds() > int(self._interval) \ + or mediainfo.type == MediaType.MOVIE: + # 发送通知 + if self._notify: + + # 汇总处理文件总大小 + total_size = 0 + file_count = 0 + + # 剧集汇总 + episodes = [] + for file in media_files: + transferinfo = file.get("transferinfo") + total_size += transferinfo.total_size + file_count += 1 + + file_meta = file.get("file_meta") + if file_meta and file_meta.begin_episode: + episodes.append(file_meta.begin_episode) + + transferinfo.total_size = total_size + # 汇总处理文件数量 + transferinfo.file_count = file_count + + # 剧集季集信息 S01 E01-E04 || S01 E01、E02、E04 + season_episode = None + # 处理文件多,说明是剧集,显示季入库消息 + if mediainfo.type == MediaType.TV: + # 季集文本 + season_episode = f"{file_meta.season} {StringUtils.format_ep(episodes)}" + # 发送消息 + self.transferchian.send_transfer_message(meta=file_meta, + mediainfo=mediainfo, + transferinfo=transferinfo, + season_episode=season_episode) + # 发送完消息,移出key + del self._medias[medis_title_year_season] + continue + + def get_state(self) -> bool: + return self._enabled + + 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 and self._cron: + return [{ + "id": "vcbanimemonitor", + "name": "vcbanimemonitor", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self.sync_all, + "kwargs": {} + }] + return [] + + def sync(self) -> schemas.Response: + """ + API调用目录同步 + """ + self.sync_all() + return schemas.Response(success=True) + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_command(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '发送通知', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'ova', + 'label': '开启识别OVA/OAD文件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'scrape', + 'label': '刮削元数据', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'mode', + 'label': '监控模式', + 'items': [ + {'title': '兼容模式', 'value': 'compatibility'}, + {'title': '性能模式', 'value': 'fast'} + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'transfer_type', + 'label': '转移方式', + 'items': [ + {'title': '移动', 'value': 'move'}, + {'title': '复制', 'value': 'copy'}, + {'title': '硬链接', 'value': 'link'}, + {'title': '软链接', 'value': 'softlink'}, + {'title': 'Rclone复制', 'value': 'rclone_copy'}, + {'title': 'Rclone移动', 'value': 'rclone_move'} + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'interval', + 'label': '入库消息延迟', + 'placeholder': '10' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '定时全量同步周期', + 'placeholder': '5位cron表达式,留空关闭' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'size', + 'label': '监控文件大小(GB)', + 'placeholder': '0' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'torrents_path', + 'label': '监控种子目录', + 'placeholder': '填入路径代表启用' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'monitor_dirs', + 'label': '监控目录', + 'rows': 4, + 'placeholder': '每一行一个目录,支持以下几种配置方式,转移方式支持 move、copy、link、softlink、rclone_copy、rclone_move:\n' + '监控目录\n' + '监控目录#转移方式\n' + '监控目录:转移目的目录\n' + '监控目录:转移目的目录#转移方式' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'exclude_keywords', + 'label': '排除关键词', + 'rows': 2, + 'placeholder': '每一行一个关键词' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '核心用法与目录同步插件相同,不同点在于只识别处理VCB-Studio资源。' + '默认不处理SPs、CDs、SCans目录下的文件,OVA/OAD集数暂时根据入库顺序累加命名,' + '因此不保证与TMDB集数匹配。部分季度以罗马音音译为名的作品暂时无法识别出准确季度。' + '有想法,有问题欢迎点击插件作者主页提issue!' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '最佳使用方式:监控目录单独设置一个作为保存VCB-Studio资源的目录,' + '填入监控种子目录,开启后会将正在QB(仅支持QB)下载器内正在下载的VCB-Studio资源转移到监控目录实现自动整理(' + '仅支持第一个监控目录),' + '监控种子目录为空则不转移文件' + } + } + ] + } + ] + }, + ] + }, + ], { + "enabled": False, + "notify": False, + "onlyonce": False, + "mode": "fast", + "transfer_type": settings.TRANSFER_TYPE, + "monitor_dirs": "", + "exclude_keywords": "", + "interval": 10, + "cron": "", + "size": 0, + "ova": False, + "torrents_path": "", + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + if self._observer: + for observer in self._observer: + try: + observer.stop() + observer.join() + except Exception as e: + print(str(e)) + self._observer = [] + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._event.set() + self._scheduler.shutdown() + self._event.clear() + self._scheduler = None diff --git a/plugins.v2/vcbanimemonitor/remeta.py b/plugins.v2/vcbanimemonitor/remeta.py new file mode 100644 index 0000000..ea261eb --- /dev/null +++ b/plugins.v2/vcbanimemonitor/remeta.py @@ -0,0 +1,284 @@ +import concurrent +import re +from dataclasses import dataclass +from pathlib import Path +from typing import List +from app.chain.media import MediaChain +from app.chain.tmdb import TmdbChain +from app.core.metainfo import MetaInfoPath +from app.log import logger +from app.schemas import MediaType + +season_patterns = [ + {"pattern": re.compile(r"S(\d+)$", re.IGNORECASE), "group": 1}, + {"pattern": re.compile(r"(\d+)$", re.IGNORECASE), "group": 1}, + {"pattern": re.compile(r"(\d+)(st|nd|rd|th)?\s*season", re.IGNORECASE), "group": 1}, + {"pattern": re.compile(r"(.*) ?\s*season (\d+)", re.IGNORECASE), "group": 2}, + {"pattern": re.compile(r"\s(II|III|IV|V|VI|VII|VIII|IX|X)$", re.IGNORECASE), "group": "1"} +] +episode_patterns = [ + {"pattern": re.compile(r"(\d+)\((\d+)\)", re.IGNORECASE), "group": 2}, + {"pattern": re.compile(r"(\d+)", re.IGNORECASE), "group": 1}, + {"pattern": re.compile(r'(\d+)v\d+', re.IGNORECASE), "group": 1}, +] + +ova_patterns = [ + re.compile(r".*?(OVA|OAD).*?", re.IGNORECASE), + re.compile(r"\d+\.5"), + re.compile(r"00") +] + +final_season_patterns = [ + re.compile('final season', re.IGNORECASE), + re.compile('The Final', re.IGNORECASE), + re.compile(r'\sFinal') +] + +movie_patterns = [ + re.compile("Movie", re.IGNORECASE), + re.compile("the Movie", re.IGNORECASE), +] + + +@dataclass +class VCBMetaBase: + # 转化为小写后的原始文件名称 (不含后缀) + original_title: str = "" + # 解析后不包含季度和集数的标题 + title: str = "" + # 类型:TV / Movie (默认TV) + type: str = "TV" + # 可能含有季度的标题,一级解析后的标题 + season_title: str = "" + # 可能含有集数的字符串列表 + ep_title: List[str] = None + # 识别出来的季度 + season: int = None + # 识别出来的集数 + ep: int = None + # 是否是OVA/OAD + is_ova: bool = False + # TMDB ID + tmdb_id: int = None + + +blocked_words = ["vcb-studio", "360p", "480p", "720p", "1080p", "2160p", "hdr", "x265", "x264", "aac", "flac"] + + +class ReMeta: + + def __init__(self, ova_switch: bool = False, custom_season_patterns: list[dict] = None): + self.meta = None + # TODO:自定义季度匹配规则 + self.custom_season_patterns = custom_season_patterns + self.season_patterns = season_patterns + self.ova_switch = ova_switch + self.vcb_meta = VCBMetaBase() + self.is_ova = False + + def is_tv(self, title: str) -> bool: + """ + 判断是否是TV + """ + if title.count("[") != 4 and title.count("]") != 4: + self.vcb_meta.type = "Movie" + self.vcb_meta.title = re.sub(r'\[.*?\]', '', title).strip() + return False + return True + + def handel_file(self, file_path: Path): + file_name = file_path.stem.strip().lower() + self.vcb_meta.original_title = file_name + if not self.is_tv(file_name): + logger.warn( + "不符合VCB-Studio的剧集命名规范,归类为电影,跳过剧集模块处理。注意:年份较为久远的作品可能在此会判断错误") + self.parse_movie() + else: + self.tv_mode() + self.is_ova = self.vcb_meta.is_ova + meta = MetaInfoPath(file_path) + meta.title = self.vcb_meta.title + meta.en_name = self.vcb_meta.title + if self.vcb_meta.type == "Movie": + meta.type = MediaType.MOVIE + else: + meta.type = MediaType.TV + if self.vcb_meta.ep is not None: + meta.begin_episode = self.vcb_meta.ep + if self.vcb_meta.season is not None: + meta.begin_season = self.vcb_meta.season + if self.vcb_meta.tmdb_id is not None: + meta.tmdbid = self.vcb_meta.tmdb_id + return meta + + def split_season_ep(self): + # 把所有的[] 里面的内容获取出来,不需要[]本身 + self.vcb_meta.ep_title = re.findall(r'\[(.*?)\]', self.vcb_meta.original_title) + # 去除所有[]后只剩下剧名 + self.vcb_meta.season_title = re.sub(r"\[.*?\]", "", self.vcb_meta.original_title).strip() + if self.vcb_meta.ep_title: + self.culling_blocked_words() + logger.info( + f"分离出包含可能季度的内容部分:{self.vcb_meta.season_title} | 可能包含集数的内容部分: {self.vcb_meta.ep_title}") + self.vcb_meta.title = self.vcb_meta.season_title + if not self.vcb_meta.ep_title: + self.vcb_meta.title = self.vcb_meta.season_title + logger.warn("未识别出可能存在集数位置的信息,跳过剩余识别步骤!") + + def tv_mode(self): + logger.info("开始分离季度和集数部分") + self.split_season_ep() + if not self.vcb_meta.ep_title: + return + self.parse_season() + self.parse_episode() + + def parse_season(self): + """ + 从标题中解析季度 + """ + flag = False + for pattern in season_patterns: + match = pattern["pattern"].search(self.vcb_meta.season_title) + if match: + if isinstance(pattern["group"], int): + self.vcb_meta.season = int(match.group(pattern["group"])) + else: + self.vcb_meta.season = self.roman_to_int(match.group(pattern["group"])) + # 匹配成功后,标题中去除季度信息 + self.vcb_meta.title = pattern["pattern"].sub("", self.vcb_meta.season_title).strip + logger.info(f"识别出季度为{self.vcb_meta.season}") + return + logger.info(f"正常匹配季度失败,开始匹配ova/oad/最终季度") + if not flag: + # 匹配是否为最终季 + for pattern in final_season_patterns: + if pattern.search(self.vcb_meta.season_title): + logger.info("命中到最终季匹配规则") + self.vcb_meta.title = pattern.sub("", self.vcb_meta.season_title).strip() + self.handle_final_season() + return + logger.info("未识别出最终季度,开始匹配OVA/OAD") + # 匹配是否为OVA/OAD + if "ova" in self.vcb_meta.season_title or "oad" in self.vcb_meta.season_title: + logger.info("季度部分命中到OVA/OAD匹配规则") + if self.ova_switch: + logger.info("开启OVA/OAD处理逻辑") + self.vcb_meta.is_ova = True + for pattern in ova_patterns: + if pattern.search(self.vcb_meta.season_title): + self.vcb_meta.title = pattern.sub("", self.vcb_meta.season_title).strip() + self.vcb_meta.title = re.sub("ova|oad", "", self.vcb_meta.season_title).strip() + self.vcb_meta.season = 0 + return + logger.warn("未识别出季度,默认处理逻辑返回第一季") + self.vcb_meta.title = self.vcb_meta.season_title + self.vcb_meta.season = 1 + + def parse_episode(self): + """ + 从标题中解析集数 + """ + # 从ep_title中剔除不相关的内容之后只剩下存在集数的字符串 + ep = self.vcb_meta.ep_title[0] + for pattern in episode_patterns: + match = pattern["pattern"].search(ep) + if match: + self.vcb_meta.ep = int(match.group(pattern["group"])) + logger.info(f"识别出集数为{self.vcb_meta.ep}") + return + # 直接进入判断是否为OVA/OAD + for pattern in ova_patterns: + if pattern.search(ep): + self.vcb_meta.is_ova = True + # 直接获取数字 + self.vcb_meta.ep = int(re.search(r"\d+", ep).group()) or 1 + logger.info(f"OVA模式下识别出集数为{self.vcb_meta.ep}") + self.vcb_meta.season = 0 + return + + def culling_blocked_words(self): + """ + 从ep_title中剔除不相关的内容 + """ + blocked_set = set(blocked_words) # 将阻止词列表转换为集合 + result = [ep for ep in self.vcb_meta.ep_title if not any(word in ep for word in blocked_set)] + self.vcb_meta.ep_title = result + + def handle_final_season(self): + + _, medias = MediaChain().search(title=self.vcb_meta.title) + if not medias: + logger.warning("匹配到最终季时无法找到对应的媒体信息!季度返回默认值:1") + self.vcb_meta.season = 1 + return + + filter_medias = [media for media in medias if media.type == MediaType.TV] + if not filter_medias: + logger.warning("匹配到最终季时无法找到对应的媒体信息!季度返回默认值:1") + self.vcb_meta.season = 1 + return + medias = [media for media in filter_medias if media.popularity or media.vote_average] + if not medias: + logger.warning("匹配到最终季时无法找到对应的媒体信息!季度返回默认值:1") + self.vcb_meta.season = 1 + return + # 获取欢迎度最高或者评分最高的媒体 + medias_sorted = sorted(medias, key=lambda x: x.popularity or x.vote_average, reverse=True)[0] + self.vcb_meta.tmdb_id = medias_sorted.tmdb_id + if medias_sorted.tmdb_id: + seasons_info = TmdbChain().tmdb_seasons(tmdbid=medias_sorted.tmdb_id) + if seasons_info: + self.vcb_meta.season = len(seasons_info) + logger.info(f"获取到最终季度,季度为{self.vcb_meta.season}") + return + logger.warning("无法获取到最终季度信息,季度返回默认值:1") + self.vcb_meta.season = 1 + + + + def parse_movie(self): + logger.info("开始尝试剧场版模式解析") + for pattern in movie_patterns: + if pattern.search(self.vcb_meta.title): + logger.info("命中剧场版匹配规则,加上剧场版标识辅助识别") + self.vcb_meta.type = "Movie" + self.vcb_meta.title = pattern.sub("", self.vcb_meta.title).strip() + self.vcb_meta.title = self.vcb_meta.title + return + + def find_ova_episode(self): + """ + 搜索OVA的集数 + TODO:模糊匹配OVA的集数 + """ + pass + + + @staticmethod + def roman_to_int(s) -> int: + """ + :param s: 罗马数字字符串 + 罗马数字转整数 + """ + roman_dict = {'I': 1, 'V': 5, 'X': 10, 'L': 50, 'C': 100, 'D': 500, 'M': 1000} + total = 0 + prev_value = 0 + + for char in reversed(s): # 反向遍历罗马数字字符串 + current_value = roman_dict[char] + if current_value >= prev_value: + total += current_value # 如果当前值大于等于前一个值,加上当前值 + else: + total -= current_value # 如果当前值小于前一个值,减去当前值 + prev_value = current_value + + return total + + + +# if __name__ == '__main__': +# ReMeta( +# ova_switch=True, +# ).handel_file(Path( +# r"[Airota&Nekomoe kissaten&VCB-Studio] Yuru Camp [Heya Camp EP00][Ma10p_1080p][x265_flac].mkv")) diff --git a/plugins/removelink/__init__.py b/plugins/removelink/__init__.py index df8bb29..f34419b 100644 --- a/plugins/removelink/__init__.py +++ b/plugins/removelink/__init__.py @@ -497,7 +497,6 @@ class RemoveLink(_PluginBase): self._transferhistory.delete(transfer_history.id) logger.info(f"删除历史记录:{transfer_history.id}") - def delete_empty_folders(self, path): """ 从指定路径开始,逐级向上层目录检测并删除空目录,直到遇到非空目录或到达指定监控目录为止 @@ -589,7 +588,7 @@ class RemoveLink(_PluginBase): mtype=NotificationType.SiteMessage, title=f"【清理硬链接】", text=f"监控到删除源文件:[{file_path}]\n" - f"同步删除硬链接文件:[{path}]", + f"同步删除硬链接文件:[{path}]", ) except Exception as e: logger.error( From eab8d9d4a76a39a8829203459d45a192bf5a28c0 Mon Sep 17 00:00:00 2001 From: Attente <19653207+wikrin@users.noreply.github.com> Date: Sat, 28 Sep 2024 00:16:57 +0800 Subject: [PATCH 056/218] =?UTF-8?q?fix:=20=E5=B7=B2=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E8=A2=AB=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E6=B7=BB=E5=8A=A0=E8=80=8C=E4=BA=92=E7=9B=B8?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 ++- plugins/bangumicoll/__init__.py | 23 ++++++++++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 9a6f9e7..651a73d 100644 --- a/package.json +++ b/package.json @@ -3,11 +3,12 @@ "name": "Bangumi收藏订阅", "description": "Bangumi用户收藏添加到订阅", "labels": "订阅", - "version": "1.1", + "version": "1.2", "icon": "https://raw.githubusercontent.com/wikrin/MoviePilot-Plugins/main/icons/bangumi_b.png", "author": "Attente", "level": 2, "history": { + "v1.2": "修复已完成订阅条目重复添加的问题", "v1.1": "新增根据收藏状态移除由此插件添加的订阅", "v1.0": "将bangumi用户收藏添加到 MP 订阅,部分功能未实现" } diff --git a/plugins/bangumicoll/__init__.py b/plugins/bangumicoll/__init__.py index 52a0dbc..adddc34 100644 --- a/plugins/bangumicoll/__init__.py +++ b/plugins/bangumicoll/__init__.py @@ -16,6 +16,9 @@ from app.utils.http import RequestUtils from app.db.subscribe_oper import SubscribeOper from app.helper.subscribe import SubscribeHelper from app.schemas.types import NotificationType +from app.db import db_query +from app.db.models.subscribehistory import SubscribeHistory +from sqlalchemy.orm import Session class BangumiColl(_PluginBase): @@ -26,7 +29,7 @@ class BangumiColl(_PluginBase): # 插件图标 plugin_icon = "https://raw.githubusercontent.com/wikrin/MoviePilot-Plugins/main/icons/bangumi_b.png" # 插件版本 - plugin_version = "1.1" + plugin_version = "1.2" # 插件作者 plugin_author = "Attente" # 作者主页 @@ -421,8 +424,10 @@ class BangumiColl(_PluginBase): for i in self.subscribechain.subscribeoper.list() if i.bangumiid and i.username == "Bangumi订阅" } + ## 获取历史订阅 + db_hist = self.get_subscribe_history() # 新增条目 - new_sub = items.keys() - db_sub.keys() + new_sub = items.keys() - db_sub.keys() - db_hist logger.debug(f"待新增条目:{new_sub}") # 移除条目 del_sub = db_sub.keys() - items.keys() @@ -481,7 +486,7 @@ class BangumiColl(_PluginBase): # 额外参数 kwargs = { "save_path": self._save_path, - "sites": self._sites, + "sites": str(self._sites), } # 添加到订阅 self.subscribechain.add( @@ -527,8 +532,7 @@ class BangumiColl(_PluginBase): def are_dates(date_str1, date_str2, threshold_days: int = 7) -> bool: """ 对比两个日期字符串是否接近 - :param date_str1: 第一个日期字符串,格式为'YYYY-MM-DD' - :param date_str2: 第二个日期字符串,格式为'YYYY-MM-DD' + :param date_str: 日期字符串,格式为'YYYY-MM-DD' :param threshold_days: 阈值天数,默认为7天 :return: 如果两个日期之间的差异小于等于阈值天数,则返回True,否则返回False """ @@ -544,3 +548,12 @@ class BangumiColl(_PluginBase): # 比较差异和阈值 return delta <= threshold + + @db_query + def get_subscribe_history(self, db: Session = None) -> set: + ''' + 获取已完成的订阅 + ''' + result = db.query(SubscribeHistory).filter(SubscribeHistory.bangumiid != None).all() + return set([i.bangumiid for i in result]) + \ No newline at end of file From c0cb4dbd1175e3dfa242fbe62e77d9f56e4b3061 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Mon, 30 Sep 2024 10:32:18 +0800 Subject: [PATCH 057/218] fix SiteStatistic --- package.v2.json | 4 +- plugins.v2/sitestatistic/__init__.py | 743 ++---------------- .../sitestatistic/siteuserinfo/__init__.py | 428 ---------- .../sitestatistic/siteuserinfo/discuz.py | 139 ---- .../sitestatistic/siteuserinfo/file_list.py | 127 --- .../sitestatistic/siteuserinfo/gazelle.py | 163 ---- .../sitestatistic/siteuserinfo/ipt_project.py | 93 --- .../sitestatistic/siteuserinfo/mtorrent.py | 200 ----- .../siteuserinfo/nexus_audiences.py | 22 - .../siteuserinfo/nexus_hhanclub.py | 61 -- .../sitestatistic/siteuserinfo/nexus_php.py | 404 ---------- .../siteuserinfo/nexus_project.py | 24 - .../siteuserinfo/nexus_rabbit.py | 57 -- .../sitestatistic/siteuserinfo/small_horse.py | 110 --- .../sitestatistic/siteuserinfo/tnode.py | 103 --- .../siteuserinfo/torrent_leech.py | 109 --- .../sitestatistic/siteuserinfo/unit3d.py | 130 --- plugins.v2/sitestatistic/siteuserinfo/yema.py | 113 --- 18 files changed, 79 insertions(+), 2951 deletions(-) delete mode 100644 plugins.v2/sitestatistic/siteuserinfo/__init__.py delete mode 100644 plugins.v2/sitestatistic/siteuserinfo/discuz.py delete mode 100644 plugins.v2/sitestatistic/siteuserinfo/file_list.py delete mode 100644 plugins.v2/sitestatistic/siteuserinfo/gazelle.py delete mode 100644 plugins.v2/sitestatistic/siteuserinfo/ipt_project.py delete mode 100644 plugins.v2/sitestatistic/siteuserinfo/mtorrent.py delete mode 100644 plugins.v2/sitestatistic/siteuserinfo/nexus_audiences.py delete mode 100644 plugins.v2/sitestatistic/siteuserinfo/nexus_hhanclub.py delete mode 100644 plugins.v2/sitestatistic/siteuserinfo/nexus_php.py delete mode 100644 plugins.v2/sitestatistic/siteuserinfo/nexus_project.py delete mode 100644 plugins.v2/sitestatistic/siteuserinfo/nexus_rabbit.py delete mode 100644 plugins.v2/sitestatistic/siteuserinfo/small_horse.py delete mode 100644 plugins.v2/sitestatistic/siteuserinfo/tnode.py delete mode 100644 plugins.v2/sitestatistic/siteuserinfo/torrent_leech.py delete mode 100644 plugins.v2/sitestatistic/siteuserinfo/unit3d.py delete mode 100644 plugins.v2/sitestatistic/siteuserinfo/yema.py diff --git a/package.v2.json b/package.v2.json index bdf7828..7bedbe4 100644 --- a/package.v2.json +++ b/package.v2.json @@ -3,12 +3,12 @@ "name": "站点数据统计", "description": "站点统计数据图表。", "labels": "站点,仪表板", - "version": "1.0.0", + "version": "1.0.1", "icon": "statistic.png", "author": "lightolly,jxxghp", "level": 2, "history": { - "v1.0.0": "MoviePilot V2 版本站点数据统计插件" + "v1.0.1": "MoviePilot V2 版本站点数据统计插件" } } } \ No newline at end of file diff --git a/plugins.v2/sitestatistic/__init__.py b/plugins.v2/sitestatistic/__init__.py index 2c5622a..f1c5d2e 100644 --- a/plugins.v2/sitestatistic/__init__.py +++ b/plugins.v2/sitestatistic/__init__.py @@ -1,33 +1,19 @@ -import json -import re +import time import warnings -from datetime import datetime, timedelta -from multiprocessing.dummy import Pool as ThreadPool from threading import Lock from typing import Optional, Any, List, Dict, Tuple -import pytz -import requests -from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.triggers.cron import CronTrigger -from ruamel.yaml import CommentedMap - from app import schemas +from app.chain.site import SiteChain from app.core.config import settings from app.core.event import Event, eventmanager -from app.db.models import PluginData +from app.db.models.siteuserdata import SiteUserData from app.db.site_oper import SiteOper -from app.helper.browser import PlaywrightHelper -from app.helper.module import ModuleHelper from app.helper.sites import SitesHelper from app.log import logger from app.plugins import _PluginBase -from app.plugins.sitestatistic.siteuserinfo import ISiteUserInfo -from app.schemas.types import EventType, NotificationType -from app.utils.http import RequestUtils -from app.utils.object import ObjectUtils +from app.schemas.types import EventType from app.utils.string import StringUtils -from app.utils.timer import TimerUtils warnings.filterwarnings("ignore", category=FutureWarning) @@ -42,7 +28,7 @@ class SiteStatistic(_PluginBase): # 插件图标 plugin_icon = "statistic.png" # 插件版本 - plugin_version = "1.0.0" + plugin_version = "1.0.1" # 插件作者 plugin_author = "lightolly,jxxghp" # 作者主页 @@ -54,29 +40,17 @@ class SiteStatistic(_PluginBase): # 可使用的用户级别 auth_level = 2 - # 私有属性 - sites = None - siteoper = None - _scheduler: Optional[BackgroundScheduler] = None - _last_update_time: Optional[datetime] = None - _sites_data: dict = {} - _site_schema: List[ISiteUserInfo] = None - # 配置属性 + siteoper = None + siteshelper = None _enabled: bool = False _onlyonce: bool = False - _sitemsg: bool = True - _cron: str = "" - _notify: bool = False - _queue_cnt: int = 5 - _remove_failed: bool = False - _statistic_type: str = None - _statistic_sites: list = [] _dashboard_type: str = "today" def init_plugin(self, config: dict = None): - self.sites = SitesHelper() self.siteoper = SiteOper() + self.siteshelper = SitesHelper() + # 停止现有任务 self.stop_service() @@ -84,51 +58,12 @@ class SiteStatistic(_PluginBase): if config: self._enabled = config.get("enabled") self._onlyonce = config.get("onlyonce") - self._cron = config.get("cron") - 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 [] self._dashboard_type = config.get("dashboard_type") or "today" - # 过滤掉已删除的站点 - all_sites = [site.id for site in self.siteoper.list_order_by_pri()] + [site.get("id") for site in - self.__custom_sites()] - self._statistic_sites = [site_id for site_id in all_sites if site_id in self._statistic_sites] - self.__update_config() - if self._enabled or self._onlyonce: - # 加载模块 - self._site_schema = ModuleHelper.load('app.plugins.sitestatistic.siteuserinfo', - filter_func=lambda _, obj: hasattr(obj, 'schema')) - - self._site_schema.sort(key=lambda x: x.order) - # 站点上一次更新时间 - self._last_update_time = None - # 站点数据 - self._sites_data = {} - # 立即运行一次 if self._onlyonce: - # 定时服务 - self._scheduler = BackgroundScheduler(timezone=settings.TZ) - logger.info(f"站点数据统计服务启动,立即运行一次") - self._scheduler.add_job(self.refresh_all_site_data, 'date', - run_date=datetime.now( - tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3) - ) - # 关闭一次性开关 - self._onlyonce = False - - # 保存配置 - self.__update_config() - - # 启动任务 - if self._scheduler.get_jobs(): - self._scheduler.print_jobs() - self._scheduler.start() + SiteChain().refresh_userdata() def get_state(self) -> bool: return self._enabled @@ -168,57 +103,12 @@ class SiteStatistic(_PluginBase): }] 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 and self._cron: - return [{ - "id": "SiteStatistic", - "name": "站点数据统计服务", - "trigger": CronTrigger.from_crontab(self._cron), - "func": self.refresh_all_site_data, - "kwargs": {} - }] - elif 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"SiteStatistic|{trigger.hour}:{trigger.minute}", - "name": "站点数据统计服务", - "trigger": "cron", - "func": self.refresh_all_site_data, - "kwargs": { - "hour": trigger.hour, - "minute": trigger.minute - } - }) - return ret_jobs - return [] + pass def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: """ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 """ - # 站点的可选项(内置站点 + 自定义站点) - customSites = self.__custom_sites() - - site_options = ([{"title": site.name, "value": site.id} - for site in self.siteoper.list_order_by_pri()] - + [{"title": site.get("name"), "value": site.get("id")} - for site in customSites]) - return [ { 'component': 'VForm', @@ -242,22 +132,6 @@ class SiteStatistic(_PluginBase): } ] }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'notify', - 'label': '发送通知', - } - } - ] - }, { 'component': 'VCol', 'props': { @@ -283,60 +157,7 @@ class SiteStatistic(_PluginBase): 'component': 'VCol', 'props': { 'cols': 12, - 'md': 3 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'cron', - 'label': '执行周期', - 'placeholder': '5位cron表达式,留空自动' - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 3 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'queue_cnt', - 'label': '队列数量' - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 3 - }, - 'content': [ - { - 'component': 'VSelect', - 'props': { - 'model': 'statistic_type', - 'label': '统计类型', - 'items': [ - {'title': '全量', 'value': 'all'}, - {'title': '增量', 'value': 'add'} - ] - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 3 + 'md': 6 }, 'content': [ { @@ -354,112 +175,47 @@ class SiteStatistic(_PluginBase): ] } ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'content': [ - { - 'component': 'VSelect', - 'props': { - 'chips': True, - 'multiple': True, - 'model': 'statistic_sites', - 'label': '统计站点', - 'items': site_options - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'sitemsg', - 'label': '站点未读消息', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'remove_failed', - 'label': '移除失效站点', - } - } - ] - }, - ] } ] } ], { "enabled": False, "onlyonce": False, - "notify": True, - "sitemsg": True, - "cron": "5 1 * * *", - "queue_cnt": 5, - "remove_failed": False, - "statistic_type": "all", - "statistic_sites": [], "dashboard_type": 'today' } - def __get_data(self) -> Tuple[str, dict, dict]: + def __get_data(self) -> Tuple[str, List[SiteUserData], List[SiteUserData]]: """ 获取今天的日期、今天的站点数据、昨天的站点数据 """ - # 最近一天的签到数据 - stattistic_data: Dict[str, Dict[str, Any]] = {} + # 最近一天的数据 + stattistic_data: List[SiteUserData] = [] # 昨天数据 - yesterday_sites_data: Dict[str, Dict[str, Any]] = {} + yesterday_sites_data: List[SiteUserData] = [] # 获取最近所有数据 - data_list: List[PluginData] = self.get_data(key=None) + data_list: List[SiteUserData] = self.siteoper.get_userdata() if not data_list: - return "", {}, {} - # 取key符合日期格式的数据 - data_list = [data for data in data_list if re.match(r"\d{4}-\d{2}-\d{2}", data.key)] + return "", [], [] + # 每个日期只保留最后一条数据 + data_list = list({data.updated_day: data for data in data_list}.values()) # 按日期倒序排序 - data_list.sort(key=lambda x: x.key, reverse=True) + data_list.sort(key=lambda x: x.updated_day, reverse=True) # 今天的日期 - today = data_list[0].key - # 数据按时间降序排序 - datas = [json.loads(data.value) for data in data_list if ObjectUtils.is_obj(data.value)] + today = time.strftime('%Y-%m-%d', time.localtime()) if len(data_list) > 0: - stattistic_data = datas[0] + today = data_list[0].updated_day + stattistic_data = [data for data in data_list if data.updated_day == today] if len(data_list) > 1: - yesterday_sites_data = datas[1] + yestoday = data_list[1].updated_day + yesterday_sites_data = [data for data in data_list if data.updated_day == yestoday] + + # 今日数据按数据量降序排序 + stattistic_data.sort(key=lambda x: x.upload, reverse=True) - # 数据按时间降序排序 - stattistic_data = dict(sorted(stattistic_data.items(), - key=lambda item: item[1].get('upload') or 0, - reverse=True)) return today, stattistic_data, yesterday_sites_data @staticmethod - def __get_total_elements(today: str, stattistic_data: dict, yesterday_sites_data: dict, + def __get_total_elements(today: str, stattistic_data: List[SiteUserData], yesterday_sites_data: List[SiteUserData], dashboard: str = "today") -> List[dict]: """ 获取统计元素 @@ -473,7 +229,7 @@ class SiteStatistic(_PluginBase): return 0 return round(float(value) / 1024 / 1024 / 1024, 1) - def __sub_dict(d1: dict, d2: dict) -> dict: + def __sub_data(d1: dict, d2: dict) -> dict: """ 计算两个字典相同Key值的差值(如果值为数字),返回新字典 """ @@ -491,17 +247,13 @@ class SiteStatistic(_PluginBase): if dashboard in ['total', 'all']: # 总上传量 - total_upload = sum([int(data.get("upload")) - for data in stattistic_data.values() if data.get("upload")]) + total_upload = sum([data.upload for data in stattistic_data if data.upload]) # 总下载量 - total_download = sum([int(data.get("download")) - for data in stattistic_data.values() if data.get("download")]) + total_download = sum([data.download for data in stattistic_data if data.download]) # 总做种数 - total_seed = sum([int(data.get("seeding")) - for data in stattistic_data.values() if data.get("seeding")]) + total_seed = sum([data.seeding for data in stattistic_data if data.seeding]) # 总做种体积 - total_seed_size = sum([int(data.get("seeding_size")) - for data in stattistic_data.values() if data.get("seeding_size")]) + total_seed_size = sum([data.seeding_size for data in stattistic_data if data.seeding_size]) total_elements = [ # 总上传量 @@ -787,10 +539,15 @@ class SiteStatistic(_PluginBase): if dashboard in ["today", "all"]: # 计算增量数据集 inc_data = {} - for site, data in stattistic_data.items(): - inc = __sub_dict(data, yesterday_sites_data.get(site)) + for data in stattistic_data: + yesterday_datas = [yd for yd in yesterday_sites_data if yd.domain == data.domain] + if yesterday_datas: + yesterday_data = yesterday_datas[0] + else: + yesterday_data = None + inc = __sub_data(data.to_dict(), yesterday_data.to_dict() if yesterday_data else None) if inc: - inc_data[site] = inc + inc_data[data.name] = inc # 今日上传 uploads = {k: v for k, v in inc_data.items() if v.get("upload")} # 今日上传站点 @@ -889,7 +646,7 @@ class SiteStatistic(_PluginBase): # 合并返回 return total_elements + today_elements - def get_dashboard(self) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], List[dict]]]: + def get_dashboard(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], List[dict]]]: """ 获取插件仪表盘页面,需要返回:1、仪表板col配置字典;2、仪表板页面元素配置json(含数据);3、全局配置(自动刷新等) 1、col配置参考: @@ -970,48 +727,48 @@ class SiteStatistic(_PluginBase): 'props': { 'class': 'whitespace-nowrap break-keep text-high-emphasis' }, - 'text': site + 'text': data.name }, { 'component': 'td', - 'text': data.get("username") + 'text': data.username }, { 'component': 'td', - 'text': data.get("user_level") + 'text': data.user_level }, { 'component': 'td', 'props': { 'class': 'text-success' }, - 'text': StringUtils.str_filesize(data.get("upload")) + 'text': StringUtils.str_filesize(data.upload) }, { 'component': 'td', 'props': { 'class': 'text-error' }, - 'text': StringUtils.str_filesize(data.get("download")) + 'text': StringUtils.str_filesize(data.download) }, { 'component': 'td', - 'text': data.get('ratio') + 'text': data.ratio }, { 'component': 'td', - 'text': format_bonus(data.get('bonus') or 0) + 'text': format_bonus(data.bonus or 0) }, { 'component': 'td', - 'text': data.get('seeding') + 'text': data.seeding }, { 'component': 'td', - 'text': StringUtils.str_filesize(data.get('seeding_size')) + 'text': StringUtils.str_filesize(data.seeding_size) } ] - } for site, data in stattistic_data.items() if not data.get("err_msg") + } for data in stattistic_data ] # 拼装页面 @@ -1113,239 +870,7 @@ class SiteStatistic(_PluginBase): ] def stop_service(self): - """ - 退出插件 - """ - try: - if self._scheduler: - self._scheduler.remove_all_jobs() - if self._scheduler.running: - self._scheduler.shutdown() - self._scheduler = None - except Exception as e: - logger.error("退出插件失败:%s" % str(e)) - - def __build_class(self, html_text: str) -> Any: - for site_schema in self._site_schema: - try: - if site_schema.match(html_text): - return site_schema - except Exception as e: - logger.error(f"站点匹配失败 {str(e)}") - return None - - def build(self, site_info: CommentedMap) -> Optional[ISiteUserInfo]: - """ - 构建站点信息 - """ - site_name = site_info.get("name") - site_cookie = site_info.get("cookie") - apikey = site_info.get("apikey") - token = site_info.get("token") - if not site_cookie and not apikey and not token: - return None - url = site_info.get("url") - proxy = site_info.get("proxy") - ua = site_info.get("ua") - # 会话管理 - with requests.Session() as session: - proxies = settings.PROXY if proxy else None - proxy_server = settings.PROXY_SERVER if proxy else None - render = site_info.get("render") - logger.debug(f"站点 {site_name} url={url},site_cookie={site_cookie},ua={ua},api_key={apikey},token={token},proxy={proxy}") - if render: - # 演染模式 - html_text = PlaywrightHelper().get_page_source(url=url, - cookies=site_cookie, - ua=ua, - proxies=proxy_server) - else: - # 普通模式 - res = RequestUtils(cookies=site_cookie, - session=session, - ua=ua, - proxies=proxies - ).get_res(url=url) - if res and res.status_code == 200: - if re.search(r"charset=\"?utf-8\"?", res.text, re.IGNORECASE): - res.encoding = "utf-8" - else: - res.encoding = res.apparent_encoding - html_text = res.text - # 第一次登录反爬 - if html_text.find("title") == -1: - i = html_text.find("window.location") - if i == -1: - return None - tmp_url = url + html_text[i:html_text.find(";")] \ - .replace("\"", "") \ - .replace("+", "") \ - .replace(" ", "") \ - .replace("window.location=", "") - res = RequestUtils(cookies=site_cookie, - session=session, - ua=ua, - proxies=proxies - ).get_res(url=tmp_url) - if res and res.status_code == 200: - if "charset=utf-8" in res.text or "charset=UTF-8" in res.text: - res.encoding = "UTF-8" - else: - res.encoding = res.apparent_encoding - html_text = res.text - if not html_text: - return None - elif res is not None: - logger.error("站点 %s 被反爬限制:%s, 状态码:%s" % (site_name, url, res.status_code)) - return None - else: - logger.error("站点 %s 无法访问:%s" % (site_name, url)) - return None - - # 兼容假首页情况,假首页通常没有 schemas.Response: - """ - 刷新一个站点数据,可由API调用 - """ - if apikey != settings.API_TOKEN: - return schemas.Response(success=False, message="API密钥错误") - site_info = self.sites.get_indexer(domain) - if site_info: - site_data = self.__refresh_site_data(site_info) - if site_data: - return schemas.Response( - success=True, - message=f"站点 {domain} 刷新成功", - data=site_data.to_dict() - ) - return schemas.Response( - success=False, - message=f"站点 {domain} 刷新数据失败,未获取到数据" - ) - return schemas.Response( - success=False, - message=f"站点 {domain} 不存在" - ) - - def __refresh_site_data(self, site_info: CommentedMap) -> Optional[ISiteUserInfo]: - """ - 更新单个site 数据信息 - :param site_info: - :return: - """ - site_name = site_info.get('name') - site_url = site_info.get('url') - if not site_url: - return None - unread_msg_notify = True - try: - site_user_info: ISiteUserInfo = self.build(site_info=site_info) - if site_user_info: - logger.debug(f"站点 {site_name} 开始以 {site_user_info.site_schema()} 模型解析") - # 开始解析 - site_user_info.parse() - logger.debug(f"站点 {site_name} 解析完成") - - # 获取不到数据时,仅返回错误信息,不做历史数据更新 - if site_user_info.err_msg: - self._sites_data.update({site_name: {"err_msg": site_user_info.err_msg}}) - return None - - if self._sitemsg: - # 发送通知,存在未读消息 - self.__notify_unread_msg(site_name, site_user_info, unread_msg_notify) - - # 分享率接近1时,发送消息提醒 - if site_user_info.ratio and float(site_user_info.ratio) < 1: - self.post_message(mtype=NotificationType.SiteMessage, - title=f"【站点分享率低预警】", - text=f"站点 {site_user_info.site_name} 分享率 {site_user_info.ratio},请注意!") - - self._sites_data.update( - { - site_name: { - "upload": site_user_info.upload, - "username": site_user_info.username, - "user_level": site_user_info.user_level, - "join_at": site_user_info.join_at, - "download": site_user_info.download, - "ratio": site_user_info.ratio, - "seeding": site_user_info.seeding, - "seeding_size": site_user_info.seeding_size, - "leeching": site_user_info.leeching, - "bonus": site_user_info.bonus, - "url": site_url, - "err_msg": site_user_info.err_msg, - "message_unread": site_user_info.message_unread, - "updated_at": datetime.now().strftime('%Y-%m-%d') - } - }) - return site_user_info - - except Exception as e: - import traceback - logger.error(f"站点 {site_name} 获取流量数据失败:{str(e)}") - logger.error(traceback.format_exc()) - return None - - def __notify_unread_msg(self, site_name: str, site_user_info: ISiteUserInfo, unread_msg_notify: bool): - if site_user_info.message_unread <= 0: - return - if self._sites_data.get(site_name, {}).get('message_unread') == site_user_info.message_unread: - return - if not unread_msg_notify: - return - - # 解析出内容,则发送内容 - if len(site_user_info.message_unread_contents) > 0: - for head, date, content in site_user_info.message_unread_contents: - msg_title = f"【站点 {site_user_info.site_name} 消息】" - msg_text = f"时间:{date}\n标题:{head}\n内容:\n{content}" - self.post_message(mtype=NotificationType.SiteMessage, title=msg_title, text=msg_text) - else: - self.post_message(mtype=NotificationType.SiteMessage, - title=f"站点 {site_user_info.site_name} 收到 " - f"{site_user_info.message_unread} 条新消息,请登陆查看") + pass @eventmanager.register(EventType.PluginAction) def refresh(self, event: Event): @@ -1360,145 +885,31 @@ class SiteStatistic(_PluginBase): self.post_message(channel=event.event_data.get("channel"), title="开始刷新站点数据 ...", userid=event.event_data.get("user")) - self.refresh_all_site_data() + SiteChain().refresh_userdatas() if event: self.post_message(channel=event.event_data.get("channel"), title="站点数据刷新完成!", userid=event.event_data.get("user")) - def refresh_all_site_data(self): + def refresh_by_domain(self, domain: str, apikey: str) -> schemas.Response: """ - 多线程刷新站点下载上传量,默认间隔6小时 + 刷新一个站点数据,可由API调用 """ - if not self.sites.get_indexers(): - return - - logger.info("开始刷新站点数据 ...") - - with lock: - - all_sites = [site for site in self.sites.get_indexers() if not site.get("public")] + self.__custom_sites() - # 没有指定站点,默认使用全部站点 - if not self._statistic_sites: - refresh_sites = all_sites - else: - refresh_sites = [site for site in all_sites if - site.get("id") in self._statistic_sites] - 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: - messages = {} - # 总上传 - incUploads = 0 - # 总下载 - incDownloads = 0 - - 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 += 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: - 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(sorted_messages)) - - # 保存数据 - self.save_data(today_date, self._sites_data) - - # 更新时间 - self.save_data("last_update_time", today_date) - - self.eventmanager.send_event(etype=EventType.PluginAction, data={ - "action": "sitestatistic_refresh_complete" - }) - - logger.info("站点数据刷新完成") - - def __custom_sites(self) -> List[Any]: - custom_sites = [] - custom_sites_config = self.get_config("CustomSites") - if custom_sites_config and custom_sites_config.get("enabled"): - custom_sites = custom_sites_config.get("sites") - return custom_sites - - def __update_config(self): - self.update_config({ - "enabled": self._enabled, - "onlyonce": self._onlyonce, - "cron": self._cron, - "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, - "dashboard_type": self._dashboard_type - }) - - @eventmanager.register(EventType.SiteDeleted) - def site_deleted(self, event): - """ - 删除对应站点选中 - """ - site_id = event.event_data.get("site_id") - config = self.get_config() - if config: - statistic_sites = config.get("statistic_sites") - if statistic_sites: - if isinstance(statistic_sites, str): - statistic_sites = [statistic_sites] - - # 删除对应站点 - if site_id: - statistic_sites = [site for site in statistic_sites if int(site) != int(site_id)] - else: - # 清空 - statistic_sites = [] - - # 若无站点,则停止 - if len(statistic_sites) == 0: - self._enabled = False - - self._statistic_sites = statistic_sites - # 保存配置 - self.__update_config() + if apikey != settings.API_TOKEN: + return schemas.Response(success=False, message="API密钥错误") + site_info = self.siteshelper.get_indexer(domain) + if site_info: + site_data = SiteChain().refresh_userdata(site=site_info) + if site_data: + return schemas.Response( + success=True, + message=f"站点 {domain} 刷新成功", + data=site_data.dict() + ) + return schemas.Response( + success=False, + message=f"站点 {domain} 刷新数据失败,未获取到数据" + ) + return schemas.Response( + success=False, + message=f"站点 {domain} 不存在" + ) diff --git a/plugins.v2/sitestatistic/siteuserinfo/__init__.py b/plugins.v2/sitestatistic/siteuserinfo/__init__.py deleted file mode 100644 index 92da174..0000000 --- a/plugins.v2/sitestatistic/siteuserinfo/__init__.py +++ /dev/null @@ -1,428 +0,0 @@ -# -*- coding: utf-8 -*- -import json -import re -from abc import ABCMeta, abstractmethod -from enum import Enum -from typing import Optional -from urllib.parse import urljoin, urlsplit - -from requests import Session - -from app.core.config import settings -from app.helper.cloudflare import under_challenge -from app.log import logger -from app.utils.http import RequestUtils -from app.utils.site import SiteUtils - -SITE_BASE_ORDER = 1000 - - -# 站点框架 -class SiteSchema(Enum): - DiscuzX = "Discuz!" - Gazelle = "Gazelle" - Ipt = "IPTorrents" - NexusPhp = "NexusPhp" - NexusProject = "NexusProject" - NexusRabbit = "NexusRabbit" - NexusHhanclub = "NexusHhanclub" - NexusAudiences = "NexusAudiences" - SmallHorse = "Small Horse" - Unit3d = "Unit3d" - TorrentLeech = "TorrentLeech" - FileList = "FileList" - TNode = "TNode" - MTorrent = "MTorrent" - Yema = "Yema" - - -class ISiteUserInfo(metaclass=ABCMeta): - # 站点模版 - schema = SiteSchema.NexusPhp - # 站点解析时判断顺序,值越小越先解析 - order = SITE_BASE_ORDER - # 请求模式 cookie/apikey - request_mode = "cookie" - - def __init__(self, site_name: str, - url: str, - site_cookie: str, - apikey: str, - token: str, - index_html: str, - session: Session = None, - ua: str = None, - emulate: bool = False, - proxy: bool = None): - super().__init__() - # 站点信息 - self.site_name = None - self.site_url = None - self.apikey = apikey - self.token = token - # 用户信息 - self.username = None - self.userid = None - # 未读消息 - self.message_unread = 0 - self.message_unread_contents = [] - - # 流量信息 - self.upload = 0 - self.download = 0 - self.ratio = 0 - - # 种子信息 - self.seeding = 0 - self.leeching = 0 - self.uploaded = 0 - self.completed = 0 - self.incomplete = 0 - self.seeding_size = 0 - self.leeching_size = 0 - self.uploaded_size = 0 - self.completed_size = 0 - self.incomplete_size = 0 - # 做种人数, 种子大小 - self.seeding_info = [] - - # 用户详细信息 - self._user_basic_page = None - self._user_basic_params = None - self._user_basic_headers = None - self.user_level = None - self.join_at = None - self.bonus = 0.0 - - # 错误信息 - self.err_msg = None - # 内部数据 - self._addition_headers = None - - # 站点页面 - self._brief_page = "index.php" - self._user_detail_page = "userdetails.php?id=" - self._user_detail_params = None - self._user_detail_headers = None - self._user_traffic_page = "index.php" - self._user_traffic_params = None - self._user_traffic_headers = None - self._user_mail_unread_page = "messages.php?action=viewmailbox&box=1&unread=yes" - self._sys_mail_unread_page = "messages.php?action=viewmailbox&box=-2&unread=yes" - self._mail_unread_params = None - self._mail_unread_headers = None - self._mail_content_params = None - self._mail_content_headers = None - self._torrent_seeding_page = "getusertorrentlistajax.php?userid=" - self._torrent_seeding_params = None - self._torrent_seeding_headers = None - - split_url = urlsplit(url) - self.site_name = site_name - self.site_url = url - self.site_domain = split_url.netloc - self._base_url = f"{split_url.scheme}://{split_url.netloc}" - self._site_cookie = site_cookie - self._index_html = index_html - self._session = session if session else None - self._ua = ua - - self._emulate = emulate - self._proxy = proxy - - def site_schema(self) -> SiteSchema: - """ - 站点解析模型 - :return: 站点解析模型 - """ - return self.schema - - @classmethod - def match(cls, html_text: str) -> bool: - """ - 是否匹配当前解析模型 - :param html_text: 站点首页html - :return: 是否匹配 - """ - pass - - def parse(self): - """ - 解析站点信息 - :return: - """ - # 检查是否已经登录 - if not self._parse_logged_in(self._index_html): - return - # 解析站点页面 - self._parse_site_page(self._index_html) - # 解析用户基础信息 - if self._user_basic_page: - self._parse_user_base_info( - self._get_page_content( - url=urljoin(self._base_url, self._user_basic_page), - params=self._user_basic_params, - headers=self._user_basic_headers - ) - ) - else: - self._parse_user_base_info(self._index_html) - # 解析用户详细信息 - if self._user_detail_page: - self._parse_user_detail_info( - self._get_page_content( - url=urljoin(self._base_url, self._user_detail_page), - params=self._user_detail_params, - headers=self._user_detail_headers - ) - ) - # 解析用户未读消息 - self._pase_unread_msgs() - # 解析用户上传、下载、分享率等信息 - if self._user_traffic_page: - self._parse_user_traffic_info( - self._get_page_content( - url=urljoin(self._base_url, self._user_traffic_page), - params=self._user_traffic_params, - headers=self._user_traffic_headers - ) - ) - # 解析用户做种信息 - self._parse_seeding_pages() - self.seeding_info = json.dumps(self.seeding_info) - - def _pase_unread_msgs(self): - """ - 解析所有未读消息标题和内容 - :return: - """ - unread_msg_links = [] - if self.message_unread > 0: - links = {self._user_mail_unread_page, self._sys_mail_unread_page} - for link in links: - if not link: - continue - msg_links = [] - next_page = self._parse_message_unread_links( - self._get_page_content( - url=urljoin(self._base_url, link), - params=self._mail_unread_params, - headers=self._mail_unread_headers - ), - msg_links) - while next_page: - next_page = self._parse_message_unread_links( - self._get_page_content( - url=urljoin(self._base_url, next_page), - params=self._mail_unread_params, - headers=self._mail_unread_headers - ), - 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}") - head, date, content = self._parse_message_content( - self._get_page_content( - urljoin(self._base_url, msg_link), - params=self._mail_content_params, - headers=self._mail_content_headers - ) - ) - logger.debug(f"{self.site_name} 标题 {head} 时间 {date} 内容 {content}") - self.message_unread_contents.append((head, date, content)) - - def _parse_seeding_pages(self): - """ - 解析做种页面 - """ - if self._torrent_seeding_page: - # 第一页 - next_page = self._parse_user_torrent_seeding_info( - self._get_page_content( - url=urljoin(self._base_url, self._torrent_seeding_page), - params=self._torrent_seeding_params, - headers=self._torrent_seeding_headers - ) - ) - - # 其他页处理 - while next_page is not None and next_page is not False: - next_page = self._parse_user_torrent_seeding_info( - self._get_page_content( - url=urljoin(urljoin(self._base_url, self._torrent_seeding_page), next_page), - params=self._torrent_seeding_params, - headers=self._torrent_seeding_headers - ), - multi_page=True) - - @staticmethod - def _prepare_html_text(html_text): - """ - 处理掉HTML中的干扰部分 - """ - return re.sub(r"#\d+", "", re.sub(r"\d+px", "", html_text)) - - @abstractmethod - def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: - """ - 获取未阅读消息链接 - :param html_text: - :return: - """ - pass - - def _get_page_content(self, url: str, params: dict = None, headers: dict = None): - """ - :param url: 网页地址 - :param params: post参数 - :param headers: 额外的请求头 - :return: - """ - req_headers = None - proxies = settings.PROXY if self._proxy else None - if self._ua or headers or self._addition_headers: - req_headers = { - "User-Agent": f"{self._ua}" - } - - if headers: - req_headers.update(headers) - else: - req_headers.update({ - "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", - }) - if self._addition_headers: - req_headers.update(self._addition_headers) - - if self.request_mode == "apikey": - # 使用apikey请求,通过请求头传递 - cookie = None - session = None - else: - # 使用cookie请求 - cookie = self._site_cookie - session = self._session - - if params: - if req_headers.get("Content-Type") == "application/json": - res = RequestUtils(cookies=cookie, - session=session, - timeout=60, - proxies=proxies, - headers=req_headers).post_res(url=url, json=params) - else: - res = RequestUtils(cookies=cookie, - session=session, - timeout=60, - proxies=proxies, - headers=req_headers).post_res(url=url, data=params) - else: - res = RequestUtils(cookies=cookie, - session=session, - timeout=60, - proxies=proxies, - headers=req_headers).get_res(url=url) - if res is not None and res.status_code in (200, 500, 403): - if req_headers and "application/json" in str(req_headers.get("Accept")): - return json.dumps(res.json()) - else: - # 如果cloudflare 有防护,尝试使用浏览器仿真 - if under_challenge(res.text): - logger.warn( - f"{self.site_name} 检测到Cloudflare,请更新Cookie和UA") - return "" - if re.search(r"charset=\"?utf-8\"?", res.text, re.IGNORECASE): - res.encoding = "utf-8" - else: - res.encoding = res.apparent_encoding - return res.text - - return "" - - @abstractmethod - def _parse_site_page(self, html_text: str): - """ - 解析站点相关信息页面 - :param html_text: - :return: - """ - pass - - @abstractmethod - def _parse_user_base_info(self, html_text: str): - """ - 解析用户基础信息 - :param html_text: - :return: - """ - pass - - def _parse_logged_in(self, html_text): - """ - 解析用户是否已经登陆 - :param html_text: - :return: True/False - """ - logged_in = SiteUtils.is_logged_in(html_text) - if not logged_in: - self.err_msg = "未检测到已登陆,请检查cookies是否过期" - logger.warn(f"{self.site_name} 未登录,跳过后续操作") - - return logged_in - - @abstractmethod - def _parse_user_traffic_info(self, html_text: str): - """ - 解析用户的上传,下载,分享率等信息 - :param html_text: - :return: - """ - pass - - @abstractmethod - def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]: - """ - 解析用户的做种相关信息 - :param html_text: - :param multi_page: 是否多页数据 - :return: 下页地址 - """ - pass - - @abstractmethod - def _parse_user_detail_info(self, html_text: str): - """ - 解析用户的详细信息 - 加入时间/等级/魔力值等 - :param html_text: - :return: - """ - pass - - @abstractmethod - def _parse_message_content(self, html_text): - """ - 解析短消息内容 - :param html_text: - :return: head: message, date: time, content: message content - """ - pass - - def to_dict(self): - """ - 转化为字典 - """ - attributes = [ - attr for attr in dir(self) - if not callable(getattr(self, attr)) and not attr.startswith("_") - ] - return { - attr: getattr(self, attr).value - if isinstance(getattr(self, attr), SiteSchema) - else getattr(self, attr) for attr in attributes - } diff --git a/plugins.v2/sitestatistic/siteuserinfo/discuz.py b/plugins.v2/sitestatistic/siteuserinfo/discuz.py deleted file mode 100644 index 03fbb81..0000000 --- a/plugins.v2/sitestatistic/siteuserinfo/discuz.py +++ /dev/null @@ -1,139 +0,0 @@ -# -*- coding: utf-8 -*- -import re -from typing import Optional - -from lxml import etree - -from app.plugins.sitestatistic.siteuserinfo import ISiteUserInfo, SITE_BASE_ORDER, SiteSchema -from app.utils.string import StringUtils - - -class DiscuzUserInfo(ISiteUserInfo): - schema = SiteSchema.DiscuzX - order = SITE_BASE_ORDER + 10 - - @classmethod - def match(cls, html_text: str) -> bool: - html = etree.HTML(html_text) - if not html: - return False - - printable_text = html.xpath("string(.)") if html else "" - return 'Powered by Discuz!' in printable_text - - def _parse_user_base_info(self, html_text: str): - html_text = self._prepare_html_text(html_text) - html = etree.HTML(html_text) - - user_info = html.xpath('//a[contains(@href, "&uid=")]') - if user_info: - user_id_match = re.search(r"&uid=(\d+)", user_info[0].attrib['href']) - if user_id_match and user_id_match.group().strip(): - self.userid = user_id_match.group(1) - self._torrent_seeding_page = f"forum.php?&mod=torrents&cat_5up=on" - self._user_detail_page = user_info[0].attrib['href'] - self.username = user_info[0].text.strip() - - def _parse_site_page(self, html_text: str): - # TODO - pass - - def _parse_user_detail_info(self, html_text: str): - """ - 解析用户额外信息,加入时间,等级 - :param html_text: - :return: - """ - html = etree.HTML(html_text) - if not html: - return None - - # 用户等级 - user_levels_text = html.xpath('//a[contains(@href, "usergroup")]/text()') - if user_levels_text: - self.user_level = user_levels_text[-1].strip() - - # 加入日期 - join_at_text = html.xpath('//li[em[text()="注册时间"]]/text()') - if join_at_text: - self.join_at = StringUtils.unify_datetime_str(join_at_text[0].strip()) - - # 分享率 - ratio_text = html.xpath('//li[contains(.//text(), "分享率")]//text()') - if ratio_text: - ratio_match = re.search(r"\(([\d,.]+)\)", ratio_text[0]) - if ratio_match and ratio_match.group(1).strip(): - self.bonus = StringUtils.str_float(ratio_match.group(1)) - - # 积分 - bouns_text = html.xpath('//li[em[text()="积分"]]/text()') - if bouns_text: - self.bonus = StringUtils.str_float(bouns_text[0].strip()) - - # 上传 - upload_text = html.xpath('//li[em[contains(text(),"上传量")]]/text()') - if upload_text: - self.upload = StringUtils.num_filesize(upload_text[0].strip().split('/')[-1]) - - # 下载 - download_text = html.xpath('//li[em[contains(text(),"下载量")]]/text()') - if download_text: - self.download = StringUtils.num_filesize(download_text[0].strip().split('/')[-1]) - - def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]: - """ - 做种相关信息 - :param html_text: - :param multi_page: 是否多页数据 - :return: 下页地址 - """ - html = etree.HTML(html_text) - if not html: - return None - - size_col = 3 - seeders_col = 4 - # 搜索size列 - if html.xpath('//tr[position()=1]/td[.//img[@class="size"] and .//img[@alt="size"]]'): - size_col = len(html.xpath('//tr[position()=1]/td[.//img[@class="size"] ' - 'and .//img[@alt="size"]]/preceding-sibling::td')) + 1 - # 搜索seeders列 - if html.xpath('//tr[position()=1]/td[.//img[@class="seeders"] and .//img[@alt="seeders"]]'): - seeders_col = len(html.xpath('//tr[position()=1]/td[.//img[@class="seeders"] ' - 'and .//img[@alt="seeders"]]/preceding-sibling::td')) + 1 - - page_seeding = 0 - page_seeding_size = 0 - page_seeding_info = [] - seeding_sizes = html.xpath(f'//tr[position()>1]/td[{size_col}]') - seeding_seeders = html.xpath(f'//tr[position()>1]/td[{seeders_col}]//text()') - if seeding_sizes and seeding_seeders: - page_seeding = len(seeding_sizes) - - for i in range(0, len(seeding_sizes)): - size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip()) - seeders = StringUtils.str_int(seeding_seeders[i]) - - page_seeding_size += size - page_seeding_info.append([seeders, size]) - - self.seeding += page_seeding - self.seeding_size += page_seeding_size - self.seeding_info.extend(page_seeding_info) - - # 是否存在下页数据 - next_page = None - next_page_text = html.xpath('//a[contains(.//text(), "下一页") or contains(.//text(), "下一頁")]/@href') - if next_page_text: - next_page = next_page_text[-1].strip() - - return next_page - - def _parse_user_traffic_info(self, html_text: str): - pass - - def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: - return None - - def _parse_message_content(self, html_text): - return None, None, None diff --git a/plugins.v2/sitestatistic/siteuserinfo/file_list.py b/plugins.v2/sitestatistic/siteuserinfo/file_list.py deleted file mode 100644 index 9bf6f31..0000000 --- a/plugins.v2/sitestatistic/siteuserinfo/file_list.py +++ /dev/null @@ -1,127 +0,0 @@ -# -*- coding: utf-8 -*- -import re -from typing import Optional - -from lxml import etree - -from app.plugins.sitestatistic.siteuserinfo import ISiteUserInfo, SITE_BASE_ORDER, SiteSchema -from app.utils.string import StringUtils - - -class FileListSiteUserInfo(ISiteUserInfo): - schema = SiteSchema.FileList - order = SITE_BASE_ORDER + 50 - - @classmethod - def match(cls, html_text: str) -> bool: - html = etree.HTML(html_text) - if not html: - return False - - printable_text = html.xpath("string(.)") if html else "" - return 'Powered by FileList' in printable_text - - def _parse_site_page(self, html_text: str): - html_text = self._prepare_html_text(html_text) - - user_detail = re.search(r"userdetails.php\?id=(\d+)", html_text) - if user_detail and user_detail.group().strip(): - self._user_detail_page = user_detail.group().strip().lstrip('/') - self.userid = user_detail.group(1) - - self._torrent_seeding_page = f"snatchlist.php?id={self.userid}&action=torrents&type=seeding" - - def _parse_user_base_info(self, html_text: str): - html_text = self._prepare_html_text(html_text) - html = etree.HTML(html_text) - - ret = html.xpath(f'//a[contains(@href, "userdetails") and contains(@href, "{self.userid}")]//text()') - if ret: - self.username = str(ret[0]) - - def _parse_user_traffic_info(self, html_text: str): - """ - 上传/下载/分享率 [做种数/魔力值] - :param html_text: - :return: - """ - return - - def _parse_user_detail_info(self, html_text: str): - html_text = self._prepare_html_text(html_text) - html = etree.HTML(html_text) - - upload_html = html.xpath('//table//tr/td[text()="Uploaded"]/following-sibling::td//text()') - if upload_html: - self.upload = StringUtils.num_filesize(upload_html[0]) - download_html = html.xpath('//table//tr/td[text()="Downloaded"]/following-sibling::td//text()') - if download_html: - self.download = StringUtils.num_filesize(download_html[0]) - - ratio_html = html.xpath('//table//tr/td[text()="Share ratio"]/following-sibling::td//text()') - if ratio_html: - share_ratio = StringUtils.str_float(ratio_html[0]) - self.ratio = 0 if self.download == 0 else share_ratio - - seed_html = html.xpath('//table//tr/td[text()="Seed bonus"]/following-sibling::td//text()') - if seed_html: - self.seeding = StringUtils.str_int(seed_html[1]) - self.seeding_size = StringUtils.num_filesize(seed_html[3]) - - user_level_html = html.xpath('//table//tr/td[text()="Class"]/following-sibling::td//text()') - if user_level_html: - self.user_level = user_level_html[0].strip() - - join_at_html = html.xpath('//table//tr/td[contains(text(), "Join")]/following-sibling::td//text()') - if join_at_html: - join_at = (join_at_html[0].split("("))[0].strip() - self.join_at = StringUtils.unify_datetime_str(join_at) - - bonus_html = html.xpath('//a[contains(@href, "shop.php")]') - if bonus_html: - self.bonus = StringUtils.str_float(bonus_html[0].xpath("string(.)").strip()) - pass - - def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]: - """ - 做种相关信息 - :param html_text: - :param multi_page: 是否多页数据 - :return: 下页地址 - """ - html = etree.HTML(html_text) - if not html: - return None - - size_col = 6 - seeders_col = 7 - - page_seeding = 0 - page_seeding_size = 0 - page_seeding_info = [] - seeding_sizes = html.xpath(f'//table/tr[position()>1]/td[{size_col}]') - seeding_seeders = html.xpath(f'//table/tr[position()>1]/td[{seeders_col}]') - if seeding_sizes and seeding_seeders: - page_seeding = len(seeding_sizes) - - for i in range(0, len(seeding_sizes)): - size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip()) - seeders = StringUtils.str_int(seeding_seeders[i].xpath("string(.)").strip()) - - page_seeding_size += size - page_seeding_info.append([seeders, size]) - - # self.seeding += page_seeding - # self.seeding_size += page_seeding_size - self.seeding_info.extend(page_seeding_info) - - # 是否存在下页数据 - next_page = None - - return next_page - - def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: - return None - - def _parse_message_content(self, html_text): - return None, None, None diff --git a/plugins.v2/sitestatistic/siteuserinfo/gazelle.py b/plugins.v2/sitestatistic/siteuserinfo/gazelle.py deleted file mode 100644 index ae2de5e..0000000 --- a/plugins.v2/sitestatistic/siteuserinfo/gazelle.py +++ /dev/null @@ -1,163 +0,0 @@ -# -*- coding: utf-8 -*- -import re -from typing import Optional - -from lxml import etree - -from app.plugins.sitestatistic.siteuserinfo import ISiteUserInfo, SITE_BASE_ORDER, SiteSchema -from app.utils.string import StringUtils - - -class GazelleSiteUserInfo(ISiteUserInfo): - schema = SiteSchema.Gazelle - order = SITE_BASE_ORDER - - @classmethod - def match(cls, html_text: str) -> bool: - html = etree.HTML(html_text) - if not html: - return False - - printable_text = html.xpath("string(.)") if html else "" - - return "Powered by Gazelle" in printable_text or "DIC Music" in printable_text - - def _parse_user_base_info(self, html_text: str): - html_text = self._prepare_html_text(html_text) - html = etree.HTML(html_text) - - tmps = html.xpath('//a[contains(@href, "user.php?id=")]') - if tmps: - user_id_match = re.search(r"user.php\?id=(\d+)", tmps[0].attrib['href']) - if user_id_match and user_id_match.group().strip(): - self.userid = user_id_match.group(1) - self._torrent_seeding_page = f"torrents.php?type=seeding&userid={self.userid}" - self._user_detail_page = f"user.php?id={self.userid}" - self.username = tmps[0].text.strip() - - tmps = html.xpath('//*[@id="header-uploaded-value"]/@data-value') - if tmps: - self.upload = StringUtils.num_filesize(tmps[0]) - else: - tmps = html.xpath('//li[@id="stats_seeding"]/span/text()') - if tmps: - self.upload = StringUtils.num_filesize(tmps[0]) - - tmps = html.xpath('//*[@id="header-downloaded-value"]/@data-value') - if tmps: - self.download = StringUtils.num_filesize(tmps[0]) - else: - tmps = html.xpath('//li[@id="stats_leeching"]/span/text()') - if tmps: - self.download = StringUtils.num_filesize(tmps[0]) - - self.ratio = 0.0 if self.download <= 0.0 else round(self.upload / self.download, 3) - - tmps = html.xpath('//a[contains(@href, "bonus.php")]/@data-tooltip') - if tmps: - bonus_match = re.search(r"([\d,.]+)", tmps[0]) - if bonus_match and bonus_match.group(1).strip(): - self.bonus = StringUtils.str_float(bonus_match.group(1)) - else: - tmps = html.xpath('//a[contains(@href, "bonus.php")]') - if tmps: - bonus_text = tmps[0].xpath("string(.)") - bonus_match = re.search(r"([\d,.]+)", bonus_text) - if bonus_match and bonus_match.group(1).strip(): - self.bonus = StringUtils.str_float(bonus_match.group(1)) - - def _parse_site_page(self, html_text: str): - # TODO - pass - - def _parse_user_detail_info(self, html_text: str): - """ - 解析用户额外信息,加入时间,等级 - :param html_text: - :return: - """ - html = etree.HTML(html_text) - if not html: - return None - - # 用户等级 - user_levels_text = html.xpath('//*[@id="class-value"]/@data-value') - if user_levels_text: - self.user_level = user_levels_text[0].strip() - else: - user_levels_text = html.xpath('//li[contains(text(), "用户等级")]/text()') - if user_levels_text: - self.user_level = user_levels_text[0].split(':')[1].strip() - - # 加入日期 - join_at_text = html.xpath('//*[@id="join-date-value"]/@data-value') - if join_at_text: - self.join_at = StringUtils.unify_datetime_str(join_at_text[0].strip()) - else: - join_at_text = html.xpath( - '//div[contains(@class, "box_userinfo_stats")]//li[contains(text(), "加入时间")]/span/text()') - if join_at_text: - self.join_at = StringUtils.unify_datetime_str(join_at_text[0].strip()) - - def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]: - """ - 做种相关信息 - :param html_text: - :param multi_page: 是否多页数据 - :return: 下页地址 - """ - html = etree.HTML(html_text) - if not html: - return None - - size_col = 3 - # 搜索size列 - if html.xpath('//table[contains(@id, "torrent")]//tr[1]/td'): - size_col = len(html.xpath('//table[contains(@id, "torrent")]//tr[1]/td')) - 3 - # 搜索seeders列 - seeders_col = size_col + 2 - - page_seeding = 0 - page_seeding_size = 0 - page_seeding_info = [] - seeding_sizes = html.xpath(f'//table[contains(@id, "torrent")]//tr[position()>1]/td[{size_col}]') - seeding_seeders = html.xpath(f'//table[contains(@id, "torrent")]//tr[position()>1]/td[{seeders_col}]/text()') - if seeding_sizes and seeding_seeders: - page_seeding = len(seeding_sizes) - - for i in range(0, len(seeding_sizes)): - size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip()) - seeders = int(seeding_seeders[i]) - - page_seeding_size += size - page_seeding_info.append([seeders, size]) - - if multi_page: - self.seeding += page_seeding - self.seeding_size += page_seeding_size - self.seeding_info.extend(page_seeding_info) - else: - if not self.seeding: - self.seeding = page_seeding - if not self.seeding_size: - self.seeding_size = page_seeding_size - if not self.seeding_info: - self.seeding_info = page_seeding_info - - # 是否存在下页数据 - next_page = None - next_page_text = html.xpath('//a[contains(.//text(), "Next") or contains(.//text(), "下一页")]/@href') - if next_page_text: - next_page = next_page_text[-1].strip() - - return next_page - - def _parse_user_traffic_info(self, html_text: str): - # TODO - pass - - def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: - return None - - def _parse_message_content(self, html_text): - return None, None, None diff --git a/plugins.v2/sitestatistic/siteuserinfo/ipt_project.py b/plugins.v2/sitestatistic/siteuserinfo/ipt_project.py deleted file mode 100644 index 9eeb217..0000000 --- a/plugins.v2/sitestatistic/siteuserinfo/ipt_project.py +++ /dev/null @@ -1,93 +0,0 @@ -# -*- coding: utf-8 -*- -import re -from typing import Optional - -from lxml import etree - -from app.plugins.sitestatistic.siteuserinfo import ISiteUserInfo, SITE_BASE_ORDER, SiteSchema -from app.utils.string import StringUtils - - -class IptSiteUserInfo(ISiteUserInfo): - schema = SiteSchema.Ipt - order = SITE_BASE_ORDER + 35 - - @classmethod - def match(cls, html_text: str) -> bool: - return 'IPTorrents' in html_text - - def _parse_user_base_info(self, html_text: str): - html_text = self._prepare_html_text(html_text) - html = etree.HTML(html_text) - tmps = html.xpath('//a[contains(@href, "/u/")]//text()') - tmps_id = html.xpath('//a[contains(@href, "/u/")]/@href') - if tmps: - self.username = str(tmps[-1]) - if tmps_id: - user_id_match = re.search(r"/u/(\d+)", tmps_id[0]) - if user_id_match and user_id_match.group().strip(): - self.userid = user_id_match.group(1) - self._user_detail_page = f"user.php?u={self.userid}" - self._torrent_seeding_page = f"peers?u={self.userid}" - - tmps = html.xpath('//div[@class = "stats"]/div/div') - if tmps: - self.upload = StringUtils.num_filesize(str(tmps[0].xpath('span/text()')[1]).strip()) - self.download = StringUtils.num_filesize(str(tmps[0].xpath('span/text()')[2]).strip()) - self.seeding = StringUtils.str_int(tmps[0].xpath('a')[2].xpath('text()')[0]) - self.leeching = StringUtils.str_int(tmps[0].xpath('a')[2].xpath('text()')[1]) - self.ratio = StringUtils.str_float(str(tmps[0].xpath('span/text()')[0]).strip().replace('-', '0')) - self.bonus = StringUtils.str_float(tmps[0].xpath('a')[3].xpath('text()')[0]) - - def _parse_site_page(self, html_text: str): - # TODO - pass - - def _parse_user_detail_info(self, html_text: str): - html = etree.HTML(html_text) - if not html: - return - - user_levels_text = html.xpath('//tr/th[text()="Class"]/following-sibling::td[1]/text()') - if user_levels_text: - self.user_level = user_levels_text[0].strip() - - # 加入日期 - join_at_text = html.xpath('//tr/th[text()="Join date"]/following-sibling::td[1]/text()') - if join_at_text: - self.join_at = StringUtils.unify_datetime_str(join_at_text[0].split(' (')[0]) - - def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]: - html = etree.HTML(html_text) - if not html: - return - # seeding start - seeding_end_pos = 3 - if html.xpath('//tr/td[text() = "Leechers"]'): - seeding_end_pos = len(html.xpath('//tr/td[text() = "Leechers"]/../preceding-sibling::tr')) + 1 - seeding_end_pos = seeding_end_pos - 3 - - page_seeding = 0 - page_seeding_size = 0 - seeding_torrents = html.xpath('//tr/td[text() = "Seeders"]/../following-sibling::tr/td[position()=6]/text()') - if seeding_torrents: - page_seeding = seeding_end_pos - for per_size in seeding_torrents[:seeding_end_pos]: - if '(' in per_size and ')' in per_size: - per_size = per_size.split('(')[-1] - per_size = per_size.split(')')[0] - - page_seeding_size += StringUtils.num_filesize(per_size) - - self.seeding = page_seeding - self.seeding_size = page_seeding_size - - def _parse_user_traffic_info(self, html_text: str): - # TODO - pass - - def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: - return None - - def _parse_message_content(self, html_text): - return None, None, None diff --git a/plugins.v2/sitestatistic/siteuserinfo/mtorrent.py b/plugins.v2/sitestatistic/siteuserinfo/mtorrent.py deleted file mode 100644 index 8c999d5..0000000 --- a/plugins.v2/sitestatistic/siteuserinfo/mtorrent.py +++ /dev/null @@ -1,200 +0,0 @@ -# -*- coding: utf-8 -*- -import json -from typing import Optional, Tuple -from urllib.parse import urljoin - -from lxml import etree - -from app.log import logger -from app.plugins.sitestatistic.siteuserinfo import ISiteUserInfo, SITE_BASE_ORDER, SiteSchema -from app.utils.string import StringUtils - - -class MTorrentSiteUserInfo(ISiteUserInfo): - schema = SiteSchema.MTorrent - order = SITE_BASE_ORDER + 60 - request_mode = "apikey" - - # 用户级别字典 - MTeam_sysRoleList = { - "1": "User", - "2": "Power User", - "3": "Elite User", - "4": "Crazy User", - "5": "Insane User", - "6": "Veteran User", - "7": "Extreme User", - "8": "Ultimate User", - "9": "Nexus Master", - "10": "VIP", - "11": "Retiree", - "12": "Uploader", - "13": "Moderator", - "14": "Administrator", - "15": "Sysop", - "16": "Staff", - "17": "Offer memberStaff", - "18": "Bet memberStaff", - } - - @classmethod - def match(cls, html_text: str) -> bool: - html = etree.HTML(html_text) - if not html: - return False - if html.xpath("//title/text()") and "M-Team" in html.xpath("//title/text()")[0]: - return True - return False - - def _parse_site_page(self, html_text: str): - """ - 获取站点页面地址 - """ - # 更换api地址 - self._base_url = f"https://api.{StringUtils.get_url_domain(self._base_url)}" - self._user_traffic_page = None - self._user_detail_page = None - self._user_basic_page = "api/member/profile" - self._user_basic_params = { - "uid": self.userid - } - self._sys_mail_unread_page = None - self._user_mail_unread_page = "api/msg/search" - self._mail_unread_params = { - "keyword": "", - "box": "-2", - "type": "pageNumber", - "pageSize": 100 - } - self._torrent_seeding_page = "api/member/getUserTorrentList" - self._torrent_seeding_headers = { - "Content-Type": "application/json", - "Accept": "application/json, text/plain, */*" - } - self._addition_headers = { - "x-api-key": self.apikey, - } - - def _parse_logged_in(self, html_text): - """ - 判断是否登录成功, 通过判断是否存在用户信息 - 暂时跳过检测,待后续优化 - :param html_text: - :return: - """ - return True - - def _parse_user_base_info(self, html_text: str): - """ - 解析用户基本信息,这里把_parse_user_traffic_info和_parse_user_detail_info合并到这里 - """ - if not html_text: - return None - detail = json.loads(html_text) - if not detail or detail.get("code") != "0": - return - user_info = detail.get("data", {}) - self.userid = user_info.get("id") - self.username = user_info.get("username") - self.user_level = self.MTeam_sysRoleList.get(user_info.get("role") or "1") - self.join_at = user_info.get("memberStatus", {}).get("createdDate") - - self.upload = int(user_info.get("memberCount", {}).get("uploaded") or '0') - 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 = 99999 - - self._torrent_seeding_params = { - "pageNumber": 1, - "pageSize": 200, - "type": "SEEDING", - "userid": self.userid - } - - def _parse_user_traffic_info(self, html_text: str): - """ - 解析用户流量信息 - """ - pass - - def _parse_user_detail_info(self, html_text: str): - """ - 解析用户详细信息 - """ - pass - - def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]: - """ - 解析用户做种信息 - """ - if not html_text: - return None - seeding_info = json.loads(html_text) - if not seeding_info or seeding_info.get("code") != "0": - return None - torrents = seeding_info.get("data", {}).get("data", []) - page_seeding_size = 0 - page_seeding_info = [] - for info in torrents: - torrent = info.get("torrent", {}) - size = int(torrent.get("size") or '0') - seeders = int(torrent.get("source") or '0') - page_seeding_size += size - page_seeding_info.append([seeders, size]) - self.seeding += len(torrents) - self.seeding_size += page_seeding_size - self.seeding_info.extend(page_seeding_info) - - # 查询总做种数 - seeder_count = 0 - try: - result = self._get_page_content( - url=urljoin(self._base_url, "api/tracker/myPeerStatus"), - params={"uid": self.userid}, - ) - if result: - seeder_info = json.loads(result) - seeder_count = int(seeder_info.get("data", {}).get("seeder") or 0) - except Exception as e: - logger.error(f"获取做种数失败: {str(e)}") - if not seeder_count: - return None - if self.seeding >= seeder_count: - return None - # 还有下一页 - self._torrent_seeding_params["pageNumber"] += 1 - return "" - - def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: - """ - 解析未读消息链接,这里直接读出详情 - """ - if not html_text: - return None - messages_info = json.loads(html_text) - if not messages_info or messages_info.get("code") != "0": - return None - messages = messages_info.get("data", {}).get("data", []) - for message in messages: - if not message.get("unread"): - continue - head = message.get("title") - date = message.get("createdDate") - content = message.get("context") - if head and date and content: - self.message_unread_contents.append((head, date, content)) - # 设置已读 - self._get_page_content( - url=urljoin(self._base_url, f"api/msg/markRead"), - params={"msgId": message.get("id")} - ) - # 是否存在下页数据 - return None - - def _parse_message_content(self, html_text) -> Tuple[Optional[str], Optional[str], Optional[str]]: - """ - 解析消息内容 - """ - pass diff --git a/plugins.v2/sitestatistic/siteuserinfo/nexus_audiences.py b/plugins.v2/sitestatistic/siteuserinfo/nexus_audiences.py deleted file mode 100644 index 304dc26..0000000 --- a/plugins.v2/sitestatistic/siteuserinfo/nexus_audiences.py +++ /dev/null @@ -1,22 +0,0 @@ -# -*- coding: utf-8 -*- -from urllib.parse import urljoin - -from app.plugins.sitestatistic.siteuserinfo import SITE_BASE_ORDER, SiteSchema -from app.plugins.sitestatistic.siteuserinfo.nexus_php import NexusPhpSiteUserInfo - - -class NexusAudiencesSiteUserInfo(NexusPhpSiteUserInfo): - schema = SiteSchema.NexusAudiences - order = SITE_BASE_ORDER + 5 - - @classmethod - def match(cls, html_text: str) -> bool: - return 'audiences.me' in html_text - - def _parse_site_page(self, html_text: str): - super()._parse_site_page(html_text) - self._torrent_seeding_page = f"usertorrentlist.php?userid={self.userid}&type=seeding" - - def _parse_seeding_pages(self): - self._torrent_seeding_headers = {"Referer": urljoin(self._base_url, self._user_detail_page)} - super()._parse_seeding_pages() diff --git a/plugins.v2/sitestatistic/siteuserinfo/nexus_hhanclub.py b/plugins.v2/sitestatistic/siteuserinfo/nexus_hhanclub.py deleted file mode 100644 index c85c96d..0000000 --- a/plugins.v2/sitestatistic/siteuserinfo/nexus_hhanclub.py +++ /dev/null @@ -1,61 +0,0 @@ -# -*- coding: utf-8 -*- -import re - -from lxml import etree - -from app.plugins.sitestatistic.siteuserinfo import SITE_BASE_ORDER, SiteSchema -from app.plugins.sitestatistic.siteuserinfo.nexus_php import NexusPhpSiteUserInfo -from app.utils.string import StringUtils - - -class NexusHhanclubSiteUserInfo(NexusPhpSiteUserInfo): - schema = SiteSchema.NexusHhanclub - order = SITE_BASE_ORDER + 20 - - @classmethod - def match(cls, html_text: str) -> bool: - return 'hhanclub.top' in html_text - - def _parse_user_traffic_info(self, html_text): - super()._parse_user_traffic_info(html_text) - - html_text = self._prepare_html_text(html_text) - html = etree.HTML(html_text) - - # 上传、下载、分享率 - upload_match = re.search(r"[_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+[KMGTPI]*B)", - html.xpath('//*[@id="user-info-panel"]/div[2]/div[2]/div[4]/text()')[0]) - download_match = re.search(r"[_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+[KMGTPI]*B)", - html.xpath('//*[@id="user-info-panel"]/div[2]/div[2]/div[5]/text()')[0]) - ratio_match = re.search(r"分享率][::_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+)", - html.xpath('//*[@id="user-info-panel"]/div[2]/div[1]/div[1]/div/text()')[0]) - - # 计算分享率 - self.upload = StringUtils.num_filesize(upload_match.group(1).strip()) if upload_match else 0 - self.download = StringUtils.num_filesize(download_match.group(1).strip()) if download_match else 0 - # 优先使用页面上的分享率 - calc_ratio = 0.0 if self.download <= 0.0 else round(self.upload / self.download, 3) - self.ratio = StringUtils.str_float(ratio_match.group(1)) if ( - ratio_match and ratio_match.group(1).strip()) else calc_ratio - - def _parse_user_detail_info(self, html_text: str): - """ - 解析用户额外信息,加入时间,等级 - :param html_text: - :return: - """ - super()._parse_user_detail_info(html_text) - - html = etree.HTML(html_text) - if not html: - return - # 加入时间 - join_at_text = html.xpath('//*[@id="mainContent"]/div/div[2]/div[4]/div[3]/span[2]/text()[1]') - if join_at_text: - self.join_at = StringUtils.unify_datetime_str(join_at_text[0].split(' (')[0].strip()) - - def _get_user_level(self, html): - super()._get_user_level(html) - user_level_path = html.xpath('//*[@id="mainContent"]/div/div[2]/div[2]/div[4]/span[2]/img/@title') - if user_level_path: - self.user_level = user_level_path[0] diff --git a/plugins.v2/sitestatistic/siteuserinfo/nexus_php.py b/plugins.v2/sitestatistic/siteuserinfo/nexus_php.py deleted file mode 100644 index 13b357b..0000000 --- a/plugins.v2/sitestatistic/siteuserinfo/nexus_php.py +++ /dev/null @@ -1,404 +0,0 @@ -# -*- coding: utf-8 -*- -import re -from typing import Optional - -from lxml import etree - -from app.log import logger -from app.plugins.sitestatistic.siteuserinfo import ISiteUserInfo, SITE_BASE_ORDER, SiteSchema -from app.utils.string import StringUtils - - -class NexusPhpSiteUserInfo(ISiteUserInfo): - schema = SiteSchema.NexusPhp - order = SITE_BASE_ORDER * 2 - - @classmethod - def match(cls, html_text: str) -> bool: - """ - 默认使用NexusPhp解析 - :param html_text: - :return: - """ - return True - - def _parse_site_page(self, html_text: str): - html_text = self._prepare_html_text(html_text) - - user_detail = re.search(r"userdetails.php\?id=(\d+)", html_text) - if user_detail and user_detail.group().strip(): - self._user_detail_page = user_detail.group().strip().lstrip('/') - self.userid = user_detail.group(1) - self._torrent_seeding_page = f"getusertorrentlistajax.php?userid={self.userid}&type=seeding" - else: - user_detail = re.search(r"(userdetails)", html_text) - if user_detail and user_detail.group().strip(): - self._user_detail_page = user_detail.group().strip().lstrip('/') - self.userid = None - self._torrent_seeding_page = None - - def _parse_message_unread(self, html_text): - """ - 解析未读短消息数量 - :param html_text: - :return: - """ - html = etree.HTML(html_text) - if not html: - return - - message_labels = html.xpath('//a[@href="messages.php"]/..') - message_labels.extend(html.xpath('//a[contains(@href, "messages.php")]/..')) - if message_labels: - message_text = message_labels[0].xpath("string(.)") - - logger.debug(f"{self.site_name} 消息原始信息 {message_text}") - message_unread_match = re.findall(r"[^Date](信息箱\s*|\(|你有\xa0)(\d+)", message_text) - - if message_unread_match and len(message_unread_match[-1]) == 2: - self.message_unread = StringUtils.str_int(message_unread_match[-1][1]) - elif message_text.isdigit(): - self.message_unread = StringUtils.str_int(message_text) - - def _parse_user_base_info(self, html_text: str): - """ - 解析用户基本信息 - """ - # 合并解析,减少额外请求调用 - self._parse_user_traffic_info(html_text) - self._user_traffic_page = None - - self._parse_message_unread(html_text) - - html = etree.HTML(html_text) - if not html: - return - - ret = html.xpath(f'//a[contains(@href, "userdetails") and contains(@href, "{self.userid}")]//b//text()') - if ret: - self.username = str(ret[0]) - return - ret = html.xpath(f'//a[contains(@href, "userdetails") and contains(@href, "{self.userid}")]//text()') - if ret: - self.username = str(ret[0]) - - ret = html.xpath('//a[contains(@href, "userdetails")]//strong//text()') - if ret: - self.username = str(ret[0]) - return - - def _parse_user_traffic_info(self, html_text): - """ - 解析用户流量信息 - """ - html_text = self._prepare_html_text(html_text) - upload_match = re.search(r"[^总]上[传傳]量?[::_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+[KMGTPI]*B)", html_text, - re.IGNORECASE) - self.upload = StringUtils.num_filesize(upload_match.group(1).strip()) if upload_match else 0 - download_match = re.search(r"[^总子影力]下[载載]量?[::_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+[KMGTPI]*B)", html_text, - re.IGNORECASE) - self.download = StringUtils.num_filesize(download_match.group(1).strip()) if download_match else 0 - ratio_match = re.search(r"分享率[::_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+)", html_text) - # 计算分享率 - calc_ratio = 0.0 if self.download <= 0.0 else round(self.upload / self.download, 3) - # 优先使用页面上的分享率 - self.ratio = StringUtils.str_float(ratio_match.group(1)) if ( - ratio_match and ratio_match.group(1).strip()) else calc_ratio - leeching_match = re.search(r"(Torrents leeching|下载中)[\u4E00-\u9FA5\D\s]+(\d+)[\s\S]+<", html_text) - self.leeching = StringUtils.str_int(leeching_match.group(2)) if leeching_match and leeching_match.group( - 2).strip() else 0 - html = etree.HTML(html_text) - has_ucoin, self.bonus = self._parse_ucoin(html) - if has_ucoin: - return - tmps = html.xpath('//a[contains(@href,"mybonus")]/text()') if html else None - if tmps: - bonus_text = str(tmps[0]).strip() - bonus_match = re.search(r"([\d,.]+)", bonus_text) - if bonus_match and bonus_match.group(1).strip(): - self.bonus = StringUtils.str_float(bonus_match.group(1)) - return - bonus_match = re.search(r"mybonus.[\[\]::<>/a-zA-Z_\-=\"'\s#;.(使用&说明魔力值豆]+\s*([\d,.]+)[\[<()&\s]", html_text) - try: - if bonus_match and bonus_match.group(1).strip(): - self.bonus = StringUtils.str_float(bonus_match.group(1)) - return - bonus_match = re.search(r"[魔力值|\]][\[\]::<>/a-zA-Z_\-=\"'\s#;]+\s*([\d,.]+|\"[\d,.]+\")[<>()&\s]", - html_text, - flags=re.S) - if bonus_match and bonus_match.group(1).strip(): - self.bonus = StringUtils.str_float(bonus_match.group(1).strip('"')) - except Exception as err: - logger.error(f"{self.site_name} 解析魔力值出错, 错误信息: {str(err)}") - - @staticmethod - def _parse_ucoin(html): - """ - 解析ucoin, 统一转换为铜币 - :param html: - :return: - """ - if html: - gold, silver, copper = None, None, None - - golds = html.xpath('//span[@class = "ucoin-symbol ucoin-gold"]//text()') - if golds: - gold = StringUtils.str_float(str(golds[-1])) - silvers = html.xpath('//span[@class = "ucoin-symbol ucoin-silver"]//text()') - if silvers: - silver = StringUtils.str_float(str(silvers[-1])) - coppers = html.xpath('//span[@class = "ucoin-symbol ucoin-copper"]//text()') - if coppers: - copper = StringUtils.str_float(str(coppers[-1])) - if gold or silver or copper: - gold = gold if gold else 0 - silver = silver if silver else 0 - copper = copper if copper else 0 - return True, gold * 100 * 100 + silver * 100 + copper - return False, 0.0 - - def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]: - """ - 做种相关信息 - :param html_text: - :param multi_page: 是否多页数据 - :return: 下页地址 - """ - html = etree.HTML(str(html_text).replace(r'\/', '/')) - if not html: - return None - - # 首页存在扩展链接,使用扩展链接 - seeding_url_text = html.xpath('//a[contains(@href,"torrents.php") ' - 'and contains(@href,"seeding")]/@href') - if multi_page is False and seeding_url_text and seeding_url_text[0].strip(): - self._torrent_seeding_page = seeding_url_text[0].strip() - return self._torrent_seeding_page - - size_col = 3 - seeders_col = 4 - # 搜索size列 - size_col_xpath = '//tr[position()=1]/' \ - 'td[(img[@class="size"] and img[@alt="size"])' \ - ' or (text() = "大小")' \ - ' or (a/img[@class="size" and @alt="size"])]' - if html.xpath(size_col_xpath): - size_col = len(html.xpath(f'{size_col_xpath}/preceding-sibling::td')) + 1 - # 搜索seeders列 - seeders_col_xpath = '//tr[position()=1]/' \ - 'td[(img[@class="seeders"] and img[@alt="seeders"])' \ - ' or (text() = "在做种")' \ - ' or (a/img[@class="seeders" and @alt="seeders"])]' - if html.xpath(seeders_col_xpath): - seeders_col = len(html.xpath(f'{seeders_col_xpath}/preceding-sibling::td')) + 1 - - page_seeding = 0 - page_seeding_size = 0 - page_seeding_info = [] - # 如果 table class="torrents",则增加table[@class="torrents"] - table_class = '//table[@class="torrents"]' if html.xpath('//table[@class="torrents"]') else '' - seeding_sizes = html.xpath(f'{table_class}//tr[position()>1]/td[{size_col}]') - seeding_seeders = html.xpath(f'{table_class}//tr[position()>1]/td[{seeders_col}]/b/a/text()') - if not seeding_seeders: - seeding_seeders = html.xpath(f'{table_class}//tr[position()>1]/td[{seeders_col}]//text()') - if seeding_sizes and seeding_seeders: - page_seeding = len(seeding_sizes) - - for i in range(0, len(seeding_sizes)): - size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip()) - seeders = StringUtils.str_int(seeding_seeders[i]) - - page_seeding_size += size - page_seeding_info.append([seeders, size]) - - self.seeding += page_seeding - self.seeding_size += page_seeding_size - self.seeding_info.extend(page_seeding_info) - - # 是否存在下页数据 - next_page = None - 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 - if self.userid not in next_page: - next_page = f'{next_page}&userid={self.userid}&type=seeding' - - return next_page - - def _parse_user_detail_info(self, html_text: str): - """ - 解析用户额外信息,加入时间,等级 - :param html_text: - :return: - """ - html = etree.HTML(html_text) - if not html: - return - - self._get_user_level(html) - - self._fixup_traffic_info(html) - - # 加入日期 - join_at_text = html.xpath( - '//tr/td[text()="加入日期" or text()="注册日期" or *[text()="加入日期"]]/following-sibling::td[1]//text()' - '|//div/b[text()="加入日期"]/../text()') - if join_at_text: - self.join_at = StringUtils.unify_datetime_str(join_at_text[0].split(' (')[0].strip()) - - # 做种体积 & 做种数 - # seeding 页面获取不到的话,此处再获取一次 - seeding_sizes = html.xpath('//tr/td[text()="当前上传"]/following-sibling::td[1]//' - 'table[tr[1][td[4 and text()="尺寸"]]]//tr[position()>1]/td[4]') - seeding_seeders = html.xpath('//tr/td[text()="当前上传"]/following-sibling::td[1]//' - 'table[tr[1][td[5 and text()="做种者"]]]//tr[position()>1]/td[5]//text()') - tmp_seeding = len(seeding_sizes) - tmp_seeding_size = 0 - tmp_seeding_info = [] - for i in range(0, len(seeding_sizes)): - size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip()) - seeders = StringUtils.str_int(seeding_seeders[i]) - - tmp_seeding_size += size - tmp_seeding_info.append([seeders, size]) - - if not self.seeding_size: - self.seeding_size = tmp_seeding_size - if not self.seeding: - self.seeding = tmp_seeding - if not self.seeding_info: - self.seeding_info = tmp_seeding_info - - seeding_sizes = html.xpath('//tr/td[text()="做种统计"]/following-sibling::td[1]//text()') - if seeding_sizes: - seeding_match = re.search(r"总做种数:\s+(\d+)", seeding_sizes[0], re.IGNORECASE) - seeding_size_match = re.search(r"总做种体积:\s+([\d,.\s]+[KMGTPI]*B)", seeding_sizes[0], re.IGNORECASE) - tmp_seeding = StringUtils.str_int(seeding_match.group(1)) if ( - seeding_match and seeding_match.group(1)) else 0 - tmp_seeding_size = StringUtils.num_filesize( - seeding_size_match.group(1).strip()) if seeding_size_match else 0 - if not self.seeding_size: - self.seeding_size = tmp_seeding_size - if not self.seeding: - self.seeding = tmp_seeding - - self._fixup_torrent_seeding_page(html) - - def _fixup_torrent_seeding_page(self, html): - """ - 修正种子页面链接 - :param html: - :return: - """ - # 单独的种子页面 - seeding_url_text = html.xpath('//a[contains(@href,"getusertorrentlist.php") ' - 'and contains(@href,"seeding")]/@href') - if seeding_url_text: - self._torrent_seeding_page = seeding_url_text[0].strip() - # 从JS调用种获取用户ID - seeding_url_text = html.xpath('//a[contains(@href, "javascript: getusertorrentlistajax") ' - 'and contains(@href,"seeding")]/@href') - csrf_text = html.xpath('//meta[@name="x-csrf"]/@content') - if not self._torrent_seeding_page and seeding_url_text: - user_js = re.search(r"javascript: getusertorrentlistajax\(\s*'(\d+)", seeding_url_text[0]) - if user_js and user_js.group(1).strip(): - self.userid = user_js.group(1).strip() - self._torrent_seeding_page = f"getusertorrentlistajax.php?userid={self.userid}&type=seeding" - elif seeding_url_text and csrf_text: - if csrf_text[0].strip(): - self._torrent_seeding_page \ - = f"ajax_getusertorrentlist.php" - self._torrent_seeding_params = {'userid': self.userid, 'type': 'seeding', 'csrf': csrf_text[0].strip()} - - # 分类做种模式 - # 临时屏蔽 - # seeding_url_text = html.xpath('//tr/td[text()="当前做种"]/following-sibling::td[1]' - # '/table//td/a[contains(@href,"seeding")]/@href') - # if seeding_url_text: - # self._torrent_seeding_page = seeding_url_text - - def _get_user_level(self, html): - # 等级 获取同一行等级数据,图片格式等级,取title信息,否则取文本信息 - user_levels_text = html.xpath('//tr/td[text()="等級" or text()="等级" or *[text()="等级"]]/' - 'following-sibling::td[1]/img[1]/@title') - if user_levels_text: - self.user_level = user_levels_text[0].strip() - return - - user_levels_text = html.xpath('//tr/td[text()="等級" or text()="等级"]/' - 'following-sibling::td[1 and not(img)]' - '|//tr/td[text()="等級" or text()="等级"]/' - 'following-sibling::td[1 and img[not(@title)]]') - if user_levels_text: - self.user_level = user_levels_text[0].xpath("string(.)").strip() - return - - user_levels_text = html.xpath('//tr/td[text()="等級" or text()="等级"]/' - 'following-sibling::td[1]') - if user_levels_text: - self.user_level = user_levels_text[0].xpath("string(.)").strip() - return - - # 适配PTT用户等级 - user_levels_text = html.xpath('//tr/td[text()="用户等级"]/following-sibling::td[1]/b/@title') - if user_levels_text: - self.user_level = user_levels_text[0].strip() - return - - user_levels_text = html.xpath('//a[contains(@href, "userdetails")]/text()') - if not self.user_level and user_levels_text: - for user_level_text in user_levels_text: - user_level_match = re.search(r"\[(.*)]", user_level_text) - if user_level_match and user_level_match.group(1).strip(): - self.user_level = user_level_match.group(1).strip() - break - - def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: - html = etree.HTML(html_text) - if not html: - return None - - message_links = html.xpath('//tr[not(./td/img[@alt="Read"])]/td/a[contains(@href, "viewmessage")]/@href') - msg_links.extend(message_links) - # 是否存在下页数据 - next_page = None - next_page_text = html.xpath('//a[contains(.//text(), "下一页") or contains(.//text(), "下一頁")]/@href') - if next_page_text: - next_page = next_page_text[-1].strip() - - return next_page - - def _parse_message_content(self, html_text): - html = etree.HTML(html_text) - if not html: - return None, None, None - # 标题 - message_head_text = None - message_head = html.xpath('//h1/text()' - '|//div[@class="layui-card-header"]/span[1]/text()') - if message_head: - message_head_text = message_head[-1].strip() - - # 消息时间 - message_date_text = None - message_date = html.xpath('//h1/following-sibling::table[.//tr/td[@class="colhead"]]//tr[2]/td[2]' - '|//div[@class="layui-card-header"]/span[2]/span[2]') - if message_date: - message_date_text = message_date[0].xpath("string(.)").strip() - - # 消息内容 - message_content_text = None - message_content = html.xpath('//h1/following-sibling::table[.//tr/td[@class="colhead"]]//tr[3]/td' - '|//div[contains(@class,"layui-card-body")]') - if message_content: - message_content_text = message_content[0].xpath("string(.)").strip() - - return message_head_text, message_date_text, message_content_text - - def _fixup_traffic_info(self, html): - # fixup bonus - if not self.bonus: - bonus_text = html.xpath('//tr/td[text()="魔力值" or text()="猫粮"]/following-sibling::td[1]/text()') - if bonus_text: - self.bonus = StringUtils.str_float(bonus_text[0].strip()) diff --git a/plugins.v2/sitestatistic/siteuserinfo/nexus_project.py b/plugins.v2/sitestatistic/siteuserinfo/nexus_project.py deleted file mode 100644 index d64c59d..0000000 --- a/plugins.v2/sitestatistic/siteuserinfo/nexus_project.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*- coding: utf-8 -*- -import re - -from app.plugins.sitestatistic.siteuserinfo import SITE_BASE_ORDER, SiteSchema -from app.plugins.sitestatistic.siteuserinfo.nexus_php import NexusPhpSiteUserInfo - - -class NexusProjectSiteUserInfo(NexusPhpSiteUserInfo): - schema = SiteSchema.NexusProject - order = SITE_BASE_ORDER + 25 - - @classmethod - def match(cls, html_text: str) -> bool: - return 'Nexus Project' in html_text - - def _parse_site_page(self, html_text: str): - html_text = self._prepare_html_text(html_text) - - user_detail = re.search(r"userdetails.php\?id=(\d+)", html_text) - if user_detail and user_detail.group().strip(): - self._user_detail_page = user_detail.group().strip().lstrip('/') - self.userid = user_detail.group(1) - - self._torrent_seeding_page = f"viewusertorrents.php?id={self.userid}&show=seeding" diff --git a/plugins.v2/sitestatistic/siteuserinfo/nexus_rabbit.py b/plugins.v2/sitestatistic/siteuserinfo/nexus_rabbit.py deleted file mode 100644 index 08c4c52..0000000 --- a/plugins.v2/sitestatistic/siteuserinfo/nexus_rabbit.py +++ /dev/null @@ -1,57 +0,0 @@ -# -*- coding: utf-8 -*- -import json -from typing import Optional - -from lxml import etree - -from app.log import logger -from app.plugins.sitestatistic.siteuserinfo import SITE_BASE_ORDER, SiteSchema -from app.plugins.sitestatistic.siteuserinfo.nexus_php import NexusPhpSiteUserInfo - - -class NexusRabbitSiteUserInfo(NexusPhpSiteUserInfo): - schema = SiteSchema.NexusRabbit - order = SITE_BASE_ORDER + 5 - - @classmethod - def match(cls, html_text: str) -> bool: - html = etree.HTML(html_text) - if not html: - return False - - printable_text = html.xpath("string(.)") if html else "" - return 'Style by Rabbit' in printable_text - - def _parse_site_page(self, html_text: str): - super()._parse_site_page(html_text) - self._torrent_seeding_page = f"getusertorrentlistajax.php?page=1&limit=5000000&type=seeding&uid={self.userid}" - self._torrent_seeding_headers = {"Accept": "application/json, text/javascript, */*; q=0.01"} - - def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]: - """ - 做种相关信息 - :param html_text: - :param multi_page: 是否多页数据 - :return: 下页地址 - """ - - try: - torrents = json.loads(html_text).get('data') - except Exception as e: - logger.error(f"解析做种信息失败: {str(e)}") - return - - page_seeding_size = 0 - page_seeding_info = [] - - page_seeding = len(torrents) - for torrent in torrents: - seeders = int(torrent.get('seeders', 0)) - size = int(torrent.get('size', 0)) - page_seeding_size += int(torrent.get('size', 0)) - - page_seeding_info.append([seeders, size]) - - self.seeding += page_seeding - self.seeding_size += page_seeding_size - self.seeding_info.extend(page_seeding_info) diff --git a/plugins.v2/sitestatistic/siteuserinfo/small_horse.py b/plugins.v2/sitestatistic/siteuserinfo/small_horse.py deleted file mode 100644 index d704a28..0000000 --- a/plugins.v2/sitestatistic/siteuserinfo/small_horse.py +++ /dev/null @@ -1,110 +0,0 @@ -# -*- coding: utf-8 -*- -import re -from typing import Optional - -from lxml import etree - -from app.plugins.sitestatistic.siteuserinfo import ISiteUserInfo, SITE_BASE_ORDER, SiteSchema -from app.utils.string import StringUtils - - -class SmallHorseSiteUserInfo(ISiteUserInfo): - schema = SiteSchema.SmallHorse - order = SITE_BASE_ORDER + 30 - - @classmethod - def match(cls, html_text: str) -> bool: - return 'Small Horse' in html_text - - def _parse_site_page(self, html_text: str): - html_text = self._prepare_html_text(html_text) - - user_detail = re.search(r"user.php\?id=(\d+)", html_text) - if user_detail and user_detail.group().strip(): - self._user_detail_page = user_detail.group().strip().lstrip('/') - self.userid = user_detail.group(1) - self._torrent_seeding_page = f"torrents.php?type=seeding&userid={self.userid}" - self._user_traffic_page = f"user.php?id={self.userid}" - - def _parse_user_base_info(self, html_text: str): - html_text = self._prepare_html_text(html_text) - html = etree.HTML(html_text) - ret = html.xpath('//a[contains(@href, "user.php")]//text()') - if ret: - self.username = str(ret[0]) - - def _parse_user_traffic_info(self, html_text: str): - """ - 上传/下载/分享率 [做种数/魔力值] - :param html_text: - :return: - """ - html_text = self._prepare_html_text(html_text) - html = etree.HTML(html_text) - tmps = html.xpath('//ul[@class = "stats nobullet"]') - if tmps: - if tmps[1].xpath("li") and tmps[1].xpath("li")[0].xpath("span//text()"): - self.join_at = StringUtils.unify_datetime_str(tmps[1].xpath("li")[0].xpath("span//text()")[0]) - self.upload = StringUtils.num_filesize(str(tmps[1].xpath("li")[2].xpath("text()")[0]).split(":")[1].strip()) - self.download = StringUtils.num_filesize( - str(tmps[1].xpath("li")[3].xpath("text()")[0]).split(":")[1].strip()) - if tmps[1].xpath("li")[4].xpath("span//text()"): - self.ratio = StringUtils.str_float(str(tmps[1].xpath("li")[4].xpath("span//text()")[0]).replace('∞', '0')) - else: - self.ratio = StringUtils.str_float(str(tmps[1].xpath("li")[5].xpath("text()")[0]).split(":")[1]) - self.bonus = StringUtils.str_float(str(tmps[1].xpath("li")[5].xpath("text()")[0]).split(":")[1]) - self.user_level = str(tmps[3].xpath("li")[0].xpath("text()")[0]).split(":")[1].strip() - self.leeching = StringUtils.str_int( - (tmps[4].xpath("li")[6].xpath("text()")[0]).split(":")[1].replace("[", "")) - - def _parse_user_detail_info(self, html_text: str): - pass - - def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]: - """ - 做种相关信息 - :param html_text: - :param multi_page: 是否多页数据 - :return: 下页地址 - """ - html = etree.HTML(html_text) - if not html: - return None - - size_col = 6 - seeders_col = 8 - - page_seeding = 0 - page_seeding_size = 0 - page_seeding_info = [] - seeding_sizes = html.xpath(f'//table[@id="torrent_table"]//tr[position()>1]/td[{size_col}]') - seeding_seeders = html.xpath(f'//table[@id="torrent_table"]//tr[position()>1]/td[{seeders_col}]') - if seeding_sizes and seeding_seeders: - page_seeding = len(seeding_sizes) - - for i in range(0, len(seeding_sizes)): - size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip()) - seeders = StringUtils.str_int(seeding_seeders[i].xpath("string(.)").strip()) - - page_seeding_size += size - page_seeding_info.append([seeders, size]) - - self.seeding += page_seeding - self.seeding_size += page_seeding_size - self.seeding_info.extend(page_seeding_info) - - # 是否存在下页数据 - next_page = None - next_pages = html.xpath('//ul[@class="pagination"]/li[contains(@class,"active")]/following-sibling::li') - if next_pages and len(next_pages) > 1: - page_num = next_pages[0].xpath("string(.)").strip() - if page_num.isdigit(): - next_page = f"{self._torrent_seeding_page}&page={page_num}" - - return next_page - - def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: - return None - - def _parse_message_content(self, html_text): - return None, None, None diff --git a/plugins.v2/sitestatistic/siteuserinfo/tnode.py b/plugins.v2/sitestatistic/siteuserinfo/tnode.py deleted file mode 100644 index 8f7ce7f..0000000 --- a/plugins.v2/sitestatistic/siteuserinfo/tnode.py +++ /dev/null @@ -1,103 +0,0 @@ -# -*- coding: utf-8 -*- -import json -import re -from typing import Optional - -from app.plugins.sitestatistic.siteuserinfo import ISiteUserInfo, SITE_BASE_ORDER, SiteSchema -from app.utils.string import StringUtils - - -class TNodeSiteUserInfo(ISiteUserInfo): - schema = SiteSchema.TNode - order = SITE_BASE_ORDER + 60 - - @classmethod - def match(cls, html_text: str) -> bool: - return 'Powered By TNode' in html_text - - def _parse_site_page(self, html_text: str): - html_text = self._prepare_html_text(html_text) - - # - csrf_token = re.search(r'', html_text) - if csrf_token: - self._addition_headers = {'X-CSRF-TOKEN': csrf_token.group(1)} - self._user_detail_page = "api/user/getMainInfo" - self._torrent_seeding_page = "api/user/listTorrentActivity?id=&type=seeding&page=1&size=20000" - - def _parse_logged_in(self, html_text): - """ - 判断是否登录成功, 通过判断是否存在用户信息 - 暂时跳过检测,待后续优化 - :param html_text: - :return: - """ - return True - - def _parse_user_base_info(self, html_text: str): - self.username = self.userid - - def _parse_user_traffic_info(self, html_text: str): - pass - - def _parse_user_detail_info(self, html_text: str): - detail = json.loads(html_text) - if detail.get("status") != 200: - return - - user_info = detail.get("data", {}) - self.userid = user_info.get("id") - self.username = user_info.get("username") - self.user_level = user_info.get("class", {}).get("name") - self.join_at = user_info.get("regTime", 0) - self.join_at = StringUtils.unify_datetime_str(str(self.join_at)) - - self.upload = user_info.get("upload") - self.download = user_info.get("download") - self.ratio = 0 if self.download <= 0 else round(self.upload / self.download, 3) - self.bonus = user_info.get("bonus") - - self.message_unread = user_info.get("unreadAdmin", 0) + user_info.get("unreadInbox", 0) + user_info.get( - "unreadSystem", 0) - pass - - def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]: - """ - 解析用户做种信息 - """ - seeding_info = json.loads(html_text) - if seeding_info.get("status") != 200: - return - - torrents = seeding_info.get("data", {}).get("torrents", []) - - page_seeding_size = 0 - page_seeding_info = [] - for torrent in torrents: - size = torrent.get("size", 0) - seeders = torrent.get("seeding", 0) - - page_seeding_size += size - page_seeding_info.append([seeders, size]) - - self.seeding += len(torrents) - self.seeding_size += page_seeding_size - self.seeding_info.extend(page_seeding_info) - - # 是否存在下页数据 - next_page = None - - return next_page - - def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: - return None - - def _parse_message_content(self, html_text): - """ - 系统信息 api/message/listSystem?page=1&size=20 - 收件箱信息 api/message/listInbox?page=1&size=20 - 管理员信息 api/message/listAdmin?page=1&size=20 - :param html_text: - :return: - """ - return None, None, None diff --git a/plugins.v2/sitestatistic/siteuserinfo/torrent_leech.py b/plugins.v2/sitestatistic/siteuserinfo/torrent_leech.py deleted file mode 100644 index 96f973a..0000000 --- a/plugins.v2/sitestatistic/siteuserinfo/torrent_leech.py +++ /dev/null @@ -1,109 +0,0 @@ -# -*- coding: utf-8 -*- -import re -from typing import Optional - -from lxml import etree - -from app.plugins.sitestatistic.siteuserinfo import ISiteUserInfo, SITE_BASE_ORDER, SiteSchema -from app.utils.string import StringUtils - - -class TorrentLeechSiteUserInfo(ISiteUserInfo): - schema = SiteSchema.TorrentLeech - order = SITE_BASE_ORDER + 40 - - @classmethod - def match(cls, html_text: str) -> bool: - return 'TorrentLeech' in html_text - - def _parse_site_page(self, html_text: str): - html_text = self._prepare_html_text(html_text) - - user_detail = re.search(r"/profile/([^/]+)/", html_text) - if user_detail and user_detail.group().strip(): - self._user_detail_page = user_detail.group().strip().lstrip('/') - self.userid = user_detail.group(1) - self._user_traffic_page = f"profile/{self.userid}/view" - self._torrent_seeding_page = f"profile/{self.userid}/seeding" - - def _parse_user_base_info(self, html_text: str): - self.username = self.userid - - def _parse_user_traffic_info(self, html_text: str): - """ - 上传/下载/分享率 [做种数/魔力值] - :param html_text: - :return: - """ - html_text = self._prepare_html_text(html_text) - html = etree.HTML(html_text) - upload_html = html.xpath('//div[contains(@class,"profile-uploaded")]//span/text()') - if upload_html: - self.upload = StringUtils.num_filesize(upload_html[0]) - download_html = html.xpath('//div[contains(@class,"profile-downloaded")]//span/text()') - if download_html: - self.download = StringUtils.num_filesize(download_html[0]) - ratio_html = html.xpath('//div[contains(@class,"profile-ratio")]//span/text()') - if ratio_html: - self.ratio = StringUtils.str_float(ratio_html[0].replace('∞', '0')) - - user_level_html = html.xpath('//table[contains(@class, "profileViewTable")]' - '//tr/td[text()="Class"]/following-sibling::td/text()') - if user_level_html: - self.user_level = user_level_html[0].strip() - - join_at_html = html.xpath('//table[contains(@class, "profileViewTable")]' - '//tr/td[text()="Registration date"]/following-sibling::td/text()') - if join_at_html: - self.join_at = StringUtils.unify_datetime_str(join_at_html[0].strip()) - - bonus_html = html.xpath('//span[contains(@class, "total-TL-points")]/text()') - if bonus_html: - self.bonus = StringUtils.str_float(bonus_html[0].strip()) - - def _parse_user_detail_info(self, html_text: str): - pass - - def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]: - """ - 做种相关信息 - :param html_text: - :param multi_page: 是否多页数据 - :return: 下页地址 - """ - html = etree.HTML(html_text) - if not html: - return None - - size_col = 2 - seeders_col = 7 - - page_seeding = 0 - page_seeding_size = 0 - page_seeding_info = [] - seeding_sizes = html.xpath(f'//tbody/tr/td[{size_col}]') - seeding_seeders = html.xpath(f'//tbody/tr/td[{seeders_col}]/text()') - if seeding_sizes and seeding_seeders: - page_seeding = len(seeding_sizes) - - for i in range(0, len(seeding_sizes)): - size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip()) - seeders = StringUtils.str_int(seeding_seeders[i]) - - page_seeding_size += size - page_seeding_info.append([seeders, size]) - - self.seeding += page_seeding - self.seeding_size += page_seeding_size - self.seeding_info.extend(page_seeding_info) - - # 是否存在下页数据 - next_page = None - - return next_page - - def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: - return None - - def _parse_message_content(self, html_text): - return None, None, None diff --git a/plugins.v2/sitestatistic/siteuserinfo/unit3d.py b/plugins.v2/sitestatistic/siteuserinfo/unit3d.py deleted file mode 100644 index a40483e..0000000 --- a/plugins.v2/sitestatistic/siteuserinfo/unit3d.py +++ /dev/null @@ -1,130 +0,0 @@ -# -*- coding: utf-8 -*- -import re -from typing import Optional - -from lxml import etree - -from app.plugins.sitestatistic.siteuserinfo import ISiteUserInfo, SITE_BASE_ORDER, SiteSchema -from app.utils.string import StringUtils - - -class Unit3dSiteUserInfo(ISiteUserInfo): - schema = SiteSchema.Unit3d - order = SITE_BASE_ORDER + 15 - - @classmethod - def match(cls, html_text: str) -> bool: - return "unit3d.js" in html_text - - def _parse_user_base_info(self, html_text: str): - html_text = self._prepare_html_text(html_text) - html = etree.HTML(html_text) - - tmps = html.xpath('//a[contains(@href, "/users/") and contains(@href, "settings")]/@href') - if tmps: - user_name_match = re.search(r"/users/(.+)/settings", tmps[0]) - if user_name_match and user_name_match.group().strip(): - self.username = user_name_match.group(1) - self._torrent_seeding_page = f"/users/{self.username}/active?perPage=100&client=&seeding=include" - self._user_detail_page = f"/users/{self.username}" - - tmps = html.xpath('//a[contains(@href, "bonus/earnings")]') - if tmps: - bonus_text = tmps[0].xpath("string(.)") - bonus_match = re.search(r"([\d,.]+)", bonus_text) - if bonus_match and bonus_match.group(1).strip(): - self.bonus = StringUtils.str_float(bonus_match.group(1)) - - def _parse_site_page(self, html_text: str): - # TODO - pass - - def _parse_user_detail_info(self, html_text: str): - """ - 解析用户额外信息,加入时间,等级 - :param html_text: - :return: - """ - html = etree.HTML(html_text) - if not html: - return None - - # 用户等级 - user_levels_text = html.xpath('//div[contains(@class, "content")]//span[contains(@class, "badge-user")]/text()') - if user_levels_text: - self.user_level = user_levels_text[0].strip() - - # 加入日期 - join_at_text = html.xpath('//div[contains(@class, "content")]//h4[contains(text(), "注册日期") ' - 'or contains(text(), "註冊日期") ' - 'or contains(text(), "Registration date")]/text()') - if join_at_text: - self.join_at = StringUtils.unify_datetime_str( - join_at_text[0].replace('注册日期', '').replace('註冊日期', '').replace('Registration date', '')) - - def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]: - """ - 做种相关信息 - :param html_text: - :param multi_page: 是否多页数据 - :return: 下页地址 - """ - html = etree.HTML(html_text) - if not html: - return None - - size_col = 9 - seeders_col = 2 - # 搜索size列 - if html.xpath('//thead//th[contains(@class,"size")]'): - size_col = len(html.xpath('//thead//th[contains(@class,"size")][1]/preceding-sibling::th')) + 1 - # 搜索seeders列 - if html.xpath('//thead//th[contains(@class,"seeders")]'): - seeders_col = len(html.xpath('//thead//th[contains(@class,"seeders")]/preceding-sibling::th')) + 1 - - page_seeding = 0 - page_seeding_size = 0 - page_seeding_info = [] - seeding_sizes = html.xpath(f'//tr[position()]/td[{size_col}]') - seeding_seeders = html.xpath(f'//tr[position()]/td[{seeders_col}]') - if seeding_sizes and seeding_seeders: - page_seeding = len(seeding_sizes) - - for i in range(0, len(seeding_sizes)): - size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip()) - seeders = StringUtils.str_int(seeding_seeders[i].xpath("string(.)").strip()) - - page_seeding_size += size - page_seeding_info.append([seeders, size]) - - self.seeding += page_seeding - self.seeding_size += page_seeding_size - self.seeding_info.extend(page_seeding_info) - - # 是否存在下页数据 - next_page = None - next_pages = html.xpath('//ul[@class="pagination"]/li[contains(@class,"active")]/following-sibling::li') - if next_pages and len(next_pages) > 1: - page_num = next_pages[0].xpath("string(.)").strip() - if page_num.isdigit(): - next_page = f"{self._torrent_seeding_page}&page={page_num}" - - return next_page - - def _parse_user_traffic_info(self, html_text: str): - html_text = self._prepare_html_text(html_text) - upload_match = re.search(r"[^总]上[传傳]量?[::_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+[KMGTPI]*B)", html_text, - re.IGNORECASE) - self.upload = StringUtils.num_filesize(upload_match.group(1).strip()) if upload_match else 0 - download_match = re.search(r"[^总子影力]下[载載]量?[::_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+[KMGTPI]*B)", html_text, - re.IGNORECASE) - self.download = StringUtils.num_filesize(download_match.group(1).strip()) if download_match else 0 - ratio_match = re.search(r"分享率[::_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+)", html_text) - self.ratio = StringUtils.str_float(ratio_match.group(1)) if ( - ratio_match and ratio_match.group(1).strip()) else 0.0 - - def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: - return None - - def _parse_message_content(self, html_text): - return None, None, None diff --git a/plugins.v2/sitestatistic/siteuserinfo/yema.py b/plugins.v2/sitestatistic/siteuserinfo/yema.py deleted file mode 100644 index 44a23d7..0000000 --- a/plugins.v2/sitestatistic/siteuserinfo/yema.py +++ /dev/null @@ -1,113 +0,0 @@ -# -*- coding: utf-8 -*- -import json -from typing import Optional, Tuple -from urllib.parse import urljoin - -from app.log import logger -from app.plugins.sitestatistic.siteuserinfo import ISiteUserInfo, SITE_BASE_ORDER, SiteSchema -from app.utils.string import StringUtils - - -class TYemaSiteUserInfo(ISiteUserInfo): - schema = SiteSchema.Yema - order = SITE_BASE_ORDER + 60 - - @classmethod - def match(cls, html_text: str) -> bool: - return 'YemaPT' in html_text - - def _parse_site_page(self, html_text: str): - """ - 获取站点页面地址 - """ - self._user_traffic_page = None - self._user_detail_page = None - self._user_basic_page = "api/consumer/fetchSelfDetail" - self._user_basic_params = {} - self._sys_mail_unread_page = None - self._user_mail_unread_page = None - self._mail_unread_params = {} - self._torrent_seeding_page = "/api/userTorrent/fetchSeedTorrentInfo" - self._torrent_seeding_params = { - # 虽然这个参数是无意义的,但这个 API 必须用 POST - "status": "seeding" - } - self._torrent_seeding_headers = {} - self._addition_headers = { - "Content-Type": "application/json", - "Accept": "application/json, text/plain, */*", - } - - def _parse_logged_in(self, html_text): - """ - 判断是否登录成功, 通过判断是否存在用户信息 - 暂时跳过检测,待后续优化 - :param html_text: - :return: - """ - return True - - def _parse_user_base_info(self, html_text: str): - """ - 解析用户基本信息,这里把_parse_user_traffic_info和_parse_user_detail_info合并到这里 - """ - if not html_text: - return None - detail = json.loads(html_text) - if not detail or not detail.get("success"): - return - user_info = detail.get("data", {}) - self.userid = user_info.get("id") - self.username = user_info.get("name") - self.user_level = user_info.get("level") - self.join_at = StringUtils.unify_datetime_str(user_info.get("registerTime")) - - self.upload = user_info.get('promotionUploadSize') - self.download = user_info.get('promotionDownloadSize') - self.ratio = round(self.upload / (self.download or 1), 2) - self.bonus = user_info.get("bonus") - self.message_unread = 0 - - def _parse_user_traffic_info(self, html_text: str): - """ - 解析用户流量信息 - """ - pass - - def _parse_user_detail_info(self, html_text: str): - """ - 解析用户详细信息 - """ - pass - - def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]: - """ - 解析用户做种信息 - """ - if not html_text: - return None - seeding_info = json.loads(html_text) - if not seeding_info or not seeding_info.get("success") or not seeding_info.get("data"): - return None - - torrents = seeding_info.get("data") - - self.seeding += torrents.get("num") - self.seeding_size += torrents.get("fileSize") - - # 是否存在下页数据 - next_page = None - - return next_page - - def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: - """ - 解析未读消息链接,这里直接读出详情 - """ - pass - - def _parse_message_content(self, html_text) -> Tuple[Optional[str], Optional[str], Optional[str]]: - """ - 解析消息内容 - """ - pass From b2c62c0e335ee537874f4c62bb1db4776c470631 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Mon, 30 Sep 2024 17:26:50 +0800 Subject: [PATCH 058/218] chore: add V2_Plugin_Development.md --- README.md | 27 +-- docs/V2_Plugin_Development.md | 412 ++++++++++++++++++++++++++++++++++ 2 files changed, 413 insertions(+), 26 deletions(-) create mode 100644 docs/V2_Plugin_Development.md diff --git a/README.md b/README.md index 2057cd4..d65e0e4 100644 --- a/README.md +++ b/README.md @@ -508,30 +508,5 @@ def get_dashboard(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Di - 新增加的插件请配置在`package.json`中的末尾,这样可被识别为最新增加,可用于用户排序。 ### 10. 如何开发V2版本的插件以及实现插件多版本兼容? -- 将插件代码放置在`plugins.v2`文件夹,将插件的定义放置在`package.v2.json`中,可实现该插件仅 MoviePilot V2 版本可见 -- 如V1版本插件实际在V2版本可用,或在插件中主动兼容了V1和V2版本,则可在`package.json`中定义 `"v2": true`属性,以便在 MoviePilot V2 版本插件市场中显示 -```json -{ - "CustomSites": { - "name": "自定义站点", - "description": "增加自定义站点为签到和统计使用。", - "labels": "站点", - "version": "1.0", - "icon": "world.png", - "author": "lightolly", - "level": 2, - "v2": true - } -} -``` - -- MoviePilot V2中 Settings 模块中新增了`VERSION_FLAG`属性,V2版本值为`v2`,可通过以下代码判断当前的版本,以便在插件中兼容处理: - -```python -from app.core.config import settings -if hasattr(settings, 'VERSION_FLAG'): - version = settings.VERSION_FLAG # V2 -else: - version = "v1" -``` \ No newline at end of file +请参阅 [V2版本插件开发指南](./docs/V2_Plugin_Development.md) \ No newline at end of file diff --git a/docs/V2_Plugin_Development.md b/docs/V2_Plugin_Development.md new file mode 100644 index 0000000..24f951e --- /dev/null +++ b/docs/V2_Plugin_Development.md @@ -0,0 +1,412 @@ +# MoviePilot V2 插件开发指南 + +本指南详细介绍了如何开发适用于MoviePilot V2版本的插件,并实现插件的多版本兼容性。包括服务封装类的使用示例,帮助开发者快速升级插件至V2版本。 + +## 1. 多版本插件开发与兼容性 + +### 1.1 开发V2版本的插件 + +要开发适用于MoviePilot V2版本的插件,并实现多版本兼容性,请按照以下步骤操作: + +1. **目录结构调整**: + - 将插件代码放置在`plugins.v2`文件夹中。 + - 将插件的定义放置在`package.v2.json`中,以实现该插件仅在MoviePilot V2版本中可见。 + +2. **插件定义示例**: + ```json + { + "CustomSites": { + "name": "自定义站点", + "description": "增加自定义站点为签到和统计使用。", + "labels": "站点", + "version": "1.0", + "icon": "world.png", + "author": "lightolly", + "level": 2 + } + } + ``` + +3. **版本判断**: + - MoviePilot V2中 Settings 模块新增了`VERSION_FLAG`属性,V2版本值为`v2`,可通过以下代码判断当前的版本,以便在插件中兼容处理: + + ```python + from app.core.config import settings + + if hasattr(settings, 'VERSION_FLAG'): + version = settings.VERSION_FLAG # V2 + else: + version = "v1" + ``` + +### 1.2 实现插件多版本兼容 + +如果V1版本插件在V2版本中实际可用,或在插件中主动兼容了V1和V2版本,则可以在`package.json`中定义 `"v2": true`属性,以便在MoviePilot V2版本插件市场中显示。 + +```json +{ + "CustomSites": { + "name": "自定义站点", + "description": "增加自定义站点为签到和统计使用。", + "labels": "站点", + "version": "1.0", + "icon": "world.png", + "author": "lightolly", + "level": 2, + "v2": true + } +} +``` + +- **目录结构示例**: + ``` + plugins/ + ├── customsites/ + │ ├── __init__.py + │ └── ... + plugins.v2/ + ├── customsites/ + │ ├── __init__.py + │ └── ... + package.json + package.v2.json + ``` + +- **插件代码中实现版本兼容**: + + 在插件代码中,可以根据`version`变量执行不同的逻辑,以适应不同的MoviePilot版本。 + + ```python + from app.core.config import settings + + class MyPlugin: + def init_plugin(self, config: dict = None): + if hasattr(settings, 'VERSION_FLAG'): + version = settings.VERSION_FLAG # V2 + else: + version = "v1" + + if version == "v2": + self.setup_v2() + else: + self.setup_v1() + + def setup_v2(self): + # V2版本特有的初始化逻辑 + pass + + def setup_v1(self): + # V1版本特有的初始化逻辑 + pass + ``` + +## 2. 服务封装与使用示例 + +为了插件调用并共享实例,主程序针对几种服务进行了封装。以下是相关实现及如何在插件中使用这些封装的详细说明,帮助开发者快速将插件从V1升级到V2。 + +### 2.1 服务封装类介绍 + +#### `ServiceInfo` +`ServiceInfo` 是一个数据类,用于封装服务的相关信息。 + +```python +from dataclasses import dataclass +from typing import Optional, Any + +@dataclass +class ServiceInfo: + """ + 封装服务相关信息的数据类 + """ + # 名称 + name: Optional[str] = None + # 实例 + instance: Optional[Any] = None + # 模块 + module: Optional[Any] = None + # 类型 + type: Optional[str] = None + # 配置 + config: Optional[Any] = None +``` + +#### `ServiceBaseHelper` +`ServiceBaseHelper` 是一个通用的服务帮助类,提供了获取配置和服务实例的通用逻辑。 + +```python +from typing import Dict, List, Optional, Type, TypeVar, Generic, Iterator +from app.core.module import ModuleManager +from app.helper.serviceconfig import ServiceConfigHelper +from app.schemas import ServiceInfo +from app.schemas.types import SystemConfigKey + +TConf = TypeVar("TConf") + +class ServiceBaseHelper(Generic[TConf]): + """ + 通用服务帮助类,抽象获取配置和服务实例的通用逻辑 + """ + + def __init__(self, config_key: SystemConfigKey, conf_type: Type[TConf], modules: List[str]): + self.modulemanager = ModuleManager() + self.config_key = config_key + self.conf_type = conf_type + self.modules = modules + + def get_configs(self, include_disabled: bool = False) -> Dict[str, TConf]: + """ + 获取配置列表 + """ + configs: List[TConf] = ServiceConfigHelper.get_configs(self.config_key, self.conf_type) + return { + config.name: config + for config in configs + if (config.name and config.type and config.enabled) or include_disabled + } if configs else {} + + def get_config(self, name: str) -> Optional[TConf]: + """ + 获取指定名称配置 + """ + if not name: + return None + configs = self.get_configs() + return configs.get(name) + + def iterate_module_instances(self) -> Iterator[ServiceInfo]: + """ + 迭代所有模块的实例及其对应的配置,返回 ServiceInfo 实例 + """ + configs = self.get_configs() + for module_name in self.modules: + module = self.modulemanager.get_running_module(module_name) + if not module: + continue + module_instances = module.get_instances() + if not isinstance(module_instances, dict): + continue + for name, instance in module_instances.items(): + if not instance: + continue + config = configs.get(name) + service_info = ServiceInfo( + name=name, + instance=instance, + module=module, + type=config.type if config else None, + config=config + ) + yield service_info + + def get_services(self, type_filter: Optional[str] = None) -> Dict[str, ServiceInfo]: + """ + 获取服务信息列表,并根据类型过滤 + """ + return { + service_info.name: service_info + for service_info in self.iterate_module_instances() + if service_info.config and (type_filter is None or service_info.type == type_filter) + } + + def get_service(self, name: str, type_filter: Optional[str] = None) -> Optional[ServiceInfo]: + """ + 获取指定名称的服务信息,并根据类型过滤 + """ + if not name: + return None + for service_info in self.iterate_module_instances(): + if service_info.name == name: + if service_info.config and (type_filter is None or service_info.type == type_filter): + return service_info + return None +``` + +### 2.2 特定服务的帮助类 + +以下是针对不同服务类型的帮助类,这些类继承自 `ServiceBaseHelper`,并预设了特定的配置。 + +#### `DownloaderHelper` +用于管理下载器服务。 + +```python +from app.helper.servicebase import ServiceBaseHelper +from app.schemas import DownloaderConf +from app.schemas.types import SystemConfigKey + +class DownloaderHelper(ServiceBaseHelper[DownloaderConf]): + """ + 下载器帮助类 + """ + + def init_plugin(self, config: dict = None): + super().__init__( + config_key=SystemConfigKey.Downloaders, + conf_type=DownloaderConf, + modules=["QbittorrentModule", "TransmissionModule"] + ) +``` + +#### `MediaServerHelper` +用于管理媒体服务器服务。 + +```python +from app.helper.servicebase import ServiceBaseHelper +from app.schemas import MediaServerConf +from app.schemas.types import SystemConfigKey + +class MediaServerHelper(ServiceBaseHelper[MediaServerConf]): + """ + 媒体服务器帮助类 + """ + + def init_plugin(self, config: dict = None): + super().__init__( + config_key=SystemConfigKey.MediaServers, + conf_type=MediaServerConf, + modules=["PlexModule", "EmbyModule", "JellyfinModule"] + ) +``` + +#### `NotificationHelper` +用于管理消息通知服务。 + +```python +from app.helper.servicebase import ServiceBaseHelper +from app.schemas import NotificationConf +from app.schemas.types import SystemConfigKey + +class NotificationHelper(ServiceBaseHelper[NotificationConf]): + """ + 消息通知帮助类 + """ + + def init_plugin(self, config: dict = None): + super().__init__( + config_key=SystemConfigKey.Notifications, + conf_type=NotificationConf, + modules=["WechatModule", "WebPushModule", "VoceChatModule", "TelegramModule", "SynologyChatModule", "SlackModule"] + ) +``` + +### 2.3 在插件中使用服务帮助类 + +通过这些帮助类,插件可以方便地获取和管理各种服务。以下是具体的使用示例。 + +#### 获取下载器选项 + +插件可以通过 `DownloaderHelper` 获取所有可用的下载器配置,并生成选项列表供用户选择。 + +```python +# 假设在插件的某个类中 +from app.helper import DownloaderHelper + +class MyPlugin: + def init_plugin(self, config: dict = None): + self.downloaderhelper = DownloaderHelper() + self.downloader_options = [ + {"title": config.name, "value": config.name} + for config in self.downloaderhelper.get_configs().values() + ] +``` + +#### 获取特定下载器服务 + +根据用户选择的下载器名称,插件可以获取对应的服务实例,并执行相应的操作。 + +```python +from typing import Optional, Union +from app.helper import DownloaderHelper +from app.modules import Transmission, Qbittorrent # 假设这些是具体的下载器类 + +class MyPlugin: + def init_plugin(self, config: dict = None): + self.downloaderhelper = DownloaderHelper() + self._downloader = None + + def set_downloader(self, downloader_name: str): + self._downloader = self.downloaderhelper.get_service(name=downloader_name) + + def __get_downloader(self) -> Optional[Union[Transmission, Qbittorrent]]: + """ + 获取下载器实例 + """ + if not self._downloader: + return None + return self._downloader.instance + + def __get_downloader_type(self) -> Optional[str]: + """ + 获取下载器类型 + """ + if not self._downloader: + return None + return self._downloader.type + + def __is_qbittorrent(self) -> bool: + """ + 判断下载器是否为 qbittorrent + """ + if not self._downloader: + return False + return self._downloader.type == "qbittorrent" + + def __is_transmission(self) -> bool: + """ + 判断下载器是否为 transmission + """ + if not self._downloader: + return False + return self._downloader.type == "transmission" +``` + +#### 获取媒体服务器服务 + +类似地,插件可以通过 `MediaServerHelper` 获取媒体服务器相关的服务信息。 + +```python +from app.helper import MediaServerHelper + +class MyPlugin: + def init_plugin(self, config: dict = None): + self.mediaserverhelper = MediaServerHelper() + self.mediaserver_options = [ + {"title": config.name, "value": config.name} + for config in self.mediaserverhelper.get_configs().values() + ] + + def get_media_server_instance(self, server_name: str): + service_info = self.mediaserverhelper.get_service(name=server_name) + if service_info: + return service_info.instance + return None +``` + +#### 获取消息通知服务 + +通过 `NotificationHelper`,插件可以发送消息通知。 + +```python +from app.helper import NotificationHelper + +class MyPlugin: + def init_plugin(self, config: dict = None): + self.notificationhelper = NotificationHelper() + + def get_notify_service_instance(self, name: str, message: str): + service_info = self.notificationhelper.get_service(name=name) + if service_info: + return service_info.instance + return None +``` + +### 2.4 服务封装的优势 + +- **统一管理**:通过 `ServiceBaseHelper`,不同类型的服务配置和实例管理变得统一和简洁。 +- **灵活扩展**:新增服务类型时,只需创建相应的帮助类,无需修改现有逻辑。 +- **便捷调用**:插件可以轻松获取所需的服务实例,简化了服务的调用过程。 + +### 2.5 从V1升级到V2的注意事项 + +- **使用帮助类**:尝试在插件中使用了新的服务帮助类,如 `DownloaderHelper`、`MediaServerHelper`、`NotificationHelper` 等,而不是直接操作服务实例。 +- **更新依赖**:检查并更新 `requirements.txt` 中的依赖,确保与V2的服务封装兼容。 +- **测试插件**:在V2环境中全面测试插件,确保所有服务调用正常工作。 \ No newline at end of file From 5ab801039392dece27d9034c4f12c9b536d50dc5 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Tue, 1 Oct 2024 15:56:38 +0800 Subject: [PATCH 059/218] chore: update V2_Plugin_Development.md --- README.md | 2 +- docs/V2_Plugin_Development.md | 235 ++++++++++++++++++++-------------- 2 files changed, 142 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index d65e0e4..15f02fc 100644 --- a/README.md +++ b/README.md @@ -509,4 +509,4 @@ def get_dashboard(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Di ### 10. 如何开发V2版本的插件以及实现插件多版本兼容? -请参阅 [V2版本插件开发指南](./docs/V2_Plugin_Development.md) \ No newline at end of file +- 请参阅 [V2版本插件开发指南](./docs/V2_Plugin_Development.md) \ No newline at end of file diff --git a/docs/V2_Plugin_Development.md b/docs/V2_Plugin_Development.md index 24f951e..935f27e 100644 --- a/docs/V2_Plugin_Development.md +++ b/docs/V2_Plugin_Development.md @@ -1,6 +1,6 @@ # MoviePilot V2 插件开发指南 -本指南详细介绍了如何开发适用于MoviePilot V2版本的插件,并实现插件的多版本兼容性。包括服务封装类的使用示例,帮助开发者快速升级插件至V2版本。 +本指南详细介绍了如何开发适用于MoviePilot V2版本的插件,并实现插件的多版本兼容性,同时包括了服务封装类的使用示例,帮助开发者快速升级插件至V2版本。 ## 1. 多版本插件开发与兼容性 @@ -102,7 +102,7 @@ ## 2. 服务封装与使用示例 -为了插件调用并共享实例,主程序针对几种服务进行了封装。以下是相关实现及如何在插件中使用这些封装的详细说明,帮助开发者快速将插件从V1升级到V2。 +为了插件调用并共享实例,主程序针对几种服务进行了封装。以下是相关实现及如何在插件中使用这些封装的详细说明,帮助开发者快速将插件从 V1 升级到 V2。 ### 2.1 服务封装类介绍 @@ -223,27 +223,54 @@ class ServiceBaseHelper(Generic[TConf]): ### 2.2 特定服务的帮助类 -以下是针对不同服务类型的帮助类,这些类继承自 `ServiceBaseHelper`,并预设了特定的配置。 +以下是针对不同服务类型的帮助类,这些类继承自 `ServiceBaseHelper`,并预设了特定的配置。同时,为了简化类型检查,新增了相应的方法来判断服务类型。 #### `DownloaderHelper` 用于管理下载器服务。 ```python +from typing import Optional + from app.helper.servicebase import ServiceBaseHelper -from app.schemas import DownloaderConf +from app.schemas import DownloaderConf, ServiceInfo from app.schemas.types import SystemConfigKey + class DownloaderHelper(ServiceBaseHelper[DownloaderConf]): """ 下载器帮助类 """ - def init_plugin(self, config: dict = None): + def __init__(self, config: dict = None): super().__init__( config_key=SystemConfigKey.Downloaders, conf_type=DownloaderConf, modules=["QbittorrentModule", "TransmissionModule"] ) + + def is_qbittorrent(self, service: Optional[ServiceInfo] = None, name: Optional[str] = None) -> bool: + """ + 判断指定的下载器是否为 qbittorrent 类型,需要传入 `service` 或 `name` 中的任一参数 + + :param service: 要判断的服务信息 + :param name: 服务的名称 + :return: 如果服务类型为 qbittorrent,返回 True;否则返回 False。 + """ + if not service: + service = self.get_service(name=name) + return service.type == "qbittorrent" if service else False + + def is_transmission(self, service: Optional[ServiceInfo] = None, name: Optional[str] = None) -> bool: + """ + 判断指定的下载器是否为 transmission 类型,需要传入 `service` 或 `name` 中的任一参数 + + :param service: 要判断的服务信息 + :param name: 服务的名称 + :return: 如果服务类型为 transmission,返回 True;否则返回 False。 + """ + if not service: + service = self.get_service(name=name) + return service.type == "transmission" if service else False ``` #### `MediaServerHelper` @@ -259,12 +286,14 @@ class MediaServerHelper(ServiceBaseHelper[MediaServerConf]): 媒体服务器帮助类 """ - def init_plugin(self, config: dict = None): + def __init__(self, config: dict = None): super().__init__( config_key=SystemConfigKey.MediaServers, conf_type=MediaServerConf, modules=["PlexModule", "EmbyModule", "JellyfinModule"] ) + + ... ``` #### `NotificationHelper` @@ -280,29 +309,30 @@ class NotificationHelper(ServiceBaseHelper[NotificationConf]): 消息通知帮助类 """ - def init_plugin(self, config: dict = None): + def __init__(self, config: dict = None): super().__init__( config_key=SystemConfigKey.Notifications, conf_type=NotificationConf, modules=["WechatModule", "WebPushModule", "VoceChatModule", "TelegramModule", "SynologyChatModule", "SlackModule"] ) + + ... ``` ### 2.3 在插件中使用服务帮助类 -通过这些帮助类,插件可以方便地获取和管理各种服务。以下是具体的使用示例。 +通过这些帮助类,插件可以方便地获取和管理各种服务。以下是 `DownloaderHelper` 的使用示例,包括类型检查服务和监听模块重载事件的两种方法。 #### 获取下载器选项 插件可以通过 `DownloaderHelper` 获取所有可用的下载器配置,并生成选项列表供用户选择。 ```python -# 假设在插件的某个类中 -from app.helper import DownloaderHelper +from app.helper.downloader import DownloaderHelper class MyPlugin: - def init_plugin(self, config: dict = None): - self.downloaderhelper = DownloaderHelper() + def __init__(self, config: dict = None): + self.downloaderhelper = DownloaderHelper(config) self.downloader_options = [ {"title": config.name, "value": config.name} for config in self.downloaderhelper.get_configs().values() @@ -311,93 +341,110 @@ class MyPlugin: #### 获取特定下载器服务 -根据用户选择的下载器名称,插件可以获取对应的服务实例,并执行相应的操作。 +根据用户选择的下载器名称,插件可以获取对应的服务实例,并执行相应的操作。以下展示了两种方法: -```python -from typing import Optional, Union -from app.helper import DownloaderHelper -from app.modules import Transmission, Qbittorrent # 假设这些是具体的下载器类 +1. **使用事件监听进行模块重载,从而保持服务实例共享** -class MyPlugin: - def init_plugin(self, config: dict = None): - self.downloaderhelper = DownloaderHelper() - self._downloader = None + 如果外部模块进行了重载,需要监听模块重载事件以重置下载器服务。 - def set_downloader(self, downloader_name: str): - self._downloader = self.downloaderhelper.get_service(name=downloader_name) + ```python + from typing import Optional, Union + from app.helper import DownloaderHelper + from app.modules.qbittorrent import Qbittorrent + from app.modules.transmission import Transmission + from app.events import EventType, eventmanager - def __get_downloader(self) -> Optional[Union[Transmission, Qbittorrent]]: - """ - 获取下载器实例 - """ - if not self._downloader: - return None - return self._downloader.instance + class MyPlugin: + def __init__(self, config: dict = None): + self.downloaderhelper = DownloaderHelper(config) + self._downloader = None + self.__setup_downloader(config.get("downloader_name")) - def __get_downloader_type(self) -> Optional[str]: - """ - 获取下载器类型 - """ - if not self._downloader: - return None - return self._downloader.type + def __setup_downloader(self, downloader_name: str): + self._downloader = self.downloaderhelper.get_service(name=downloader_name) - def __is_qbittorrent(self) -> bool: - """ - 判断下载器是否为 qbittorrent - """ - if not self._downloader: + def __get_downloader(self) -> Optional[Union[Transmission, Qbittorrent]]: + """ + 获取下载器实例 + """ + if not self._downloader: + return None + return self._downloader.instance + + @eventmanager.register(EventType.ModuleReload) + def module_reload(self, event: Event): + """ + 模块重载事件 + """ + if not event: + return + event_data = event.event_data or {} + module_id = event_data.get("module_id") + # 如果模块标识不存在,则说明所有模块均发生重载 + if not module_id: + self.__setup_downloader() + + def check_downloader_type(self) -> bool: + """ + 检查下载器类型是否为 qbittorrent 或 transmission + """ + downloader = self.__get_downloader() + if self.downloaderhelper.is_qbittorrent(service=downloader): + # 处理 qbittorrent 类型 + return True + elif self.downloaderhelper.is_transmission(service=downloader): + # 处理 transmission 类型 + return True return False - return self._downloader.type == "qbittorrent" + ``` - def __is_transmission(self) -> bool: - """ - 判断下载器是否为 transmission - """ - if not self._downloader: +2. **使用 Property 实现服务实例共享** + + 通过 `Property` 方法,从而保持服务实例共享,而无需通过事件监听。 + + ```python + from typing import Optional, Union + from app.helper import DownloaderHelper + from app.modules.qbittorrent import Qbittorrent + from app.modules.transmission import Transmission + + class MyPlugin: + def __init__(self, config: dict = None): + self.downloaderhelper = DownloaderHelper(config) + + @property + def service_info(self) -> Optional[ServiceInfo]: + """ + 服务信息 + """ + service = self.downloaderhelper.get_service(name=self.downloader_name) + if not service: + return None + + if service.instance.is_inactive(): + return None + + return service + + @property + def downloader(self) -> Optional[Union[Qbittorrent, Transmission]]: + """ + 下载器实例 + """ + return self.service_info.instance if self.service_info else None + + def check_downloader_type(self) -> bool: + """ + 检查下载器类型是否为 qbittorrent 或 transmission + """ + if self.downloaderhelper.is_qbittorrent(service=self.service_info): + # 处理 qbittorrent 类型 + return True + elif self.downloaderhelper.is_transmission(service=self.service_info): + # 处理 transmission 类型 + return True return False - return self._downloader.type == "transmission" -``` - -#### 获取媒体服务器服务 - -类似地,插件可以通过 `MediaServerHelper` 获取媒体服务器相关的服务信息。 - -```python -from app.helper import MediaServerHelper - -class MyPlugin: - def init_plugin(self, config: dict = None): - self.mediaserverhelper = MediaServerHelper() - self.mediaserver_options = [ - {"title": config.name, "value": config.name} - for config in self.mediaserverhelper.get_configs().values() - ] - - def get_media_server_instance(self, server_name: str): - service_info = self.mediaserverhelper.get_service(name=server_name) - if service_info: - return service_info.instance - return None -``` - -#### 获取消息通知服务 - -通过 `NotificationHelper`,插件可以发送消息通知。 - -```python -from app.helper import NotificationHelper - -class MyPlugin: - def init_plugin(self, config: dict = None): - self.notificationhelper = NotificationHelper() - - def get_notify_service_instance(self, name: str, message: str): - service_info = self.notificationhelper.get_service(name=name) - if service_info: - return service_info.instance - return None -``` + ``` ### 2.4 服务封装的优势 @@ -405,8 +452,8 @@ class MyPlugin: - **灵活扩展**:新增服务类型时,只需创建相应的帮助类,无需修改现有逻辑。 - **便捷调用**:插件可以轻松获取所需的服务实例,简化了服务的调用过程。 -### 2.5 从V1升级到V2的注意事项 +### 2.5 从 V1 升级到 V2 的注意事项 -- **使用帮助类**:尝试在插件中使用了新的服务帮助类,如 `DownloaderHelper`、`MediaServerHelper`、`NotificationHelper` 等,而不是直接操作服务实例。 -- **更新依赖**:检查并更新 `requirements.txt` 中的依赖,确保与V2的服务封装兼容。 -- **测试插件**:在V2环境中全面测试插件,确保所有服务调用正常工作。 \ No newline at end of file +- **使用帮助类**:确保插件中使用了新的服务帮助类,如 `DownloaderHelper`、`MediaServerHelper`、`NotificationHelper` 等,而不是直接操作服务实例。 +- **更新依赖**:检查并更新 `requirements.txt` 中的依赖,确保与 V2 的服务封装兼容。 +- **测试插件**:在 V2 环境中全面测试插件,确保所有服务调用正常工作。 \ No newline at end of file From 25863a409d6fc0808e63d352bbce421e2424ee05 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Tue, 1 Oct 2024 16:05:05 +0800 Subject: [PATCH 060/218] chore: update V2_Plugin_Development.md --- docs/V2_Plugin_Development.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/V2_Plugin_Development.md b/docs/V2_Plugin_Development.md index 935f27e..d9dd7ce 100644 --- a/docs/V2_Plugin_Development.md +++ b/docs/V2_Plugin_Development.md @@ -331,7 +331,7 @@ class NotificationHelper(ServiceBaseHelper[NotificationConf]): from app.helper.downloader import DownloaderHelper class MyPlugin: - def __init__(self, config: dict = None): + def init_plugin(self, config: dict = None): self.downloaderhelper = DownloaderHelper(config) self.downloader_options = [ {"title": config.name, "value": config.name} @@ -355,7 +355,7 @@ class MyPlugin: from app.events import EventType, eventmanager class MyPlugin: - def __init__(self, config: dict = None): + def init_plugin(self, config: dict = None): self.downloaderhelper = DownloaderHelper(config) self._downloader = None self.__setup_downloader(config.get("downloader_name")) @@ -409,7 +409,7 @@ class MyPlugin: from app.modules.transmission import Transmission class MyPlugin: - def __init__(self, config: dict = None): + def init_plugin(self, config: dict = None): self.downloaderhelper = DownloaderHelper(config) @property From 09ec7a06570a6b603e20bc1bbfd9a204b8ea430c Mon Sep 17 00:00:00 2001 From: shaoyangx <2606632090@qq.com> Date: Wed, 2 Oct 2024 19:49:08 +0800 Subject: [PATCH 061/218] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E8=BD=AC=E9=92=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/torrenttransfer/__init__.py | 57 ++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/plugins/torrenttransfer/__init__.py b/plugins/torrenttransfer/__init__.py index fa22714..0ad8316 100644 --- a/plugins/torrenttransfer/__init__.py +++ b/plugins/torrenttransfer/__init__.py @@ -27,7 +27,7 @@ class TorrentTransfer(_PluginBase): # 插件图标 plugin_icon = "seed.png" # 插件版本 - plugin_version = "1.5" + plugin_version = "1.6" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -55,6 +55,7 @@ class TorrentTransfer(_PluginBase): _notify = False _nolabels = None _includelabels = None + _includecategory = None _nopaths = None _deletesource = False _deleteduplicate = False @@ -79,6 +80,7 @@ class TorrentTransfer(_PluginBase): self._notify = config.get("notify") self._nolabels = config.get("nolabels") self._includelabels = config.get("includelabels") + self._includecategory = config.get("includecategory") self._frompath = config.get("frompath") self._topath = config.get("topath") self._fromdownloader = config.get("fromdownloader") @@ -128,6 +130,7 @@ class TorrentTransfer(_PluginBase): "notify": self._notify, "nolabels": self._nolabels, "includelabels": self._includelabels, + "includecategory": self._includecategory, "frompath": self._frompath, "topath": self._topath, "fromdownloader": self._fromdownloader, @@ -250,7 +253,7 @@ class TorrentTransfer(_PluginBase): 'component': 'VCol', 'props': { 'cols': 12, - 'md': 4 + 'md': 6 }, 'content': [ { @@ -262,12 +265,28 @@ class TorrentTransfer(_PluginBase): } } ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'includecategory', + 'label': '转移种子分类', + } + } + ] }, { 'component': 'VCol', 'props': { 'cols': 12, - 'md': 4 + 'md': 6 }, 'content': [ { @@ -282,7 +301,7 @@ class TorrentTransfer(_PluginBase): 'component': 'VCol', 'props': { 'cols': 12, - 'md': 4 + 'md': 6 }, 'content': [ { @@ -293,7 +312,7 @@ class TorrentTransfer(_PluginBase): } } ] - } + }, ] }, { @@ -494,6 +513,7 @@ class TorrentTransfer(_PluginBase): "cron": "", "nolabels": "", "includelabels": "", + "includecategory": "", "frompath": "", "topath": "", "fromdownloader": "", @@ -531,7 +551,7 @@ class TorrentTransfer(_PluginBase): state = self.qb.add_torrent(content=content, download_dir=save_path, is_paused=True, - tag=["已整理", "转移做种", tag]) + tag=["转移做种", tag]) if not state: return None else: @@ -546,7 +566,7 @@ class TorrentTransfer(_PluginBase): torrent = self.tr.add_torrent(content=content, download_dir=save_path, is_paused=True, - labels=["已整理", "转移做种"]) + labels=["转移做种"]) if not torrent: return None else: @@ -600,12 +620,19 @@ class TorrentTransfer(_PluginBase): # 获取种子标签 torrent_labels = self.__get_label(torrent, downloader) - + # 获取种子分类 + torrent_category = self.__get_category(torrent, downloader) # 种子为无标签,则进行规范化 is_torrent_labels_empty = torrent_labels == [''] or torrent_labels == [] or torrent_labels is None if is_torrent_labels_empty: torrent_labels = [] - + + # 如果分类项存在数值,则进行判断 + if self._includecategory: + # 排除未标记的分类 + if torrent_category not in self._includecategory.split(','): + logger.info(f"种子 {hash_str} 不含有转移分类 {self._includecategory},跳过 ...") + continue #根据设置决定是否转移无标签的种子 if is_torrent_labels_empty: if not self._transferemptylabel: @@ -870,6 +897,18 @@ class TorrentTransfer(_PluginBase): print(str(e)) return [] + @staticmethod + def __get_category(torrent: Any, dl_type: str): + """ + 获取种子分类 + """ + try: + return str(torrent.get("category")).strip() \ + if dl_type == "qbittorrent" else "" + except Exception as e: + print(str(e)) + return "" + @staticmethod def __get_save_path(torrent: Any, dl_type: str): """ From 221b840aecaad574686868e4c297aca1aea3df4d Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sun, 6 Oct 2024 10:50:31 +0800 Subject: [PATCH 062/218] feat(BrushFlow): add support for v2 plugin --- plugins.v2/brushflow/__init__.py | 479 ++++++++----------------------- 1 file changed, 120 insertions(+), 359 deletions(-) diff --git a/plugins.v2/brushflow/__init__.py b/plugins.v2/brushflow/__init__.py index faa6c05..8fbe091 100644 --- a/plugins.v2/brushflow/__init__.py +++ b/plugins.v2/brushflow/__init__.py @@ -5,7 +5,6 @@ import re import threading import time from datetime import datetime, timedelta -from threading import Event from typing import Any, List, Dict, Tuple, Optional, Union, Set from urllib.parse import urlparse, parse_qs, unquote @@ -20,11 +19,12 @@ from app.core.context import MediaInfo from app.core.metainfo import MetaInfo from app.db.site_oper import SiteOper from app.db.subscribe_oper import SubscribeOper +from app.helper.downloader import DownloaderHelper from app.log import logger from app.modules.qbittorrent import Qbittorrent from app.modules.transmission import Transmission from app.plugins import _PluginBase -from app.schemas import NotificationType, TorrentInfo, MediaType +from app.schemas import NotificationType, TorrentInfo, MediaType, ServiceInfo from app.schemas.types import EventType from app.utils.http import RequestUtils from app.utils.string import StringUtils @@ -42,7 +42,7 @@ class BrushConfig: self.notify = config.get("notify", True) self.onlyonce = config.get("onlyonce", False) self.brushsites = config.get("brushsites", []) - self.downloader = config.get("downloader", "qbittorrent") + self.downloader = config.get("downloader") self.disksize = self.__parse_number(config.get("disksize")) self.freeleech = config.get("freeleech", "free") self.hr = config.get("hr", "no") @@ -67,17 +67,12 @@ class BrushConfig: self.auto_archive_days = self.__parse_number(config.get("auto_archive_days")) self.save_path = config.get("save_path") self.clear_task = config.get("clear_task", False) - self.archive_task = config.get("archive_task", False) self.delete_except_tags = config.get("delete_except_tags") self.except_subscribe = config.get("except_subscribe", True) self.brush_sequential = config.get("brush_sequential", False) - self.proxy_download = config.get("proxy_download", False) self.proxy_delete = config.get("proxy_delete", False) self.active_time_range = config.get("active_time_range") - self.downloader_monitor = config.get("downloader_monitor") self.qb_category = config.get("qb_category") - self.auto_qb_category = config.get("auto_qb_category", False) - self.qb_first_last_piece = config.get("qb_first_last_piece", False) self.site_hr_active = config.get("site_hr_active", False) self.brush_tag = "刷流" @@ -118,11 +113,8 @@ class BrushConfig: "seed_avgspeed", "seed_inactivetime", "save_path", - "proxy_download", "proxy_delete", "qb_category", - "auto_qb_category", - "qb_first_last_piece", "site_hr_active" # 当新增支持字段时,仅在此处添加字段名 } @@ -140,7 +132,7 @@ class BrushConfig: site_specific_config = {key: config[key] for key in allowed_fields & set(config.keys())} full_config = {key: getattr(self, key) for key in vars(self) if - key not in ['group_site_configs', 'site_config']} + key not in ["group_site_configs", "site_config"]} full_config.update(site_specific_config) self.group_site_configs[sitename] = BrushConfig(config=full_config, process_site_config=False) @@ -153,7 +145,7 @@ class BrushConfig: @staticmethod def get_demo_site_config() -> str: desc = ( - "// 以下为配置示例,请参考:https://github.com/InfinityPacer/MoviePilot-Plugins/blob/main/plugins/brushflowlowfreq/README.md 进行配置\n" + "// 以下为配置示例,请参考:https://github.com/InfinityPacer/MoviePilot-Plugins/blob/main/plugins.v2/brushflowlowfreq/README.md 进行配置\n" "// 如与全局保持一致的配置项,请勿在站点配置中配置\n" "// 注意无关内容需使用 // 注释\n") config = """[{ @@ -168,7 +160,6 @@ class BrushConfig: "pubtime": "5-120", "seed_time": 96, "save_path": "/downloads/site2", - "proxy_download": true, "hr_seed_time": 144 }, { "sitename": "站点3", @@ -187,11 +178,8 @@ class BrushConfig: "seed_avgspeed": "", "seed_inactivetime": "", "save_path": "/downloads/site1", - "proxy_download": false, "proxy_delete": false, "qb_category": "刷流", - "auto_qb_category": true, - "qb_first_last_piece": true, "site_hr_active": true }]""" return desc + config @@ -206,7 +194,7 @@ class BrushConfig: @staticmethod def __parse_number(value): - if value is None or value == '': # 更精确地检查None或空字符串 + if value is None or value == "": # 更精确地检查None或空字符串 return value elif isinstance(value, int): # 直接判断是否为int return value @@ -273,10 +261,9 @@ class BrushFlow(_PluginBase): # 私有属性 siteshelper = None siteoper = None - torrents = None + torrentschain = None subscribeoper = None - qb = None - tr = None + downloaderhelper = None # 刷流配置 _brush_config = None # Brush任务是否启动 @@ -288,18 +275,45 @@ class BrushFlow(_PluginBase): # Check定时 _check_interval = 5 # 退出事件 - _event = Event() + _event = threading.Event() _scheduler = None # tabs _tabs = None + # Property + + @property + def service_info(self) -> Optional[ServiceInfo]: + """ + 服务信息 + """ + brush_config = self.__get_brush_config() + service = self.downloaderhelper.get_service(name=brush_config.downloader) + if not service: + self.__log_and_notify_error("站点刷流任务出错,获取下载器实例失败,请检查配置") + return None + + if service.instance.is_inactive(): + self.__log_and_notify_error("站点刷流任务出错,下载器未连接") + return None + + return service + + @property + def downloader(self) -> Optional[Union[Qbittorrent, Transmission]]: + """ + 下载器实例 + """ + return self.service_info.instance if self.service_info else None + # endregion def init_plugin(self, config: dict = None): self.siteshelper = SitesHelper() self.siteoper = SiteOper() - self.torrents = TorrentsChain() + self.torrentschain = TorrentsChain() self.subscribeoper = SubscribeOper() + self.downloaderhelper = DownloaderHelper() self._task_brush_enable = False if not config: @@ -332,12 +346,6 @@ class BrushFlow(_PluginBase): if brush_config.clear_task: self.__clear_tasks() brush_config.clear_task = False - brush_config.archive_task = False - self.__update_config() - - elif brush_config.archive_task: - self.__archive_tasks() - brush_config.archive_task = False self.__update_config() if brush_config.enable_site_config: @@ -348,8 +356,12 @@ class BrushFlow(_PluginBase): # 停止现有任务 self.stop_service() - if not self.__setup_downloader(): - return + # 如果站点都没有配置,则不开启定时刷流服务 + if not brush_config.brushsites: + logger.info(f"站点刷流定时服务停止,没有配置站点") + + # 如果开启&存在站点时,才需要启用后台任务 + self._task_brush_enable = brush_config.enabled and brush_config.brushsites # 如果下载器都没有配置,那么这里也不需要继续 if not brush_config.downloader: @@ -358,30 +370,26 @@ class BrushFlow(_PluginBase): logger.info(f"站点刷流服务停止,没有配置下载器") return - # 如果站点都没有配置,则不开启定时刷流服务 - if not brush_config.brushsites: - logger.info(f"站点刷流Brush定时服务停止,没有配置站点") - - # 如果开启&存在站点时,才需要启用后台任务 - self._task_brush_enable = brush_config.enabled and brush_config.brushsites + if not self.service_info: + return # 检查是否启用了一次性任务 if brush_config.onlyonce: self._scheduler = BackgroundScheduler(timezone=settings.TZ) - logger.info(f"站点刷流Brush服务启动,立即运行一次") - self._scheduler.add_job(self.brush, 'date', + logger.info(f"站点刷流服务启动,立即运行一次") + self._scheduler.add_job(self.brush, "date", run_date=datetime.now( tz=pytz.timezone(settings.TZ) ) + timedelta(seconds=3), - name="站点刷流Brush服务") + name="站点刷流服务") - logger.info(f"站点刷流Check服务启动,立即运行一次") - self._scheduler.add_job(self.check, 'date', + logger.info(f"站点刷流检查服务启动,立即运行一次") + self._scheduler.add_job(self.check, "date", run_date=datetime.now( tz=pytz.timezone(settings.TZ) ) + timedelta(seconds=3), - name="站点刷流Check服务") + name="站点刷流检查服务") # 关闭一次性开关 brush_config.onlyonce = False @@ -422,20 +430,20 @@ class BrushFlow(_PluginBase): return services if self._task_brush_enable: - logger.info(f"站点刷流Brush定时服务启动,时间间隔 {self._brush_interval} 分钟") + logger.info(f"站点刷流定时服务启动,时间间隔 {self._brush_interval} 分钟") services.append({ "id": "BrushFlow", - "name": "站点刷流Brush服务", + "name": "站点刷流服务", "trigger": "interval", "func": self.brush, "kwargs": {"minutes": self._brush_interval} }) if brush_config.enabled: - logger.info(f"站点刷流Check定时服务启动,时间间隔 {self._check_interval} 分钟") + logger.info(f"站点刷流检查定时服务启动,时间间隔 {self._check_interval} 分钟") services.append({ "id": "BrushFlowCheck", - "name": "站点刷流Check服务", + "name": "站点刷流检查服务", "trigger": "interval", "func": self.check, "kwargs": {"minutes": self._check_interval} @@ -752,7 +760,7 @@ class BrushFlow(_PluginBase): }, ] - def get_dashboard(self) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], List[dict]]]: + def get_dashboard(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], List[dict]]]: """ 获取插件仪表盘页面,需要返回:1、仪表板col配置字典;2、全局配置(自动刷新等);3、仪表板页面元素配置json(含数据) 1、col配置参考: @@ -785,9 +793,12 @@ class BrushFlow(_PluginBase): 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 """ - # 站点的可选项 + # 站点选项 site_options = [{"title": site.get("name"), "value": site.get("id")} for site in self.siteshelper.get_indexers()] + # 下载器选项 + downloader_options = [{"title": config.name, "value": config.name} + for config in self.downloaderhelper.get_configs().values()] return [ { 'component': 'VForm', @@ -884,10 +895,7 @@ class BrushFlow(_PluginBase): 'props': { 'model': 'downloader', 'label': '下载器', - 'items': [ - {'title': 'Qbittorrent', 'value': 'qbittorrent'}, - {'title': 'Transmission', 'value': 'transmission'} - ] + 'items': downloader_options } } ] @@ -1520,8 +1528,8 @@ class BrushFlow(_PluginBase): { 'component': 'VSwitch', 'props': { - 'model': 'qb_first_last_piece', - 'label': '优先下载首尾文件块', + 'model': 'proxy_delete', + 'label': '动态删除种子(实验性功能)', } } ] @@ -1547,43 +1555,6 @@ class BrushFlow(_PluginBase): } ] }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'archive_task', - 'label': '归档已删除种子', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'proxy_delete', - 'label': '动态删除种子(实验性功能)', - } - } - ] - } - ] - }, - { - 'component': 'VRow', - "content": [ { 'component': 'VCol', 'props': { @@ -1615,59 +1586,6 @@ class BrushFlow(_PluginBase): } } ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'proxy_download', - 'label': '代理下载种子', - } - } - ] - } - ] - }, - { - 'component': 'VRow', - "content": [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'downloader_monitor', - 'label': '下载器监控', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'auto_qb_category', - 'label': '自动分类管理', - } - } - ] } ] } @@ -1703,7 +1621,7 @@ class BrushFlow(_PluginBase): { 'component': 'a', 'props': { - 'href': 'https://github.com/InfinityPacer/MoviePilot-Plugins/blob/main/plugins/brushflowlowfreq/README.md', + 'href': 'https://github.com/InfinityPacer/MoviePilot-Plugins/blob/main/plugins.v2/brushflowlowfreq/README.md', 'target': '_blank' }, 'content': [ @@ -1810,7 +1728,7 @@ class BrushFlow(_PluginBase): { 'component': 'a', 'props': { - 'href': 'https://github.com/InfinityPacer/MoviePilot-Plugins/blob/main/plugins/brushflowlowfreq/README.md', + 'href': 'https://github.com/InfinityPacer/MoviePilot-Plugins/blob/main/plugins.v2/brushflowlowfreq/README.md', 'target': '_blank' }, 'content': [ @@ -1839,18 +1757,13 @@ class BrushFlow(_PluginBase): "notify": True, "onlyonce": False, "clear_task": False, - "archive_task": False, "delete_except_tags": f"{settings.TORRENT_TAG},H&R" if settings.TORRENT_TAG else "H&R", "except_subscribe": True, "brush_sequential": False, - "proxy_download": False, "proxy_delete": False, "freeleech": "free", "hr": "yes", "enable_site_config": False, - "downloader_monitor": False, - "auto_qb_category": False, - "qb_first_last_piece": False, "site_config": BrushConfig.get_demo_site_config() } @@ -1963,7 +1876,7 @@ class BrushFlow(_PluginBase): """ brush_config = self.__get_brush_config() - if not brush_config.brushsites or not brush_config.downloader: + if not brush_config.brushsites or not brush_config.downloader or not self.downloader: return if not self.__is_current_time_in_range(): @@ -2036,7 +1949,7 @@ class BrushFlow(_PluginBase): return True logger.info(f"开始获取站点 {siteinfo.name} 的新种子 ...") - torrents = self.torrents.browse(domain=siteinfo.domain) + torrents = self.torrentschain.browse(domain=siteinfo.domain) if not torrents: logger.info(f"站点 {siteinfo.name} 没有获取到种子") return True @@ -2323,7 +2236,7 @@ class BrushFlow(_PluginBase): """ brush_config = self.__get_brush_config() - if not brush_config.downloader: + if not brush_config.downloader or not self.downloader: return with lock: @@ -2331,11 +2244,7 @@ class BrushFlow(_PluginBase): torrent_tasks: Dict[str, dict] = self.get_data("torrents") or {} unmanaged_tasks: Dict[str, dict] = self.get_data("unmanaged") or {} - downloader = self.__get_downloader(brush_config.downloader) - if not downloader: - logger.warn("无法获取下载器实例,将在下个时间周期重试") - return - + downloader = self.downloader seeding_torrents, error = downloader.get_torrents() if error: logger.warn("连接下载器出错,将在下个时间周期重试") @@ -2405,7 +2314,7 @@ class BrushFlow(_PluginBase): if need_delete_hashes: # 如果是QB,则重新汇报Tracker - if brush_config.downloader == "qbittorrent": + if self.downloaderhelper.is_qbittorrent(service=self.service_info): self.__qb_torrents_reannounce(torrent_hashes=need_delete_hashes) # 删除种子 if downloader.delete_torrents(ids=need_delete_hashes, delete_file=True): @@ -2447,13 +2356,7 @@ class BrushFlow(_PluginBase): seeding_torrents_dict: Dict[str, Any]): brush_config = self.__get_brush_config() - if brush_config.downloader_monitor: - logger.info("已开启下载器监控,开始同步种子刷流标签记录") - else: - logger.info("没有开启下载器监控,取消同步种子刷流标签记录") - return - - if not brush_config.downloader == "qbittorrent": + if not self.downloaderhelper.is_qbittorrent(service=self.service_info): logger.info("同步种子刷流标签记录目前仅支持qbittorrent") return @@ -2766,7 +2669,7 @@ class BrushFlow(_PluginBase): remaining_hashes = list( {self.__get_hash(torrent) for torrent in proxy_delete_torrents} - set(need_delete_hashes)) # 这里根据排除后的种子列表,再次从下载器中找到已完成的任务 - downloader = self.__get_downloader(brush_config.downloader) + downloader = self.downloader completed_torrents = downloader.get_completed_torrents(ids=remaining_hashes) remaining_hashes = {self.__get_hash(torrent) for torrent in completed_torrents} remaining_torrents = [(_hash, torrent_info_map[_hash]) for _hash in remaining_hashes] @@ -2819,14 +2722,6 @@ class BrushFlow(_PluginBase): """ 处理已经被删除,但是任务记录中还没有被标记删除的种子 """ - brush_config = self.__get_brush_config() - - if brush_config.downloader_monitor: - logger.info("已开启下载器监控,开始同步刷流任务删除记录") - else: - logger.info("没有开启下载器监控,取消同步刷流任务删除记录") - return - # 先通过获取的全量种子,判断已经被删除,但是任务记录中还没有被标记删除的种子 torrent_all_hashes = self.__get_all_hashes(torrents) missing_hashes = [hash_value for hash_value in torrent_check_hashes if hash_value not in torrent_all_hashes] @@ -3044,17 +2939,12 @@ class BrushFlow(_PluginBase): "auto_archive_days": brush_config.auto_archive_days, "save_path": brush_config.save_path, "clear_task": brush_config.clear_task, - "archive_task": brush_config.archive_task, "delete_except_tags": brush_config.delete_except_tags, "except_subscribe": brush_config.except_subscribe, "brush_sequential": brush_config.brush_sequential, - "proxy_download": brush_config.proxy_download, "proxy_delete": brush_config.proxy_delete, "active_time_range": brush_config.active_time_range, - "downloader_monitor": brush_config.downloader_monitor, "qb_category": brush_config.qb_category, - "auto_qb_category": brush_config.auto_qb_category, - "qb_first_last_piece": brush_config.qb_first_last_piece, "enable_site_config": brush_config.enable_site_config, "site_config": brush_config.site_config, "_tabs": self._tabs @@ -3063,38 +2953,6 @@ class BrushFlow(_PluginBase): # 使用update_config方法或其等效方法更新配置 self.update_config(config_mapping) - def __setup_downloader(self): - """ - 根据下载器类型初始化下载器实例 - """ - brush_config = self.__get_brush_config() - self.qb = Qbittorrent() - self.tr = Transmission() - - if brush_config.downloader == "qbittorrent": - if self.qb.is_inactive(): - self.__log_and_notify_error("站点刷流任务出错:Qbittorrent未连接") - return False - - elif brush_config.downloader == "transmission": - - if self.tr.is_inactive(): - self.__log_and_notify_error("站点刷流任务出错:Transmission未连接") - return False - - return True - - def __get_downloader(self, dtype: str) -> Optional[Union[Transmission, Qbittorrent]]: - """ - 根据类型返回下载器实例 - """ - if dtype == "qbittorrent": - return self.qb - elif dtype == "transmission": - return self.tr - else: - return None - @staticmethod def __get_redict_url(url: str, proxies: str = None, ua: str = None, cookie: str = None) -> Optional[str]: """ @@ -3183,135 +3041,76 @@ class BrushFlow(_PluginBase): logger.error(f"获取下载链接失败:{torrent.title}") return None - if brush_config.downloader == "qbittorrent": - if not self.qb: - return None + downloader = self.downloader + if not downloader: + return None + + if self.downloaderhelper.is_qbittorrent(service=self.service_info): # 限速值转为bytes up_speed = up_speed * 1024 if up_speed else None down_speed = down_speed * 1024 if down_speed else None # 生成随机Tag tag = StringUtils.generate_random_str(10) # 如果开启代理下载以及种子地址不是磁力地址,则请求种子到内存再传入下载器 - if brush_config.proxy_download and not torrent_content.startswith("magnet"): + if not torrent_content.startswith("magnet"): response = RequestUtils(cookies=cookies, proxies=proxies, ua=torrent.site_ua).get_res(url=torrent_content) if response and response.ok: torrent_content = response.content else: - logger.error('尝试通过MP下载种子失败,继续尝试传递种子地址到下载器进行下载') + logger.error("尝试通过MP下载种子失败,继续尝试传递种子地址到下载器进行下载") if torrent_content: - state = self.__qb_add_torrent(content=torrent_content, - download_dir=download_dir, - cookie=cookies, - tag=["已整理", brush_config.brush_tag, tag], - category=brush_config.qb_category, - is_auto=brush_config.auto_qb_category, - is_first_last_piece_priority=brush_config.qb_first_last_piece, - upload_limit=up_speed, - download_limit=down_speed) + state = downloader.add_torrent(content=torrent_content, + download_dir=download_dir, + cookie=cookies, + category=brush_config.qb_category, + tag=["已整理", brush_config.brush_tag, tag], + upload_limit=up_speed, + download_limit=down_speed) if not state: return None else: # 获取种子Hash - torrent_hash = self.qb.get_torrent_id_by_tag(tags=tag) + torrent_hash = downloader.get_torrent_id_by_tag(tags=tag) if not torrent_hash: logger.error(f"{brush_config.downloader} 获取种子Hash失败,详细信息请查看 README") return None return torrent_hash return None - elif brush_config.downloader == "transmission": - if not self.tr: - return None + elif self.downloaderhelper.is_transmission(service=self.service_info): # 如果开启代理下载以及种子地址不是磁力地址,则请求种子到内存再传入下载器 - if brush_config.proxy_download and not torrent_content.startswith("magnet"): + if not torrent_content.startswith("magnet"): response = RequestUtils(cookies=cookies, proxies=proxies, ua=torrent.site_ua).get_res(url=torrent_content) if response and response.ok: torrent_content = response.content else: - logger.error('尝试通过MP下载种子失败,继续尝试传递种子地址到下载器进行下载') + logger.error("尝试通过MP下载种子失败,继续尝试传递种子地址到下载器进行下载") if torrent_content: - torrent = self.tr.add_torrent(content=torrent_content, - download_dir=download_dir, - cookie=cookies, - labels=["已整理", brush_config.brush_tag]) + torrent = downloader.add_torrent(content=torrent_content, + download_dir=download_dir, + cookie=cookies, + labels=["已整理", brush_config.brush_tag]) if not torrent: return None else: if brush_config.up_speed or brush_config.dl_speed: - self.tr.change_torrent(hash_string=torrent.hashString, - upload_limit=up_speed, - download_limit=down_speed) + downloader.change_torrent(hash_string=torrent.hashString, + upload_limit=up_speed, + download_limit=down_speed) return torrent.hashString return None - def __qb_add_torrent(self, - content: Union[str, bytes], - is_paused: bool = False, - download_dir: str = None, - tag: Union[str, list] = None, - category: str = None, - cookie=None, - is_auto=False, - is_first_last_piece_priority=False, - **kwargs - ) -> bool: - """ - 添加种子 - :param content: 种子urls或文件内容 - :param is_paused: 添加后暂停 - :param tag: 标签 - :param category: 种子分类 - :param download_dir: 下载路径 - :param cookie: 站点Cookie用于辅助下载种子 - :return: bool - """ - if not self.qb.qbc or not content: - return False - - # 下载内容 - if isinstance(content, str): - urls = content - torrent_files = None - else: - urls = None - torrent_files = content - - # 保存目录 - if download_dir: - save_path = download_dir - else: - save_path = None - - # 标签 - if tag: - tags = tag - else: - tags = None - - try: - # 添加下载 - qbc_ret = self.qb.qbc.torrents_add(urls=urls, - torrent_files=torrent_files, - save_path=save_path, - is_paused=is_paused, - tags=tags, - use_auto_torrent_management=is_auto, - is_first_last_piece_priority=is_first_last_piece_priority, - cookie=cookie, - category=category, - **kwargs) - return True if qbc_ret and str(qbc_ret).find("Ok") != -1 else False - except Exception as err: - logger.error(f"添加种子出错:{str(err)}") - return False - def __qb_torrents_reannounce(self, torrent_hashes: List[str]): """强制重新汇报""" - if not self.qb.qbc: + downloader = self.downloader + if not downloader: + return + + if not downloader.qbc: return if not torrent_hashes: @@ -3319,7 +3118,7 @@ class BrushFlow(_PluginBase): try: # 重新汇报 - self.qb.qbc.torrents_reannounce(torrent_hashes=torrent_hashes) + downloader.qbc.torrents_reannounce(torrent_hashes=torrent_hashes) except Exception as err: logger.error(f"强制重新汇报失败:{str(err)}") @@ -3327,9 +3126,9 @@ class BrushFlow(_PluginBase): """ 获取种子hash """ - brush_config = self.__get_brush_config() try: - return torrent.get("hash") if brush_config.downloader == "qbittorrent" else torrent.hashString + return torrent.get("hash") if self.downloaderhelper.is_qbittorrent(service=self.service_info) \ + else torrent.hashString except Exception as e: print(str(e)) return "" @@ -3341,12 +3140,12 @@ class BrushFlow(_PluginBase): :param torrents: 包含种子信息的列表 :return: 包含所有Hash值的列表 """ - brush_config = self.__get_brush_config() try: all_hashes = [] for torrent in torrents: # 根据下载器类型获取Hash值 - hash_value = torrent.get("hash") if brush_config.downloader == "qbittorrent" else torrent.hashString + hash_value = torrent.get("hash") if self.downloaderhelper.is_qbittorrent(service=self.service_info) \ + else torrent.hashString if hash_value: all_hashes.append(hash_value) return all_hashes @@ -3358,10 +3157,9 @@ class BrushFlow(_PluginBase): """ 获取种子标签 """ - brush_config = self.__get_brush_config() try: return [str(tag).strip() for tag in torrent.get("tags").split(',')] \ - if brush_config.downloader == "qbittorrent" else torrent.labels or [] + if self.downloaderhelper.is_qbittorrent(service=self.service_info) else torrent.labels or [] except Exception as e: print(str(e)) return [] @@ -3371,9 +3169,8 @@ class BrushFlow(_PluginBase): 获取种子信息 """ date_now = int(time.time()) - brush_config = self.__get_brush_config() # QB - if brush_config.downloader == "qbittorrent": + if self.downloaderhelper.is_qbittorrent(service=self.service_info): """ { "added_on": 1693359031, @@ -3644,23 +3441,17 @@ class BrushFlow(_PluginBase): """ ret_info = schemas.DownloaderInfo() - # Qbittorrent - if self.qb: - info = self.qb.transfer_info() - if info: - ret_info.download_speed += info.get("dl_info_speed") - ret_info.upload_speed += info.get("up_info_speed") - ret_info.download_size += info.get("dl_info_data") - ret_info.upload_size += info.get("up_info_data") + downloader = self.downloader + if not downloader: + return ret_info - # Transmission - if self.tr: - info = self.tr.transfer_info() - if info: - ret_info.download_speed += info.download_speed - ret_info.upload_speed += info.upload_speed - ret_info.download_size += info.current_stats.downloaded_bytes - ret_info.upload_size += info.current_stats.uploaded_bytes + transfer_infos = self.chain.run_module("downloader_info") + if transfer_infos: + for transfer_info in transfer_infos: + ret_info.download_speed += transfer_info.download_speed + ret_info.upload_speed += transfer_info.upload_speed + ret_info.download_size += transfer_info.download_size + ret_info.upload_size += transfer_info.upload_size return ret_info @@ -3670,7 +3461,7 @@ class BrushFlow(_PluginBase): """ try: brush_config = self.__get_brush_config() - downloader = self.__get_downloader(brush_config.downloader) + downloader = self.downloader if not downloader: return 0 @@ -3904,36 +3695,6 @@ class BrushFlow(_PluginBase): self.save_data("archived", archived_tasks) - def __archive_tasks(self): - """ - 归档已经删除的种子数据 - """ - torrent_tasks: Dict[str, dict] = self.get_data("torrents") or {} - - # 用于存储已删除的数据 - archived_tasks: Dict[str, dict] = self.get_data("archived") or {} - - # 准备一个列表,记录所有需要从原始数据中删除的键 - keys_to_delete = set() - - # 遍历所有 torrent 条目 - for key, value in torrent_tasks.items(): - # 检查是否标记为已删除 - if value.get("deleted"): - # 如果是,加入到归档字典中 - archived_tasks[key] = value - # 记录键,稍后删除 - keys_to_delete.add(key) - - # 从原始字典中移除已删除的条目 - for key in keys_to_delete: - del torrent_tasks[key] - - self.save_data("archived", archived_tasks) - self.save_data("torrents", torrent_tasks) - # 归档需要更新一下统计数据 - self.__update_and_save_statistic_info(torrent_tasks=torrent_tasks) - def __clear_tasks(self): """ 清除统计数据 From 277eecff44987508f0dc78fde40c7c7971ea6972 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sun, 6 Oct 2024 10:53:16 +0800 Subject: [PATCH 063/218] feat(BrushFlow): v3.9 --- package.v2.json | 12 ++++++++++++ plugins.v2/brushflow/__init__.py | 5 ++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/package.v2.json b/package.v2.json index 7bedbe4..ee1042a 100644 --- a/package.v2.json +++ b/package.v2.json @@ -10,5 +10,17 @@ "history": { "v1.0.1": "MoviePilot V2 版本站点数据统计插件" } + }, + "BrushFlow": { + "name": "站点刷流", + "description": "自动托管刷流,将会提高对应站点的访问频率。", + "labels": "刷流,仪表板", + "version": "3.9", + "icon": "brush.jpg", + "author": "jxxghp,InfinityPacer", + "level": 2, + "history": { + "v3.9": "MoviePilot V2 版本站点刷流插件" + } } } \ No newline at end of file diff --git a/plugins.v2/brushflow/__init__.py b/plugins.v2/brushflow/__init__.py index 8fbe091..06f126a 100644 --- a/plugins.v2/brushflow/__init__.py +++ b/plugins.v2/brushflow/__init__.py @@ -246,7 +246,10 @@ class BrushFlow(_PluginBase): # 插件图标 plugin_icon = "brush.jpg" # 插件版本 - plugin_version = "3.8" + "history": { + "v3.9": "MoviePilot V2 版本站点数据统计插件" + } + plugin_version = "3.9" # 插件作者 plugin_author = "jxxghp,InfinityPacer" # 作者主页 From 6c5a139080800991ee2c70b93187d8887afed45d Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sun, 6 Oct 2024 16:14:50 +0800 Subject: [PATCH 064/218] fix(SiteStatistic): replace refresh_userdata with self.refresh --- plugins.v2/sitestatistic/__init__.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/plugins.v2/sitestatistic/__init__.py b/plugins.v2/sitestatistic/__init__.py index f1c5d2e..b5a373f 100644 --- a/plugins.v2/sitestatistic/__init__.py +++ b/plugins.v2/sitestatistic/__init__.py @@ -1,8 +1,12 @@ import time import warnings +from datetime import datetime, timedelta from threading import Lock from typing import Optional, Any, List, Dict, Tuple +import pytz +from apscheduler.schedulers.background import BackgroundScheduler + from app import schemas from app.chain.site import SiteChain from app.core.config import settings @@ -46,6 +50,7 @@ class SiteStatistic(_PluginBase): _enabled: bool = False _onlyonce: bool = False _dashboard_type: str = "today" + _scheduler = None def init_plugin(self, config: dict = None): self.siteoper = SiteOper() @@ -60,10 +65,15 @@ class SiteStatistic(_PluginBase): self._onlyonce = config.get("onlyonce") self._dashboard_type = config.get("dashboard_type") or "today" - if self._enabled or self._onlyonce: - # 立即运行一次 - if self._onlyonce: - SiteChain().refresh_userdata() + if self._onlyonce: + config["onlyonce"] = False + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + self._scheduler.add_job(self.refresh, "date", + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="站点数据统计服务") + self._scheduler.print_jobs() + self._scheduler.start() + self.update_config(config=config) def get_state(self) -> bool: return self._enabled @@ -873,7 +883,7 @@ class SiteStatistic(_PluginBase): pass @eventmanager.register(EventType.PluginAction) - def refresh(self, event: Event): + def refresh(self, event: Optional[Event] = None): """ 刷新站点数据 """ From aa4cc0113caf77581671a5a38ed1289282116509 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Tue, 8 Oct 2024 01:22:47 +0800 Subject: [PATCH 065/218] feat(AutoSignIn): add support for v2 plugin --- plugins.v2/autosignin/__init__.py | 1097 +++++++++++++++++++++++ plugins.v2/autosignin/sites/52pt.py | 147 +++ plugins.v2/autosignin/sites/__init__.py | 99 ++ plugins.v2/autosignin/sites/btschool.py | 75 ++ plugins.v2/autosignin/sites/chdbits.py | 148 +++ plugins.v2/autosignin/sites/haidan.py | 70 ++ plugins.v2/autosignin/sites/hares.py | 83 ++ plugins.v2/autosignin/sites/hdarea.py | 69 ++ plugins.v2/autosignin/sites/hdchina.py | 117 +++ plugins.v2/autosignin/sites/hdcity.py | 66 ++ plugins.v2/autosignin/sites/hdsky.py | 136 +++ plugins.v2/autosignin/sites/hdupt.py | 82 ++ plugins.v2/autosignin/sites/mteam.py | 61 ++ plugins.v2/autosignin/sites/nexushd.py | 70 ++ plugins.v2/autosignin/sites/opencd.py | 132 +++ plugins.v2/autosignin/sites/pterclub.py | 65 ++ plugins.v2/autosignin/sites/pttime.py | 64 ++ plugins.v2/autosignin/sites/tjupt.py | 274 ++++++ plugins.v2/autosignin/sites/ttg.py | 97 ++ plugins.v2/autosignin/sites/u2.py | 123 +++ plugins.v2/autosignin/sites/yema.py | 78 ++ plugins.v2/autosignin/sites/zhuque.py | 88 ++ 22 files changed, 3241 insertions(+) create mode 100644 plugins.v2/autosignin/__init__.py create mode 100644 plugins.v2/autosignin/sites/52pt.py create mode 100644 plugins.v2/autosignin/sites/__init__.py create mode 100644 plugins.v2/autosignin/sites/btschool.py create mode 100644 plugins.v2/autosignin/sites/chdbits.py create mode 100644 plugins.v2/autosignin/sites/haidan.py create mode 100644 plugins.v2/autosignin/sites/hares.py create mode 100644 plugins.v2/autosignin/sites/hdarea.py create mode 100644 plugins.v2/autosignin/sites/hdchina.py create mode 100644 plugins.v2/autosignin/sites/hdcity.py create mode 100644 plugins.v2/autosignin/sites/hdsky.py create mode 100644 plugins.v2/autosignin/sites/hdupt.py create mode 100644 plugins.v2/autosignin/sites/mteam.py create mode 100644 plugins.v2/autosignin/sites/nexushd.py create mode 100644 plugins.v2/autosignin/sites/opencd.py create mode 100644 plugins.v2/autosignin/sites/pterclub.py create mode 100644 plugins.v2/autosignin/sites/pttime.py create mode 100644 plugins.v2/autosignin/sites/tjupt.py create mode 100644 plugins.v2/autosignin/sites/ttg.py create mode 100644 plugins.v2/autosignin/sites/u2.py create mode 100644 plugins.v2/autosignin/sites/yema.py create mode 100644 plugins.v2/autosignin/sites/zhuque.py diff --git a/plugins.v2/autosignin/__init__.py b/plugins.v2/autosignin/__init__.py new file mode 100644 index 0000000..25cb12b --- /dev/null +++ b/plugins.v2/autosignin/__init__.py @@ -0,0 +1,1097 @@ +import re +import traceback +from datetime import datetime, timedelta +from multiprocessing.dummy import Pool as ThreadPool +from multiprocessing.pool import ThreadPool +from typing import Any, List, Dict, Tuple, Optional +from urllib.parse import urljoin + +import pytz +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from ruamel.yaml import CommentedMap + +from app import schemas +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.helper.browser import PlaywrightHelper +from app.helper.cloudflare import under_challenge +from app.helper.module import ModuleHelper +from app.helper.sites import SitesHelper +from app.log import logger +from app.plugins import _PluginBase +from app.schemas.types import EventType, NotificationType +from app.utils.http import RequestUtils +from app.utils.site import SiteUtils +from app.utils.string import StringUtils +from app.utils.timer import TimerUtils + + +class AutoSignIn(_PluginBase): + # 插件名称 + plugin_name = "站点自动签到" + # 插件描述 + plugin_desc = "自动模拟登录、签到站点。" + # 插件图标 + plugin_icon = "signin.png" + # 插件版本 + plugin_version = "2.4.2" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "autosignin_" + # 加载顺序 + plugin_order = 0 + # 可使用的用户级别 + auth_level = 2 + + # 私有属性 + sites: SitesHelper = None + siteoper: SiteOper = None + sitechain: SiteChain = None + # 事件管理器 + event: EventManager = None + # 定时器 + _scheduler: Optional[BackgroundScheduler] = None + # 加载的模块 + _site_schema: list = [] + + # 配置属性 + _enabled: bool = False + _cron: str = "" + _onlyonce: bool = False + _notify: bool = False + _queue_cnt: int = 5 + _sign_sites: list = [] + _login_sites: list = [] + _retry_keyword = None + _clean: bool = False + _start_time: int = None + _end_time: int = None + _auto_cf: int = 0 + + def init_plugin(self, config: dict = None): + self.sites = SitesHelper() + self.siteoper = SiteOper() + self.event = EventManager() + self.sitechain = SiteChain() + + # 停止现有任务 + self.stop_service() + + # 配置 + if config: + self._enabled = config.get("enabled") + self._cron = config.get("cron") + self._onlyonce = config.get("onlyonce") + self._notify = config.get("notify") + self._queue_cnt = config.get("queue_cnt") or 5 + self._sign_sites = config.get("sign_sites") or [] + self._login_sites = config.get("login_sites") or [] + self._retry_keyword = config.get("retry_keyword") + self._auto_cf = config.get("auto_cf") + self._clean = config.get("clean") + + # 过滤掉已删除的站点 + all_sites = [site.id for site in self.siteoper.list_order_by_pri()] + [site.get("id") for site in + self.__custom_sites()] + self._sign_sites = [site_id for site_id in all_sites if site_id in self._sign_sites] + self._login_sites = [site_id for site_id in all_sites if site_id in self._login_sites] + # 保存配置 + self.__update_config() + + # 加载模块 + if self._enabled or self._onlyonce: + + self._site_schema = ModuleHelper.load('app.plugins.autosignin.sites', + filter_func=lambda _, obj: hasattr(obj, 'match')) + + # 立即运行一次 + if self._onlyonce: + # 定时服务 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + logger.info("站点自动签到服务启动,立即运行一次") + self._scheduler.add_job(func=self.sign_in, trigger='date', + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="站点自动签到") + + # 关闭一次性开关 + self._onlyonce = False + # 保存配置 + self.__update_config() + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def get_state(self) -> bool: + return self._enabled + + def __update_config(self): + # 保存配置 + self.update_config( + { + "enabled": self._enabled, + "notify": self._notify, + "cron": self._cron, + "onlyonce": self._onlyonce, + "queue_cnt": self._queue_cnt, + "sign_sites": self._sign_sites, + "login_sites": self._login_sites, + "retry_keyword": self._retry_keyword, + "auto_cf": self._auto_cf, + "clean": self._clean, + } + ) + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + """ + 定义远程控制命令 + :return: 命令关键字、事件、描述、附带数据 + """ + return [{ + "cmd": "/site_signin", + "event": EventType.PluginAction, + "desc": "站点签到", + "category": "站点", + "data": { + "action": "site_signin" + } + }] + + def get_api(self) -> List[Dict[str, Any]]: + """ + 获取插件API + [{ + "path": "/xx", + "endpoint": self.xxx, + "methods": ["GET", "POST"], + "summary": "API说明" + }] + """ + return [{ + "path": "/signin_by_domain", + "endpoint": self.signin_by_domain, + "methods": ["GET"], + "summary": "站点签到", + "description": "使用站点域名签到站点", + }] + + 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 and self._cron: + try: + if str(self._cron).strip().count(" ") == 4: + return [{ + "id": "AutoSignIn", + "name": "站点自动签到服务", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self.sign_in, + "kwargs": {} + }] + else: + # 2.3/9-23 + crons = str(self._cron).strip().split("/") + if len(crons) == 2: + # 2.3 + cron = crons[0] + # 9-23 + times = crons[1].split("-") + if len(times) == 2: + # 9 + self._start_time = int(times[0]) + # 23 + self._end_time = int(times[1]) + if self._start_time and self._end_time: + return [{ + "id": "AutoSignIn", + "name": "站点自动签到服务", + "trigger": "interval", + "func": self.sign_in, + "kwargs": { + "hours": float(str(cron).strip()), + } + }] + else: + logger.error("站点自动签到服务启动失败,周期格式错误") + else: + # 默认0-24 按照周期运行 + return [{ + "id": "AutoSignIn", + "name": "站点自动签到服务", + "trigger": "interval", + "func": self.sign_in, + "kwargs": { + "hours": float(str(self._cron).strip()), + } + }] + except Exception as err: + logger.error(f"定时任务配置错误:{str(err)}") + elif self._enabled: + # 随机时间 + triggers = TimerUtils.random_scheduler(num_executions=2, + begin_hour=9, + end_hour=23, + max_interval=6 * 60, + min_interval=2 * 60) + ret_jobs = [] + for trigger in triggers: + ret_jobs.append({ + "id": f"AutoSignIn|{trigger.hour}:{trigger.minute}", + "name": "站点自动签到服务", + "trigger": "cron", + "func": self.sign_in, + "kwargs": { + "hour": trigger.hour, + "minute": trigger.minute + } + }) + return ret_jobs + return [] + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + # 站点的可选项(内置站点 + 自定义站点) + customSites = self.__custom_sites() + + site_options = ([{"title": site.name, "value": site.id} + for site in self.siteoper.list_order_by_pri()] + + [{"title": site.get("name"), "value": site.get("id")} + for site in customSites]) + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '发送通知', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'clean', + 'label': '清理本日缓存', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '执行周期', + 'placeholder': '5位cron表达式,留空自动' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'queue_cnt', + 'label': '队列数量' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'retry_keyword', + 'label': '重试关键词', + 'placeholder': '支持正则表达式,命中才重签' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'auto_cf', + 'label': '自动优选', + 'placeholder': '命中重试关键词次数(0-关闭)' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'chips': True, + 'multiple': True, + 'model': 'sign_sites', + 'label': '签到站点', + 'items': site_options + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'chips': True, + 'multiple': True, + 'model': 'login_sites', + 'label': '登录站点', + 'items': site_options + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '执行周期支持:' + '1、5位cron表达式;' + '2、配置间隔(小时),如2.3/9-23(9-23点之间每隔2.3小时执行一次);' + '3、周期不填默认9-23点随机执行2次。' + '每天首次全量执行,其余执行命中重试关键词的站点。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '自动优选:0-关闭,命中重试关键词次数大于该数量时自动执行Cloudflare IP优选(需要开启且则正确配置Cloudflare IP优选插件和自定义Hosts插件)' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "notify": True, + "cron": "", + "auto_cf": 0, + "onlyonce": False, + "clean": False, + "queue_cnt": 5, + "sign_sites": [], + "login_sites": [], + "retry_keyword": "错误|失败" + } + + def __custom_sites(self) -> List[Any]: + custom_sites = [] + custom_sites_config = self.get_config("CustomSites") + if custom_sites_config and custom_sites_config.get("enabled"): + custom_sites = custom_sites_config.get("sites") + return custom_sites + + def get_page(self) -> List[dict]: + """ + 拼装插件详情页面,需要返回页面配置,同时附带数据 + """ + # 最近两天的日期数组 + date_list = [(datetime.now() - timedelta(days=i)).date() for i in range(2)] + # 最近一天的签到数据 + current_day = "" + sign_data = [] + for day in date_list: + current_day = f"{day.month}月{day.day}日" + sign_data = self.get_data(current_day) + if sign_data: + break + if sign_data: + contents = [ + { + 'component': 'tr', + 'props': { + 'class': 'text-sm' + }, + 'content': [ + { + 'component': 'td', + 'props': { + 'class': 'whitespace-nowrap break-keep text-high-emphasis' + }, + 'text': current_day + }, + { + 'component': 'td', + 'text': data.get("site") + }, + { + 'component': 'td', + 'text': data.get("status") + } + ] + } for data in sign_data + ] + else: + contents = [ + { + 'component': 'tr', + 'props': { + 'class': 'text-sm' + }, + 'content': [ + { + 'component': 'td', + 'props': { + 'colspan': 3, + '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': 'tbody', + 'content': contents + } + ] + } + ] + + @eventmanager.register(EventType.PluginAction) + def sign_in(self, event: Event = None): + """ + 自动签到|模拟登录 + """ + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "site_signin": + return + # 日期 + today = datetime.today() + if self._start_time and self._end_time: + if int(datetime.today().hour) < self._start_time or int(datetime.today().hour) > self._end_time: + logger.error( + f"当前时间 {int(datetime.today().hour)} 不在 {self._start_time}-{self._end_time} 范围内,暂不执行任务") + return + if event: + logger.info("收到命令,开始站点签到 ...") + self.post_message(channel=event.event_data.get("channel"), + title="开始站点签到 ...", + userid=event.event_data.get("user")) + + if self._sign_sites: + self.__do(today=today, type_str="签到", do_sites=self._sign_sites, event=event) + if self._login_sites: + self.__do(today=today, type_str="登录", do_sites=self._login_sites, event=event) + + def __do(self, today: datetime, type_str: str, do_sites: list, event: Event = None): + """ + 签到逻辑 + """ + yesterday = today - timedelta(days=1) + yesterday_str = yesterday.strftime('%Y-%m-%d') + # 删除昨天历史 + self.del_data(key=type_str + "-" + yesterday_str) + self.del_data(key=f"{yesterday.month}月{yesterday.day}日") + + # 查看今天有没有签到|登录历史 + today = today.strftime('%Y-%m-%d') + today_history = self.get_data(key=type_str + "-" + today) + + # 查询所有站点 + all_sites = [site for site in self.sites.get_indexers() if not site.get("public")] + self.__custom_sites() + # 过滤掉没有选中的站点 + if do_sites: + do_sites = [site for site in all_sites if site.get("id") in do_sites] + else: + do_sites = all_sites + + # 今日没数据 + if not today_history or self._clean: + logger.info(f"今日 {today} 未{type_str},开始{type_str}已选站点") + if self._clean: + # 关闭开关 + self._clean = False + else: + # 需要重试站点 + retry_sites = today_history.get("retry") or [] + # 今天已签到|登录站点 + already_sites = today_history.get("do") or [] + + # 今日未签|登录站点 + no_sites = [site for site in do_sites if + site.get("id") not in already_sites or site.get("id") in retry_sites] + + if not no_sites: + logger.info(f"今日 {today} 已{type_str},无重新{type_str}站点,本次任务结束") + return + + # 任务站点 = 需要重试+今日未do + do_sites = no_sites + logger.info(f"今日 {today} 已{type_str},开始重试命中关键词站点") + + if not do_sites: + logger.info(f"没有需要{type_str}的站点") + return + + # 执行签到 + logger.info(f"开始执行{type_str}任务 ...") + if type_str == "签到": + with ThreadPool(min(len(do_sites), int(self._queue_cnt))) as p: + status = p.map(self.signin_site, do_sites) + else: + with ThreadPool(min(len(do_sites), int(self._queue_cnt))) as p: + status = p.map(self.login_site, do_sites) + + if status: + logger.info(f"站点{type_str}任务完成!") + # 获取今天的日期 + key = f"{datetime.now().month}月{datetime.now().day}日" + today_data = self.get_data(key) + if today_data: + if not isinstance(today_data, list): + today_data = [today_data] + for s in status: + today_data.append({ + "site": s[0], + "status": s[1] + }) + else: + today_data = [{ + "site": s[0], + "status": s[1] + } for s in status] + # 保存数据 + self.save_data(key, today_data) + + # 命中重试词的站点id + retry_sites = [] + # 命中重试词的站点签到msg + retry_msg = [] + # 登录成功 + login_success_msg = [] + # 签到成功 + sign_success_msg = [] + # 已签到 + already_sign_msg = [] + # 仿真签到成功 + fz_sign_msg = [] + # 失败|错误 + failed_msg = [] + + sites = {site.get('name'): site.get("id") for site in self.sites.get_indexers() if not site.get("public")} + for s in status: + site_name = s[0] + site_id = None + if site_name: + site_id = sites.get(site_name) + + if 'Cookie已失效' in str(s) and site_id: + # 触发自动登录插件登录 + logger.info(f"触发站点 {site_name} 自动登录更新Cookie和Ua") + self.eventmanager.send_event(EventType.PluginAction, + { + "site_id": site_id, + "action": "site_refresh" + }) + # 记录本次命中重试关键词的站点 + if self._retry_keyword: + if site_id: + match = re.search(self._retry_keyword, s[1]) + if match: + logger.debug(f"站点 {site_name} 命中重试关键词 {self._retry_keyword}") + retry_sites.append(site_id) + # 命中的站点 + retry_msg.append(s) + continue + + if "登录成功" in str(s): + login_success_msg.append(s) + elif "仿真签到成功" in str(s): + fz_sign_msg.append(s) + continue + elif "签到成功" in str(s): + sign_success_msg.append(s) + elif '已签到' in str(s): + already_sign_msg.append(s) + else: + failed_msg.append(s) + + if not self._retry_keyword: + # 没设置重试关键词则重试已选站点 + retry_sites = self._sign_sites if type_str == "签到" else self._login_sites + logger.debug(f"下次{type_str}重试站点 {retry_sites}") + + # 存入历史 + self.save_data(key=type_str + "-" + today, + value={ + "do": self._sign_sites if type_str == "签到" else self._login_sites, + "retry": retry_sites + }) + + # 自动Cloudflare IP优选 + if self._auto_cf and int(self._auto_cf) > 0 and retry_msg and len(retry_msg) >= int(self._auto_cf): + self.eventmanager.send_event(EventType.PluginAction, { + "action": "cloudflare_speedtest" + }) + + # 发送通知 + if self._notify: + # 签到详细信息 登录成功、签到成功、已签到、仿真签到成功、失败--命中重试 + signin_message = login_success_msg + sign_success_msg + already_sign_msg + fz_sign_msg + failed_msg + if len(retry_msg) > 0: + signin_message += retry_msg + + signin_message = "\n".join([f'【{s[0]}】{s[1]}' for s in signin_message if s]) + self.post_message(title=f"【站点自动{type_str}】", + mtype=NotificationType.SiteMessage, + text=f"全部{type_str}数量: {len(self._sign_sites if type_str == '签到' else self._login_sites)} \n" + f"本次{type_str}数量: {len(do_sites)} \n" + f"下次{type_str}数量: {len(retry_sites) if self._retry_keyword else 0} \n" + f"{signin_message}" + ) + if event: + self.post_message(channel=event.event_data.get("channel"), + title=f"站点{type_str}完成!", userid=event.event_data.get("user")) + else: + logger.error(f"站点{type_str}任务失败!") + if event: + self.post_message(channel=event.event_data.get("channel"), + title=f"站点{type_str}任务失败!", userid=event.event_data.get("user")) + # 保存配置 + self.__update_config() + + def __build_class(self, url) -> Any: + for site_schema in self._site_schema: + try: + if site_schema.match(url): + return site_schema + except Exception as e: + logger.error("站点模块加载失败:%s" % str(e)) + return None + + def signin_by_domain(self, url: str, apikey: str) -> schemas.Response: + """ + 签到一个站点,可由API调用 + """ + # 校验 + if apikey != settings.API_TOKEN: + return schemas.Response(success=False, message="API密钥错误") + domain = StringUtils.get_url_domain(url) + site_info = self.sites.get_indexer(domain) + if not site_info: + return schemas.Response( + success=True, + message=f"站点【{url}】不存在" + ) + else: + return schemas.Response( + success=True, + message=self.signin_site(site_info) + ) + + def signin_site(self, site_info: CommentedMap) -> Tuple[str, str]: + """ + 签到一个站点 + """ + site_module = self.__build_class(site_info.get("url")) + # 开始记时 + start_time = datetime.now() + if site_module and hasattr(site_module, "signin"): + try: + state, message = site_module().signin(site_info) + except Exception as e: + traceback.print_exc() + state, message = False, f"签到失败:{str(e)}" + else: + 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.siteoper.success(domain=domain, seconds=seconds) + else: + self.siteoper.fail(domain) + return site_info.get("name"), message + + @staticmethod + def __signin_base(site_info: CommentedMap) -> Tuple[bool, str]: + """ + 通用签到处理 + :param site_info: 站点信息 + :return: 签到结果信息 + """ + if not site_info: + return False, "" + site = site_info.get("name") + site_url = site_info.get("url") + site_cookie = site_info.get("cookie") + ua = site_info.get("ua") + render = site_info.get("render") + proxies = settings.PROXY if site_info.get("proxy") else None + 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 False, "" + # 模拟登录 + try: + # 访问链接 + checkin_url = site_url + if site_url.find("attendance.php") == -1: + # 拼登签到地址 + checkin_url = urljoin(site_url, "attendance.php") + logger.info(f"开始站点签到:{site},地址:{checkin_url}...") + if render: + page_source = PlaywrightHelper().get_page_source(url=checkin_url, + cookies=site_cookie, + ua=ua, + proxies=proxy_server) + if not SiteUtils.is_logged_in(page_source): + if under_challenge(page_source): + return False, f"无法通过Cloudflare!" + return False, f"仿真登录失败,Cookie已失效!" + else: + # 判断是否已签到 + if re.search(r'已签|签到已得', page_source, re.IGNORECASE) \ + or SiteUtils.is_checkin(page_source): + return True, f"签到成功" + return True, "仿真签到成功" + else: + res = RequestUtils(cookies=site_cookie, + ua=ua, + proxies=proxies + ).get_res(url=checkin_url) + if not res and site_url != checkin_url: + logger.info(f"开始站点模拟登录:{site},地址:{site_url}...") + res = RequestUtils(cookies=site_cookie, + ua=ua, + proxies=proxies + ).get_res(url=site_url) + # 判断登录状态 + if res and res.status_code in [200, 500, 403]: + if not SiteUtils.is_logged_in(res.text): + if under_challenge(res.text): + msg = "站点被Cloudflare防护,请打开站点浏览器仿真" + elif res.status_code == 200: + msg = "Cookie已失效" + else: + msg = f"状态码:{res.status_code}" + logger.warn(f"{site} 签到失败,{msg}") + return False, f"签到失败,{msg}!" + else: + logger.info(f"{site} 签到成功") + return True, f"签到成功" + elif res is not None: + logger.warn(f"{site} 签到失败,状态码:{res.status_code}") + return False, f"签到失败,状态码:{res.status_code}!" + else: + logger.warn(f"{site} 签到失败,无法打开网站") + return False, f"签到失败,无法打开网站!" + except Exception as e: + logger.warn("%s 签到失败:%s" % (site, str(e))) + traceback.print_exc() + return False, f"签到失败:{str(e)}!" + + def login_site(self, site_info: CommentedMap) -> Tuple[str, str]: + """ + 模拟登录一个站点 + """ + site_module = self.__build_class(site_info.get("url")) + # 开始记时 + start_time = datetime.now() + if site_module and hasattr(site_module, "login"): + try: + state, message = site_module().login(site_info) + except Exception as e: + traceback.print_exc() + state, message = False, f"模拟登录失败:{str(e)}" + else: + state, message = self.__login_base(site_info) + # 统计 + seconds = (datetime.now() - start_time).seconds + domain = StringUtils.get_url_domain(site_info.get('url')) + if state: + self.siteoper.success(domain=domain, seconds=seconds) + else: + self.siteoper.fail(domain) + return site_info.get("name"), message + + @staticmethod + def __login_base(site_info: CommentedMap) -> Tuple[bool, str]: + """ + 模拟登录通用处理 + :param site_info: 站点信息 + :return: 签到结果信息 + """ + if not site_info: + return False, "" + site = site_info.get("name") + site_url = site_info.get("url") + site_cookie = site_info.get("cookie") + ua = site_info.get("ua") + render = site_info.get("render") + proxies = settings.PROXY if site_info.get("proxy") else None + 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 False, "" + # 模拟登录 + try: + # 访问链接 + site_url = str(site_url).replace("attendance.php", "") + logger.info(f"开始站点模拟登录:{site},地址:{site_url}...") + if render: + page_source = PlaywrightHelper().get_page_source(url=site_url, + cookies=site_cookie, + ua=ua, + proxies=proxy_server) + if not SiteUtils.is_logged_in(page_source): + if under_challenge(page_source): + return False, f"无法通过Cloudflare!" + return False, f"仿真登录失败,Cookie已失效!" + else: + return True, "模拟登录成功" + else: + res = RequestUtils(cookies=site_cookie, + ua=ua, + proxies=proxies + ).get_res(url=site_url) + # 判断登录状态 + if res and res.status_code in [200, 500, 403]: + if not SiteUtils.is_logged_in(res.text): + if under_challenge(res.text): + msg = "站点被Cloudflare防护,请打开站点浏览器仿真" + elif res.status_code == 200: + msg = "Cookie已失效" + else: + msg = f"状态码:{res.status_code}" + logger.warn(f"{site} 模拟登录失败,{msg}") + return False, f"模拟登录失败,{msg}!" + else: + logger.info(f"{site} 模拟登录成功") + return True, f"模拟登录成功" + elif res is not None: + logger.warn(f"{site} 模拟登录失败,状态码:{res.status_code}") + return False, f"模拟登录失败,状态码:{res.status_code}!" + else: + logger.warn(f"{site} 模拟登录失败,无法打开网站") + return False, f"模拟登录失败,无法打开网站!" + except Exception as e: + logger.warn("%s 模拟登录失败:%s" % (site, str(e))) + traceback.print_exc() + return False, f"模拟登录失败:{str(e)}!" + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) + + @eventmanager.register(EventType.SiteDeleted) + def site_deleted(self, event): + """ + 删除对应站点选中 + """ + site_id = event.event_data.get("site_id") + config = self.get_config() + if config: + self._sign_sites = self.__remove_site_id(config.get("sign_sites") or [], site_id) + self._login_sites = self.__remove_site_id(config.get("login_sites") or [], site_id) + # 保存配置 + self.__update_config() + + def __remove_site_id(self, do_sites, site_id): + if do_sites: + if isinstance(do_sites, str): + do_sites = [do_sites] + + # 删除对应站点 + if site_id: + do_sites = [site for site in do_sites if int(site) != int(site_id)] + else: + # 清空 + do_sites = [] + + # 若无站点,则停止 + if len(do_sites) == 0: + self._enabled = False + + return do_sites diff --git a/plugins.v2/autosignin/sites/52pt.py b/plugins.v2/autosignin/sites/52pt.py new file mode 100644 index 0000000..44c6155 --- /dev/null +++ b/plugins.v2/autosignin/sites/52pt.py @@ -0,0 +1,147 @@ +import random +import re +from typing import Tuple + +from lxml import etree + +from app.core.config import settings +from app.log import logger +from app.plugins.autosignin.sites import _ISiteSigninHandler +from app.utils.http import RequestUtils +from app.utils.string import StringUtils + + +class Pt52(_ISiteSigninHandler): + """ + 52pt + 如果填写openai key则调用chatgpt获取答案 + 否则随机 + """ + # 匹配的站点Url,每一个实现类都需要设置为自己的站点Url + site_url = "52pt.site" + + # 已签到 + _sign_regex = ['今天已经签过到了'] + + # 签到成功,待补充 + _success_regex = ['\\d+点魔力值'] + + @classmethod + def match(cls, url: str) -> bool: + """ + 根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可 + :param url: 站点Url + :return: 是否匹配,如匹配则会调用该类的signin方法 + """ + return True if StringUtils.url_equal(url, cls.site_url) else False + + def signin(self, site_info: dict) -> Tuple[bool, str]: + """ + 执行签到操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 签到结果信息 + """ + site = site_info.get("name") + site_cookie = site_info.get("cookie") + ua = site_info.get("ua") + render = site_info.get("render") + proxy = site_info.get("proxy") + + # 判断今日是否已签到 + html_text = self.get_page_source(url='https://52pt.site/bakatest.php', + cookie=site_cookie, + ua=ua, + proxy=proxy, + render=render) + + if not html_text: + logger.error(f"{site} 签到失败,请检查站点连通性") + return False, '签到失败,请检查站点连通性' + + if "login.php" in html_text: + logger.error(f"{site} 签到失败,Cookie已失效") + return False, '签到失败,Cookie已失效' + + sign_status = self.sign_in_result(html_res=html_text, + regexs=self._sign_regex) + if sign_status: + logger.info(f"今日已签到") + return True, '今日已签到' + + # 没有签到则解析html + html = etree.HTML(html_text) + + if not html: + return False, '签到失败' + + # 获取页面问题、答案 + questionid = html.xpath("//input[@name='questionid']/@value")[0] + option_ids = html.xpath("//input[@name='choice[]']/@value") + question_str = html.xpath("//td[@class='text' and contains(text(),'请问:')]/text()")[0] + + # 正则获取问题 + match = re.search(r'请问:(.+)', question_str) + if match: + question_str = match.group(1) + logger.debug(f"获取到签到问题 {question_str}") + else: + logger.error(f"未获取到签到问题") + return False, f"【{site}】签到失败,未获取到签到问题" + + # 正确答案,默认随机,如果gpt返回则用gpt返回的答案提交 + choice = [option_ids[random.randint(0, len(option_ids) - 1)]] + + # 签到 + return self.__signin(questionid=questionid, + choice=choice, + site_cookie=site_cookie, + ua=ua, + proxy=proxy, + site=site) + + def __signin(self, questionid: str, + choice: list, + site: str, + site_cookie: str, + ua: str, + proxy: bool) -> Tuple[bool, str]: + """ + 签到请求 + questionid: 450 + choice[]: 8 + choice[]: 4 + usercomment: 此刻心情:无 + submit: 提交 + 多选会有多个choice[].... + """ + data = { + 'questionid': questionid, + 'choice[]': choice[0] if len(choice) == 1 else choice, + 'usercomment': '太难了!', + 'wantskip': '不会' + } + logger.debug(f"签到请求参数 {data}") + + sign_res = RequestUtils(cookies=site_cookie, + ua=ua, + proxies=settings.PROXY if proxy else None + ).post_res(url='https://52pt.site/bakatest.php', data=data) + if not sign_res or sign_res.status_code != 200: + logger.error(f"{site} 签到失败,签到接口请求失败") + return False, '签到失败,签到接口请求失败' + + # 判断是否签到成功 + sign_status = self.sign_in_result(html_res=sign_res.text, + regexs=self._success_regex) + if sign_status: + logger.info(f"{site} 签到成功") + return True, '签到成功' + else: + sign_status = self.sign_in_result(html_res=sign_res.text, + regexs=self._sign_regex) + if sign_status: + logger.info(f"{site} 今日已签到") + return True, '今日已签到' + + logger.error(f"{site} 签到失败,请到页面查看") + return False, '签到失败,请到页面查看' diff --git a/plugins.v2/autosignin/sites/__init__.py b/plugins.v2/autosignin/sites/__init__.py new file mode 100644 index 0000000..b0e2ef2 --- /dev/null +++ b/plugins.v2/autosignin/sites/__init__.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +import re +from abc import ABCMeta, abstractmethod +from typing import Tuple + +import chardet +from ruamel.yaml import CommentedMap + +from app.core.config import settings +from app.helper.browser import PlaywrightHelper +from app.log import logger +from app.utils.http import RequestUtils +from app.utils.string import StringUtils + + +class _ISiteSigninHandler(metaclass=ABCMeta): + """ + 实现站点签到的基类,所有站点签到类都需要继承此类,并实现match和signin方法 + 实现类放置到sitesignin目录下将会自动加载 + """ + # 匹配的站点Url,每一个实现类都需要设置为自己的站点Url + site_url = "" + + @abstractmethod + def match(self, url: str) -> bool: + """ + 根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可 + :param url: 站点Url + :return: 是否匹配,如匹配则会调用该类的signin方法 + """ + if StringUtils.url_equal(url, self.site_url): + return True + return False + + @abstractmethod + def signin(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行签到操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: True|False,签到结果信息 + """ + pass + + @staticmethod + def get_page_source(url: str, cookie: str, ua: str, proxy: bool, render: bool, token: str = None) -> str: + """ + 获取页面源码 + :param url: Url地址 + :param cookie: Cookie + :param ua: UA + :param proxy: 是否使用代理 + :param render: 是否渲染 + :param token: JWT Token + :return: 页面源码,错误信息 + """ + if render: + return PlaywrightHelper().get_page_source(url=url, + cookies=cookie, + ua=ua, + proxies=settings.PROXY_SERVER if proxy else None) + else: + if token: + headers = { + "Authorization": token, + "User-Agent": ua + } + else: + headers = { + "User-Agent": ua, + "Cookie": cookie + } + res = RequestUtils(headers=headers, + proxies=settings.PROXY if proxy else None).get_res(url=url) + if res is not None: + # 使用chardet检测字符编码 + raw_data = res.content + if raw_data: + try: + result = chardet.detect(raw_data) + encoding = result['encoding'] + # 解码为字符串 + return raw_data.decode(encoding) + except Exception as e: + logger.error(f"chardet解码失败:{str(e)}") + return res.text + else: + return res.text + return "" + + @staticmethod + def sign_in_result(html_res: str, regexs: list) -> bool: + """ + 判断是否签到成功 + """ + html_text = re.sub(r"#\d+", "", re.sub(r"\d+px", "", html_res)) + for regex in regexs: + if re.search(str(regex), html_text): + return True + return False diff --git a/plugins.v2/autosignin/sites/btschool.py b/plugins.v2/autosignin/sites/btschool.py new file mode 100644 index 0000000..b8f2671 --- /dev/null +++ b/plugins.v2/autosignin/sites/btschool.py @@ -0,0 +1,75 @@ +from typing import Tuple + +from ruamel.yaml import CommentedMap + +from app.log import logger +from app.plugins.autosignin.sites import _ISiteSigninHandler +from app.utils.string import StringUtils + + +class BTSchool(_ISiteSigninHandler): + """ + 学校签到 + """ + # 匹配的站点Url,每一个实现类都需要设置为自己的站点Url + site_url = "pt.btschool.club" + + # 已签到 + _sign_text = '每日签到' + + @classmethod + def match(cls, url) -> bool: + """ + 根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可 + :param url: 站点Url + :return: 是否匹配,如匹配则会调用该类的signin方法 + """ + return True if StringUtils.url_equal(url, cls.site_url) else False + + def signin(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行签到操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 签到结果信息 + """ + site = site_info.get("name") + site_cookie = site_info.get("cookie") + ua = site_info.get("ua") + render = site_info.get("render") + proxy = site_info.get("proxy") + + logger.info(f"{site} 开始签到") + # 判断今日是否已签到 + html_text = self.get_page_source(url='https://pt.btschool.club', + cookie=site_cookie, + ua=ua, + proxy=proxy, + render=render) + + if not html_text: + logger.error(f"{site} 签到失败,请检查站点连通性") + return False, '签到失败,请检查站点连通性' + + if "login.php" in html_text: + logger.error(f"{site} 签到失败,Cookie已失效") + return False, '签到失败,Cookie已失效' + + # 已签到 + if self._sign_text not in html_text: + logger.info(f"{site} 今日已签到") + return True, '今日已签到' + + html_text = self.get_page_source(url='https://pt.btschool.club/index.php?action=addbonus', + cookie=site_cookie, + ua=ua, + proxy=proxy, + render=render) + + if not html_text: + logger.error(f"{site} 签到失败,签到接口请求失败") + return False, '签到失败,签到接口请求失败' + + # 签到成功 + if self._sign_text not in html_text: + logger.info(f"{site} 签到成功") + return True, '签到成功' diff --git a/plugins.v2/autosignin/sites/chdbits.py b/plugins.v2/autosignin/sites/chdbits.py new file mode 100644 index 0000000..ed2cf67 --- /dev/null +++ b/plugins.v2/autosignin/sites/chdbits.py @@ -0,0 +1,148 @@ +import random +import re +from typing import Tuple + +from lxml import etree +from ruamel.yaml import CommentedMap + +from app.core.config import settings +from app.log import logger +from app.plugins.autosignin.sites import _ISiteSigninHandler +from app.utils.http import RequestUtils +from app.utils.string import StringUtils + + +class CHDBits(_ISiteSigninHandler): + """ + 彩虹岛签到 + 如果填写openai key则调用chatgpt获取答案 + 否则随机 + """ + # 匹配的站点Url,每一个实现类都需要设置为自己的站点Url + site_url = "ptchdbits.co" + + # 已签到 + _sign_regex = ['今天已经签过到了'] + + # 签到成功,待补充 + _success_regex = ['\\d+点魔力值'] + + @classmethod + def match(cls, url: str) -> bool: + """ + 根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可 + :param url: 站点Url + :return: 是否匹配,如匹配则会调用该类的signin方法 + """ + return True if StringUtils.url_equal(url, cls.site_url) else False + + def signin(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行签到操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 签到结果信息 + """ + site = site_info.get("name") + site_cookie = site_info.get("cookie") + ua = site_info.get("ua") + proxy = site_info.get("proxy") + render = site_info.get("render") + + # 判断今日是否已签到 + html_text = self.get_page_source(url='https://ptchdbits.co/bakatest.php', + cookie=site_cookie, + ua=ua, + proxy=proxy, + render=render) + + if not html_text: + logger.error(f"{site} 签到失败,请检查站点连通性") + return False, '签到失败,请检查站点连通性' + + if "login.php" in html_text: + logger.error(f"{site} 签到失败,Cookie已失效") + return False, '签到失败,Cookie已失效' + + sign_status = self.sign_in_result(html_res=html_text, + regexs=self._sign_regex) + if sign_status: + logger.info(f"{site} 今日已签到") + return True, '今日已签到' + + # 没有签到则解析html + html = etree.HTML(html_text) + + if not html: + return False, '签到失败' + + # 获取页面问题、答案 + questionid = html.xpath("//input[@name='questionid']/@value")[0] + option_ids = html.xpath("//input[@name='choice[]']/@value") + question_str = html.xpath("//td[@class='text' and contains(text(),'请问:')]/text()")[0] + + # 正则获取问题 + match = re.search(r'请问:(.+)', question_str) + if match: + question_str = match.group(1) + logger.debug(f"获取到签到问题 {question_str}") + else: + logger.error(f"未获取到签到问题") + return False, f"【{site}】签到失败,未获取到签到问题" + + # 正确答案,默认随机,如果gpt返回则用gpt返回的答案提交 + choice = [option_ids[random.randint(0, len(option_ids) - 1)]] + + # 签到 + return self.__signin(questionid=questionid, + choice=choice, + site_cookie=site_cookie, + ua=ua, + proxy=proxy, + site=site) + + def __signin(self, questionid: str, + choice: list, + site: str, + site_cookie: str, + ua: str, + proxy: bool) -> Tuple[bool, str]: + """ + 签到请求 + questionid: 450 + choice[]: 8 + choice[]: 4 + usercomment: 此刻心情:无 + submit: 提交 + 多选会有多个choice[].... + """ + data = { + 'questionid': questionid, + 'choice[]': choice[0] if len(choice) == 1 else choice, + 'usercomment': '太难了!', + 'wantskip': '不会' + } + logger.debug(f"签到请求参数 {data}") + + sign_res = RequestUtils(cookies=site_cookie, + ua=ua, + proxies=settings.PROXY if proxy else None + ).post_res(url='https://ptchdbits.co/bakatest.php', data=data) + if not sign_res or sign_res.status_code != 200: + logger.error(f"{site} 签到失败,签到接口请求失败") + return False, '签到失败,签到接口请求失败' + + # 判断是否签到成功 + sign_status = self.sign_in_result(html_res=sign_res.text, + regexs=self._success_regex) + if sign_status: + logger.info(f"{site} 签到成功") + return True, '签到成功' + else: + sign_status = self.sign_in_result(html_res=sign_res.text, + regexs=self._sign_regex) + if sign_status: + logger.info(f"{site} 今日已签到") + return True, '今日已签到' + + logger.error(f"{site} 签到失败,请到页面查看") + return False, '签到失败,请到页面查看' diff --git a/plugins.v2/autosignin/sites/haidan.py b/plugins.v2/autosignin/sites/haidan.py new file mode 100644 index 0000000..23f6b03 --- /dev/null +++ b/plugins.v2/autosignin/sites/haidan.py @@ -0,0 +1,70 @@ +from typing import Tuple + +from ruamel.yaml import CommentedMap + +from app.log import logger +from app.plugins.autosignin.sites import _ISiteSigninHandler +from app.utils.string import StringUtils + + +class HaiDan(_ISiteSigninHandler): + """ + 海胆签到 + """ + # 匹配的站点Url,每一个实现类都需要设置为自己的站点Url + site_url = "haidan.video" + + # 签到成功 + _succeed_regex = ['(?<=value=")已经打卡(?=")'] + + @classmethod + def match(cls, url: str) -> bool: + """ + 根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可 + :param url: 站点Url + :return: 是否匹配,如匹配则会调用该类的signin方法 + """ + return True if StringUtils.url_equal(url, cls.site_url) else False + + def signin(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行签到操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 签到结果信息 + """ + site = site_info.get("name") + site_cookie = site_info.get("cookie") + ua = site_info.get("ua") + proxy = site_info.get("proxy") + render = site_info.get("render") + + # 签到 + # 签到页会重定向到index.php,由于302重定向特性,导致index.php没有携带cookie + self.get_page_source(url='https://www.haidan.video/signin.php', + cookie=site_cookie, + ua=ua, + proxy=proxy, + render=render) + + # 重新携带cookie获取index.php查看签到结果 + html_text = self.get_page_source(url='https://www.haidan.video/index.php', + cookie=site_cookie, + ua=ua, + proxy=proxy, + render=render) + if not html_text: + logger.error(f"{site} 签到失败,请检查站点连通性") + return False, '签到失败,请检查站点连通性' + + if "login.php" in html_text: + logger.error(f"{site} 签到失败,Cookie已失效") + return False, '签到失败,Cookie已失效' + + sign_status = self.sign_in_result(html_res=html_text, + regexs=self._succeed_regex) + if sign_status: + logger.info(f"{site} 签到成功") + return True, '签到成功' + + logger.error(f"{site} 签到失败,签到接口返回 {html_text}") + return False, '签到失败' diff --git a/plugins.v2/autosignin/sites/hares.py b/plugins.v2/autosignin/sites/hares.py new file mode 100644 index 0000000..5aea8f1 --- /dev/null +++ b/plugins.v2/autosignin/sites/hares.py @@ -0,0 +1,83 @@ +import json +from typing import Tuple + +from ruamel.yaml import CommentedMap + +from app.core.config import settings +from app.log import logger +from app.plugins.autosignin.sites import _ISiteSigninHandler +from app.utils.http import RequestUtils +from app.utils.string import StringUtils + + +class Hares(_ISiteSigninHandler): + """ + 白兔签到 + """ + # 匹配的站点Url,每一个实现类都需要设置为自己的站点Url + site_url = "club.hares.top" + + # 已签到 + _sign_text = '已签到' + + @classmethod + def match(cls, url: str) -> bool: + """ + 根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可 + :param url: 站点Url + :return: 是否匹配,如匹配则会调用该类的signin方法 + """ + return True if StringUtils.url_equal(url, cls.site_url) else False + + def signin(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行签到操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 签到结果信息 + """ + site = site_info.get("name") + site_cookie = site_info.get("cookie") + ua = site_info.get("ua") + proxy = site_info.get("proxy") + render = site_info.get("render") + + # 获取页面html + html_text = self.get_page_source(url='https://club.hares.top', + cookie=site_cookie, + ua=ua, + proxy=proxy, + render=render) + + if not html_text: + logger.error(f"{site} 模拟访问失败,请检查站点连通性") + return False, '模拟访问失败,请检查站点连通性' + + if "login.php" in html_text: + logger.error(f"{site} 模拟访问失败,Cookie已失效") + return False, '模拟访问失败,Cookie已失效' + + # if self._sign_text in html_res.text: + # logger.info(f"今日已签到") + # return True, '今日已签到' + + headers = { + 'Accept': 'application/json', + "User-Agent": ua + } + sign_res = RequestUtils(cookies=site_cookie, + headers=headers, + proxies=settings.PROXY if proxy else None + ).get_res(url="https://club.hares.top/attendance.php?action=sign") + if not sign_res or sign_res.status_code != 200: + logger.error(f"{site} 签到失败,签到接口请求失败") + return False, '签到失败,签到接口请求失败' + + # {"code":1,"msg":"您今天已经签到过了"} + # {"code":0,"msg":"签到成功"} + sign_dict = json.loads(sign_res.text) + if sign_dict['code'] == 0: + logger.info(f"{site} 签到成功") + return True, '签到成功' + else: + logger.info(f"{site} 今日已签到") + return True, '今日已签到' diff --git a/plugins.v2/autosignin/sites/hdarea.py b/plugins.v2/autosignin/sites/hdarea.py new file mode 100644 index 0000000..bc345e7 --- /dev/null +++ b/plugins.v2/autosignin/sites/hdarea.py @@ -0,0 +1,69 @@ +from typing import Tuple + +from ruamel.yaml import CommentedMap + +from app.core.config import settings +from app.log import logger +from app.plugins.autosignin.sites import _ISiteSigninHandler +from app.utils.http import RequestUtils +from app.utils.string import StringUtils + + +class HDArea(_ISiteSigninHandler): + """ + 好大签到 + """ + + # 匹配的站点Url,每一个实现类都需要设置为自己的站点Url + site_url = "hdarea.club" + + # 签到成功 + _success_text = "此次签到您获得" + _repeat_text = "请不要重复签到哦" + + @classmethod + def match(cls, url: str) -> bool: + """ + 根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可 + :param url: 站点Url + :return: 是否匹配,如匹配则会调用该类的signin方法 + """ + return True if StringUtils.url_equal(url, cls.site_url) else False + + def signin(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行签到操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 签到结果信息 + """ + site = site_info.get("name") + site_cookie = site_info.get("cookie") + ua = site_info.get("ua") + proxies = settings.PROXY if site_info.get("proxy") else None + + # 获取页面html + data = { + 'action': 'sign_in' + } + html_res = RequestUtils(cookies=site_cookie, + ua=ua, + proxies=proxies + ).post_res(url="https://www.hdarea.club/sign_in.php", data=data) + if not html_res or html_res.status_code != 200: + logger.error(f"{site} 签到失败,请检查站点连通性") + return False, '签到失败,请检查站点连通性' + + if "login.php" in html_res.text: + logger.error(f"{site} 签到失败,Cookie已失效") + return False, '签到失败,Cookie已失效' + + # 判断是否已签到 + # '已连续签到278天,此次签到您获得了100魔力值奖励!' + if self._success_text in html_res.text: + logger.info(f"{site} 签到成功") + return True, '签到成功' + if self._repeat_text in html_res.text: + logger.info(f"{site} 今日已签到") + return True, '今日已签到' + logger.error(f"{site} 签到失败,签到接口返回 {html_res.text}") + return False, '签到失败' diff --git a/plugins.v2/autosignin/sites/hdchina.py b/plugins.v2/autosignin/sites/hdchina.py new file mode 100644 index 0000000..1d14982 --- /dev/null +++ b/plugins.v2/autosignin/sites/hdchina.py @@ -0,0 +1,117 @@ +import json +from typing import Tuple + +from lxml import etree +from ruamel.yaml import CommentedMap + +from app.core.config import settings +from app.log import logger +from app.plugins.autosignin.sites import _ISiteSigninHandler +from app.utils.http import RequestUtils +from app.utils.string import StringUtils + + +class HDChina(_ISiteSigninHandler): + """ + 瓷器签到 + """ + # 匹配的站点Url,每一个实现类都需要设置为自己的站点Url + site_url = "hdchina.org" + + # 已签到 + _sign_regex = ['已签到'] + + @classmethod + def match(cls, url: str) -> bool: + """ + 根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可 + :param url: 站点Url + :return: 是否匹配,如匹配则会调用该类的signin方法 + """ + return True if StringUtils.url_equal(url, cls.site_url) else False + + def signin(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行签到操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 签到结果信息 + """ + site = site_info.get("name") + site_cookie = site_info.get("cookie") + ua = site_info.get("ua") + proxies = settings.PROXY if site_info.get("proxy") else None + + # 尝试解决瓷器cookie每天签到后过期,只保留hdchina=部分 + cookie = "" + # 按照分号进行字符串拆分 + sub_strs = site_cookie.split(";") + # 遍历每个子字符串 + for sub_str in sub_strs: + if "hdchina=" in sub_str: + # 如果子字符串包含"hdchina=",则保留该子字符串 + cookie += sub_str + ";" + + if "hdchina=" not in cookie: + logger.error(f"{site} 签到失败,Cookie已失效") + return False, '签到失败,Cookie已失效' + + site_cookie = cookie + # 获取页面html + html_res = RequestUtils(cookies=site_cookie, + ua=ua, + proxies=proxies + ).get_res(url="https://hdchina.org/index.php") + if not html_res or html_res.status_code != 200: + logger.error(f"{site} 签到失败,请检查站点连通性") + return False, '签到失败,请检查站点连通性' + + if "login.php" in html_res.text or "阻断页面" in html_res.text: + logger.error(f"{site} 签到失败,Cookie失效") + return False, '签到失败,Cookie失效' + + # 获取新返回的cookie进行签到 + site_cookie = ';'.join(['{}={}'.format(k, v) for k, v in html_res.cookies.get_dict().items()]) + + # 判断是否已签到 + html_res.encoding = "utf-8" + sign_status = self.sign_in_result(html_res=html_res.text, + regexs=self._sign_regex) + if sign_status: + logger.info(f"{site} 今日已签到") + return True, '今日已签到' + + # 没有签到则解析html + html = etree.HTML(html_res.text) + + if not html: + return False, '签到失败' + + # x_csrf + x_csrf = html.xpath("//meta[@name='x-csrf']/@content")[0] + if not x_csrf: + logger.error("{site} 签到失败,获取x-csrf失败") + return False, '签到失败' + logger.debug(f"获取到x-csrf {x_csrf}") + + # 签到 + data = { + 'csrf': x_csrf + } + sign_res = RequestUtils(cookies=site_cookie, + ua=ua, + proxies=proxies + ).post_res(url="https://hdchina.org/plugin_sign-in.php?cmd=signin", data=data) + if not sign_res or sign_res.status_code != 200: + logger.error(f"{site} 签到失败,签到接口请求失败") + return False, '签到失败,签到接口请求失败' + + sign_dict = json.loads(sign_res.text) + logger.debug(f"签到返回结果 {sign_dict}") + if sign_dict['state']: + # {'state': 'success', 'signindays': 10, 'integral': 20} + logger.info(f"{site} 签到成功") + return True, '签到成功' + else: + # {'state': False, 'msg': '不正确的CSRF / Incorrect CSRF token'} + logger.error(f"{site} 签到失败,不正确的CSRF / Incorrect CSRF token") + return False, '签到失败' diff --git a/plugins.v2/autosignin/sites/hdcity.py b/plugins.v2/autosignin/sites/hdcity.py new file mode 100644 index 0000000..229a523 --- /dev/null +++ b/plugins.v2/autosignin/sites/hdcity.py @@ -0,0 +1,66 @@ +from typing import Tuple + +from ruamel.yaml import CommentedMap + +from app.log import logger +from app.plugins.autosignin.sites import _ISiteSigninHandler +from app.utils.string import StringUtils + + +class HDCity(_ISiteSigninHandler): + """ + 城市签到 + """ + # 匹配的站点Url,每一个实现类都需要设置为自己的站点Url + site_url = "hdcity.city" + + # 签到成功 + _success_text = '本次签到获得魅力' + # 重复签到 + _repeat_text = '已签到' + + @classmethod + def match(cls, url: str) -> bool: + """ + 根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可 + :param url: 站点Url + :return: 是否匹配,如匹配则会调用该类的signin方法 + """ + return True if StringUtils.url_equal(url, cls.site_url) else False + + def signin(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行签到操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 签到结果信息 + """ + site = site_info.get("name") + site_cookie = site_info.get("cookie") + ua = site_info.get("ua") + proxy = site_info.get("proxy") + render = site_info.get("render") + + # 获取页面html + html_text = self.get_page_source(url='https://hdcity.city/sign', + cookie=site_cookie, + ua=ua, + proxy=proxy, + render=render) + if not html_text: + logger.error(f"{site} 签到失败,请检查站点连通性") + return False, '签到失败,请检查站点连通性' + + if "login" in html_text: + logger.error(f"{site} 签到失败,Cookie已失效") + return False, '签到失败,Cookie已失效' + + # 判断是否已签到 + # '已连续签到278天,此次签到您获得了100魔力值奖励!' + if self._success_text in html_text: + logger.info(f"{site} 签到成功") + return True, '签到成功' + if self._repeat_text in html_text: + logger.info(f"{site} 今日已签到") + return True, '今日已签到' + logger.error(f"{site} 签到失败,签到接口返回 {html_text}") + return False, '签到失败' diff --git a/plugins.v2/autosignin/sites/hdsky.py b/plugins.v2/autosignin/sites/hdsky.py new file mode 100644 index 0000000..d75bf85 --- /dev/null +++ b/plugins.v2/autosignin/sites/hdsky.py @@ -0,0 +1,136 @@ +import json +import time +from typing import Tuple + +from ruamel.yaml import CommentedMap + +from app.core.config import settings +from app.helper.ocr import OcrHelper +from app.log import logger +from app.plugins.autosignin.sites import _ISiteSigninHandler +from app.utils.http import RequestUtils +from app.utils.string import StringUtils + + +class HDSky(_ISiteSigninHandler): + """ + 天空ocr签到 + """ + # 匹配的站点Url,每一个实现类都需要设置为自己的站点Url + site_url = "hdsky.me" + + # 已签到 + _sign_regex = ['已签到'] + + @classmethod + def match(cls, url: str) -> bool: + """ + 根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可 + :param url: 站点Url + :return: 是否匹配,如匹配则会调用该类的signin方法 + """ + return True if StringUtils.url_equal(url, cls.site_url) else False + + def signin(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行签到操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 签到结果信息 + """ + site = site_info.get("name") + site_cookie = site_info.get("cookie") + ua = site_info.get("ua") + proxy = site_info.get("proxy") + render = site_info.get("render") + + # 判断今日是否已签到 + html_text = self.get_page_source(url='https://hdsky.me', + cookie=site_cookie, + ua=ua, + proxy=proxy, + render=render) + if not html_text: + logger.error(f"{site} 签到失败,请检查站点连通性") + return False, '签到失败,请检查站点连通性' + + if "login.php" in html_text: + logger.error(f"{site} 签到失败,Cookie已失效") + return False, '签到失败,Cookie已失效' + + sign_status = self.sign_in_result(html_res=html_text, + regexs=self._sign_regex) + if sign_status: + logger.info(f"{site} 今日已签到") + return True, '今日已签到' + + # 获取验证码请求,考虑到网络问题获取失败,多获取几次试试 + res_times = 0 + img_hash = None + while not img_hash and res_times <= 3: + image_res = RequestUtils(cookies=site_cookie, + ua=ua, + content_type='application/x-www-form-urlencoded; charset=UTF-8', + referer="https://hdsky.me/index.php", + accept_type="*/*", + proxies=settings.PROXY if proxy else None + ).post_res(url='https://hdsky.me/image_code_ajax.php', + data={'action': 'new'}) + if image_res and image_res.status_code == 200: + image_json = json.loads(image_res.text) + if image_json["success"]: + img_hash = image_json["code"] + break + res_times += 1 + logger.info(f"获取 {site} 验证码失败,正在进行重试,目前重试次数:{res_times}") + time.sleep(1) + + # 获取到二维码hash + if img_hash: + # 完整验证码url + img_get_url = 'https://hdsky.me/image.php?action=regimage&imagehash=%s' % img_hash + logger.info(f"获取到 {site} 验证码链接:{img_get_url}") + # ocr识别多次,获取6位验证码 + times = 0 + ocr_result = None + # 识别几次 + while times <= 3: + # ocr二维码识别 + ocr_result = OcrHelper().get_captcha_text(image_url=img_get_url, + cookie=site_cookie, + ua=ua) + logger.info(f"OCR识别 {site} 验证码:{ocr_result}") + if ocr_result: + if len(ocr_result) == 6: + logger.info(f"OCR识别 {site} 验证码成功:{ocr_result}") + break + times += 1 + logger.info(f"OCR识别 {site} 验证码失败,正在进行重试,目前重试次数:{times}") + time.sleep(1) + + if ocr_result: + # 组装请求参数 + data = { + 'action': 'showup', + 'imagehash': img_hash, + 'imagestring': ocr_result + } + # 访问签到链接 + res = RequestUtils(cookies=site_cookie, + ua=ua, + proxies=settings.PROXY if proxy else None + ).post_res(url='https://hdsky.me/showup.php', data=data) + if res and res.status_code == 200: + if json.loads(res.text)["success"]: + logger.info(f"{site} 签到成功") + return True, '签到成功' + elif str(json.loads(res.text)["message"]) == "date_unmatch": + # 重复签到 + logger.warn(f"{site} 重复成功") + return True, '今日已签到' + elif str(json.loads(res.text)["message"]) == "invalid_imagehash": + # 验证码错误 + logger.warn(f"{site} 签到失败:验证码错误") + return False, '签到失败:验证码错误' + + logger.error(f'{site} 签到失败:未获取到验证码') + return False, '签到失败:未获取到验证码' diff --git a/plugins.v2/autosignin/sites/hdupt.py b/plugins.v2/autosignin/sites/hdupt.py new file mode 100644 index 0000000..470981d --- /dev/null +++ b/plugins.v2/autosignin/sites/hdupt.py @@ -0,0 +1,82 @@ +import re +from typing import Tuple + +from ruamel.yaml import CommentedMap + +from app.log import logger +from app.plugins.autosignin.sites import _ISiteSigninHandler +from app.utils.string import StringUtils + + +class HDUpt(_ISiteSigninHandler): + """ + hdu签到 + """ + # 匹配的站点Url,每一个实现类都需要设置为自己的站点Url + site_url = "pt.hdupt.com" + + # 已签到 + _sign_regex = [''] + + # 签到成功 + _success_text = '本次签到获得魅力' + + @classmethod + def match(cls, url: str) -> bool: + """ + 根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可 + :param url: 站点Url + :return: 是否匹配,如匹配则会调用该类的signin方法 + """ + return True if StringUtils.url_equal(url, cls.site_url) else False + + def signin(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行签到操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 签到结果信息 + """ + site = site_info.get("name") + site_cookie = site_info.get("cookie") + ua = site_info.get("ua") + proxy = site_info.get("proxy") + render = site_info.get("render") + + # 获取页面html + html_text = self.get_page_source(url='https://pt.hdupt.com', + cookie=site_cookie, + ua=ua, + proxy=proxy, + render=render) + if not html_text: + logger.error(f"{site} 签到失败,请检查站点连通性") + return False, '签到失败,请检查站点连通性' + + if "login.php" in html_text: + logger.error(f"{site} 签到失败,Cookie已失效") + return False, '签到失败,Cookie已失效' + + sign_status = self.sign_in_result(html_res=html_text, + regexs=self._sign_regex) + if sign_status: + logger.info(f"{site} 今日已签到") + return True, '今日已签到' + + # 签到 + html_text = self.get_page_source(url='https://pt.hdupt.com/added.php?action=qiandao', + cookie=site_cookie, + ua=ua, + proxy=proxy, + render=render) + if not html_text: + logger.error(f"{site} 签到失败,请检查站点连通性") + return False, '签到失败,请检查站点连通性' + + logger.debug(f"{site} 签到接口返回 {html_text}") + # 判断是否已签到 sign_res.text = ".23" + if len(list(map(int, re.findall(r"\d+", html_text)))) > 0: + logger.info(f"{site} 签到成功") + return True, '签到成功' + + logger.error(f"{site} 签到失败,签到接口返回 {html_text}") + return False, '签到失败' diff --git a/plugins.v2/autosignin/sites/mteam.py b/plugins.v2/autosignin/sites/mteam.py new file mode 100644 index 0000000..5db1ef1 --- /dev/null +++ b/plugins.v2/autosignin/sites/mteam.py @@ -0,0 +1,61 @@ +from typing import Tuple +from urllib.parse import urljoin + +from ruamel.yaml import CommentedMap + +from app.core.config import settings +from app.plugins.autosignin.sites import _ISiteSigninHandler +from app.utils.http import RequestUtils +from app.utils.string import StringUtils + + +class MTorrent(_ISiteSigninHandler): + """ + m-team签到 + """ + # 匹配的站点Url,每一个实现类都需要设置为自己的站点Url + site_url = "m-team" + + @classmethod + def match(cls, url: str) -> bool: + """ + 根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可 + :param url: 站点Url + :return: 是否匹配,如匹配则会调用该类的signin方法 + """ + return True if cls.site_url in url.split(".") else False + + def signin(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行签到操作,馒头实际没有签到,非仿真模式下需要更新访问时间 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 签到结果信息 + """ + headers = { + "Content-Type": "application/json", + "User-Agent": site_info.get("ua"), + "Accept": "application/json, text/plain, */*", + "Authorization": site_info.get("token") + } + url = site_info.get('url') + domain = StringUtils.get_url_domain(url) + # 更新最后访问时间 + res = RequestUtils(headers=headers, + timeout=60, + proxies=settings.PROXY if site_info.get("proxy") else None, + referer=f"{url}index" + ).post_res(url=f"https://api.{domain}/api/member/updateLastBrowse") + if res: + return True, "模拟登录成功" + elif res is not None: + return False, f"模拟登录失败,状态码:{res.status_code}" + else: + return False, "模拟登录失败,无法打开网站" + + def login(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行登录操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 登录结果信息 + """ + return self.signin(site_info) diff --git a/plugins.v2/autosignin/sites/nexushd.py b/plugins.v2/autosignin/sites/nexushd.py new file mode 100644 index 0000000..78941c0 --- /dev/null +++ b/plugins.v2/autosignin/sites/nexushd.py @@ -0,0 +1,70 @@ +from typing import Tuple + +from ruamel.yaml import CommentedMap + +from app.core.config import settings +from app.log import logger +from app.plugins.autosignin.sites import _ISiteSigninHandler +from app.utils.http import RequestUtils +from app.utils.string import StringUtils + + +class NexusHD(_ISiteSigninHandler): + """ + NexusHD签到 + """ + + # 匹配的站点Url,每一个实现类都需要设置为自己的站点Url + site_url = "v6.nexushd.org" + + # 签到成功 + _success_text = "本次签到获得" + _repeat_text = "你今天已经签到过了" + + @classmethod + def match(cls, url: str) -> bool: + """ + 根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可 + :param url: 站点Url + :return: 是否匹配,如匹配则会调用该类的signin方法 + """ + return True if StringUtils.url_equal(url, cls.site_url) else False + + def signin(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行签到操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 签到结果信息 + """ + site = site_info.get("name") + site_cookie = site_info.get("cookie") + ua = site_info.get("ua") + proxies = settings.PROXY if site_info.get("proxy") else None + + # 获取页面html + data = { + 'action': 'post', + 'content': '' + } + html_res = RequestUtils(cookies=site_cookie, + ua=ua, + proxies=proxies + ).post_res(url="https://v6.nexushd.org/signin.php", data=data) + if not html_res or html_res.status_code != 200: + logger.error(f"{site} 签到失败,请检查站点连通性") + return False, '签到失败,请检查站点连通性' + + if "login.php" in html_res.text: + logger.error(f"{site} 签到失败,Cookie已失效") + return False, '签到失败,Cookie已失效' + + # 判断是否已签到 + # '已连续签到278天,此次签到您获得了100魔力值奖励!' + if self._success_text in html_res.text: + logger.info(f"{site} 签到成功") + return True, '签到成功' + if self._repeat_text in html_res.text: + logger.info(f"{site} 今日已签到") + return True, '今日已签到' + logger.error(f"{site} 签到失败,签到接口返回 {html_res.text}") + return False, '签到失败' diff --git a/plugins.v2/autosignin/sites/opencd.py b/plugins.v2/autosignin/sites/opencd.py new file mode 100644 index 0000000..1f8d0c1 --- /dev/null +++ b/plugins.v2/autosignin/sites/opencd.py @@ -0,0 +1,132 @@ +import json +import time +from typing import Tuple + +from lxml import etree +from ruamel.yaml import CommentedMap + +from app.core.config import settings +from app.helper.ocr import OcrHelper +from app.log import logger +from app.plugins.autosignin.sites import _ISiteSigninHandler +from app.utils.http import RequestUtils +from app.utils.string import StringUtils + + +class Opencd(_ISiteSigninHandler): + """ + 皇后ocr签到 + """ + # 匹配的站点Url,每一个实现类都需要设置为自己的站点Url + site_url = "open.cd" + + # 已签到 + _repeat_text = "/plugin_sign-in.php?cmd=show-log" + + @classmethod + def match(cls, url: str) -> bool: + """ + 根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可 + :param url: 站点Url + :return: 是否匹配,如匹配则会调用该类的signin方法 + """ + return True if StringUtils.url_equal(url, cls.site_url) else False + + def signin(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行签到操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 签到结果信息 + """ + site = site_info.get("name") + site_cookie = site_info.get("cookie") + ua = site_info.get("ua") + proxy = site_info.get("proxy") + render = site_info.get("render") + + # 判断今日是否已签到 + html_text = self.get_page_source(url='https://www.open.cd', + cookie=site_cookie, + ua=ua, + proxy=proxy, + render=render) + if not html_text: + logger.error(f"{site} 签到失败,请检查站点连通性") + return False, '签到失败,请检查站点连通性' + + if "login.php" in html_text: + logger.error(f"{site} 签到失败,Cookie已失效") + return False, '签到失败,Cookie已失效' + + if self._repeat_text in html_text: + logger.info(f"{site} 今日已签到") + return True, '今日已签到' + + # 获取签到参数 + html_text = self.get_page_source(url='https://www.open.cd/plugin_sign-in.php', + cookie=site_cookie, + ua=ua, + proxy=proxy, + render=render) + if not html_text: + logger.error(f"{site} 签到失败,请检查站点连通性") + return False, '签到失败,请检查站点连通性' + + # 没有签到则解析html + html = etree.HTML(html_text) + if not html: + return False, '签到失败' + + # 签到参数 + img_url = html.xpath('//form[@id="frmSignin"]//img/@src')[0] + img_hash = html.xpath('//form[@id="frmSignin"]//input[@name="imagehash"]/@value')[0] + if not img_url or not img_hash: + logger.error(f"{site} 签到失败,获取签到参数失败") + return False, '签到失败,获取签到参数失败' + + # 完整验证码url + img_get_url = 'https://www.open.cd/%s' % img_url + logger.debug(f"{site} 获取到{site}验证码链接 {img_get_url}") + + # ocr识别多次,获取6位验证码 + times = 0 + ocr_result = None + # 识别几次 + while times <= 3: + # ocr二维码识别 + ocr_result = OcrHelper().get_captcha_text(image_url=img_get_url, + cookie=site_cookie, + ua=ua) + logger.debug(f"ocr识别{site}验证码 {ocr_result}") + if ocr_result: + if len(ocr_result) == 6: + logger.info(f"ocr识别{site}验证码成功 {ocr_result}") + break + times += 1 + logger.debug(f"ocr识别{site}验证码失败,正在进行重试,目前重试次数 {times}") + time.sleep(1) + + if ocr_result: + # 组装请求参数 + data = { + 'imagehash': img_hash, + 'imagestring': ocr_result + } + # 访问签到链接 + sign_res = RequestUtils(cookies=site_cookie, + ua=ua, + proxies=settings.PROXY if proxy else None + ).post_res(url='https://www.open.cd/plugin_sign-in.php?cmd=signin', data=data) + if sign_res and sign_res.status_code == 200: + logger.debug(f"sign_res返回 {sign_res.text}") + # sign_res.text = '{"state":"success","signindays":"0","integral":"10"}' + sign_dict = json.loads(sign_res.text) + if sign_dict['state']: + logger.info(f"{site} 签到成功") + return True, '签到成功' + else: + logger.error(f"{site} 签到失败,签到接口返回 {sign_dict}") + return False, '签到失败' + + logger.error(f'{site} 签到失败:未获取到验证码') + return False, '签到失败:未获取到验证码' diff --git a/plugins.v2/autosignin/sites/pterclub.py b/plugins.v2/autosignin/sites/pterclub.py new file mode 100644 index 0000000..4047272 --- /dev/null +++ b/plugins.v2/autosignin/sites/pterclub.py @@ -0,0 +1,65 @@ +import json +from typing import Tuple + +from ruamel.yaml import CommentedMap + +from app.log import logger +from app.plugins.autosignin.sites import _ISiteSigninHandler +from app.utils.string import StringUtils + + +class PTerClub(_ISiteSigninHandler): + """ + 猫签到 + """ + # 匹配的站点Url,每一个实现类都需要设置为自己的站点Url + site_url = "pterclub.com" + + @classmethod + def match(cls, url: str) -> bool: + """ + 根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可 + :param url: 站点Url + :return: 是否匹配,如匹配则会调用该类的signin方法 + """ + return True if StringUtils.url_equal(url, cls.site_url) else False + + def signin(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行签到操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 签到结果信息 + """ + site = site_info.get("name") + site_cookie = site_info.get("cookie") + ua = site_info.get("ua") + proxy = site_info.get("proxy") + render = site_info.get("render") + + # 签到 + html_text = self.get_page_source(url='https://pterclub.com/attendance-ajax.php', + cookie=site_cookie, + ua=ua, + proxy=proxy, + render=render) + if not html_text: + logger.error(f"{site} 签到失败,请检查站点连通性") + return False, '签到失败,请检查站点连通性' + + if "login.php" in html_text: + logger.error(f"{site} 签到失败,Cookie已失效") + return False, '签到失败,Cookie已失效' + try: + sign_dict = json.loads(html_text) + except Exception as e: + logger.error(f"{site} 签到失败,签到接口返回数据异常,错误信息:{str(e)}") + return False, '签到失败,签到接口返回数据异常' + if sign_dict['status'] == '1': + # {"status":"1","data":" (签到已成功300)","message":"

这是您的第237次签到, + # 已连续签到237天。

本次签到获得300克猫粮。

"} + logger.info(f"{site} 签到成功") + return True, '签到成功' + else: + # {"status":"0","data":"抱歉","message":"您今天已经签到过了,请勿重复刷新。"} + logger.info(f"{site} 今日已签到") + return True, '今日已签到' diff --git a/plugins.v2/autosignin/sites/pttime.py b/plugins.v2/autosignin/sites/pttime.py new file mode 100644 index 0000000..6c766d2 --- /dev/null +++ b/plugins.v2/autosignin/sites/pttime.py @@ -0,0 +1,64 @@ +from typing import Tuple + +from ruamel.yaml import CommentedMap + +from app.log import logger +from app.plugins.autosignin.sites import _ISiteSigninHandler +from app.utils.string import StringUtils + + +class PTTime(_ISiteSigninHandler): + """ + PT时间签到 + """ + # 匹配的站点Url,每一个实现类都需要设置为自己的站点Url + site_url = "pttime.org" + + # 签到成功 + _succeed_regex = ['签到成功'] + + @classmethod + def match(cls, url: str) -> bool: + """ + 根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可 + :param url: 站点Url + :return: 是否匹配,如匹配则会调用该类的signin方法 + """ + return True if StringUtils.url_equal(url, cls.site_url) else False + + def signin(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行签到操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 签到结果信息 + """ + site = site_info.get("name") + site_cookie = site_info.get("cookie") + ua = site_info.get("ua") + proxy = site_info.get("proxy") + render = site_info.get("render") + + # 签到 + # 签到返回:签到成功 + html_text = self.get_page_source(url='https://www.pttime.org/attendance.php', + cookie=site_cookie, + ua=ua, + proxy=proxy, + render=render) + + if not html_text: + logger.error(f"{site} 签到失败,请检查站点连通性") + return False, '签到失败,请检查站点连通性' + + if "login.php" in html_text: + logger.error(f"{site} 签到失败,Cookie已失效") + return False, '签到失败,Cookie已失效' + + sign_status = self.sign_in_result(html_res=html_text, + regexs=self._succeed_regex) + if sign_status: + logger.info(f"{site} 签到成功") + return True, '签到成功' + + logger.error(f"{site} 签到失败,签到接口返回 {html_text}") + return False, '签到失败' diff --git a/plugins.v2/autosignin/sites/tjupt.py b/plugins.v2/autosignin/sites/tjupt.py new file mode 100644 index 0000000..4a20f84 --- /dev/null +++ b/plugins.v2/autosignin/sites/tjupt.py @@ -0,0 +1,274 @@ +import json +import os +import time +from io import BytesIO +from typing import Tuple + +from PIL import Image +from lxml import etree +from ruamel.yaml import CommentedMap + +from app.core.config import settings +from app.log import logger +from app.plugins.autosignin.sites import _ISiteSigninHandler +from app.utils.http import RequestUtils +from app.utils.string import StringUtils + + +class Tjupt(_ISiteSigninHandler): + """ + 北洋签到 + """ + # 匹配的站点Url,每一个实现类都需要设置为自己的站点Url + site_url = "tjupt.org" + + # 签到地址 + _sign_in_url = 'https://www.tjupt.org/attendance.php' + + # 已签到 + _sign_regex = ['今日已签到'] + + # 签到成功 + _succeed_regex = ['这是您的首次签到,本次签到获得\\d+个魔力值。', + '签到成功,这是您的第\\d+次签到,已连续签到\\d+天,本次签到获得\\d+个魔力值。', + '重新签到成功,本次签到获得\\d+个魔力值'] + + # 存储正确的答案,后续可直接查 + _answer_path = settings.TEMP_PATH / "signin/" + _answer_file = _answer_path / "tjupt.json" + + @classmethod + def match(cls, url: str) -> bool: + """ + 根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可 + :param url: 站点Url + :return: 是否匹配,如匹配则会调用该类的signin方法 + """ + return True if StringUtils.url_equal(url, cls.site_url) else False + + def signin(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行签到操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 签到结果信息 + """ + site = site_info.get("name") + site_cookie = site_info.get("cookie") + ua = site_info.get("ua") + proxy = site_info.get("proxy") + render = site_info.get("render") + + # 创建正确答案存储目录 + if not os.path.exists(os.path.dirname(self._answer_file)): + os.makedirs(os.path.dirname(self._answer_file)) + + # 获取北洋签到页面html + html_text = self.get_page_source(url=self._sign_in_url, + cookie=site_cookie, + ua=ua, + proxy=proxy, + render=render) + + # 获取签到后返回html,判断是否签到成功 + if not html_text: + logger.error(f"{site} 签到失败,请检查站点连通性") + return False, '签到失败,请检查站点连通性' + + if "login.php" in html_text: + logger.error(f"{site} 签到失败,Cookie已失效") + return False, '签到失败,Cookie已失效' + + sign_status = self.sign_in_result(html_res=html_text, + regexs=self._sign_regex) + if sign_status: + logger.info(f"{site} 今日已签到") + return True, '今日已签到' + + # 没有签到则解析html + html = etree.HTML(html_text) + if not html: + return False, '签到失败' + img_url = html.xpath('//table[@class="captcha"]//img/@src')[0] + + if not img_url: + logger.error(f"{site} 签到失败,未获取到签到图片") + return False, '签到失败,未获取到签到图片' + + # 签到图片 + img_url = "https://www.tjupt.org" + img_url + logger.info(f"获取到签到图片 {img_url}") + # 获取签到图片hash + captcha_img_res = RequestUtils(cookies=site_cookie, + ua=ua, + proxies=settings.PROXY if proxy else None + ).get_res(url=img_url) + if not captcha_img_res or captcha_img_res.status_code != 200: + logger.error(f"{site} 签到图片 {img_url} 请求失败") + return False, '签到失败,未获取到签到图片' + captcha_img = Image.open(BytesIO(captcha_img_res.content)) + captcha_img_hash = self._tohash(captcha_img) + logger.debug(f"签到图片hash {captcha_img_hash}") + + # 签到答案选项 + values = html.xpath("//input[@name='answer']/@value") + options = html.xpath("//input[@name='answer']/following-sibling::text()") + + if not values or not options: + logger.error(f"{site} 签到失败,未获取到答案选项") + return False, '签到失败,未获取到答案选项' + + # value+选项 + answers = list(zip(values, options)) + logger.debug(f"获取到所有签到选项 {answers}") + + # 查询已有答案 + exits_answers = {} + try: + with open(self._answer_file, 'r') as f: + json_str = f.read() + exits_answers = json.loads(json_str) + # 查询本地本次验证码hash答案 + captcha_answer = exits_answers[captcha_img_hash] + + # 本地存在本次hash对应的正确答案再遍历查询 + if captcha_answer: + for value, answer in answers: + if str(captcha_answer) == str(answer): + # 确实是答案 + return self.__signin(answer=value, + site_cookie=site_cookie, + ua=ua, + proxy=proxy, + site=site) + except (FileNotFoundError, IOError, OSError) as e: + logger.debug(f"查询本地已知答案失败:{str(e)},继续请求豆瓣查询") + + # 本地不存在正确答案则请求豆瓣查询匹配 + for value, answer in answers: + if answer: + # 豆瓣检索 + db_res = RequestUtils().get_res(url=f'https://movie.douban.com/j/subject_suggest?q={answer}') + if not db_res or db_res.status_code != 200: + logger.debug(f"签到选项 {answer} 未查询到豆瓣数据") + continue + + # 豆瓣返回结果 + db_answers = json.loads(db_res.text) + if not isinstance(db_answers, list): + db_answers = [db_answers] + + if len(db_answers) == 0: + logger.debug(f"签到选项 {answer} 查询到豆瓣数据为空") + + for db_answer in db_answers: + answer_img_url = db_answer['img'] + + # 获取答案hash + answer_img_res = RequestUtils(referer="https://movie.douban.com").get_res(url=answer_img_url) + if not answer_img_res or answer_img_res.status_code != 200: + logger.debug(f"签到答案 {answer} {answer_img_url} 请求失败") + continue + + answer_img = Image.open(BytesIO(answer_img_res.content)) + answer_img_hash = self._tohash(answer_img) + logger.debug(f"签到答案图片hash {answer} {answer_img_hash}") + + # 获取选项图片与签到图片相似度,大于0.9默认是正确答案 + score = self._comparehash(captcha_img_hash, answer_img_hash) + logger.info(f"签到图片与选项 {answer} 豆瓣图片相似度 {score}") + if score > 0.9: + # 确实是答案 + return self.__signin(answer=value, + site_cookie=site_cookie, + ua=ua, + proxy=proxy, + site=site, + exits_answers=exits_answers, + captcha_img_hash=captcha_img_hash) + + # 间隔5s,防止请求太频繁被豆瓣屏蔽ip + time.sleep(5) + logger.error(f"豆瓣图片匹配,未获取到匹配答案") + + # 没有匹配签到成功,则签到失败 + return False, '签到失败,未获取到匹配答案' + + def __signin(self, answer, site_cookie, ua, proxy, site, exits_answers=None, captcha_img_hash=None): + """ + 签到请求 + """ + data = { + 'answer': answer, + 'submit': '提交' + } + logger.debug(f"提交data {data}") + sign_in_res = RequestUtils(cookies=site_cookie, + ua=ua, + proxies=settings.PROXY if proxy else None + ).post_res(url=self._sign_in_url, data=data) + if not sign_in_res or sign_in_res.status_code != 200: + logger.error(f"{site} 签到失败,签到接口请求失败") + return False, '签到失败,签到接口请求失败' + + # 获取签到后返回html,判断是否签到成功 + sign_status = self.sign_in_result(html_res=sign_in_res.text, + regexs=self._succeed_regex) + if sign_status: + logger.info(f"签到成功") + if exits_answers and captcha_img_hash: + # 签到成功写入本地文件 + self.__write_local_answer(exits_answers=exits_answers or {}, + captcha_img_hash=captcha_img_hash, + answer=answer) + return True, '签到成功' + else: + logger.error(f"{site} 签到失败,请到页面查看") + return False, '签到失败,请到页面查看' + + def __write_local_answer(self, exits_answers, captcha_img_hash, answer): + """ + 签到成功写入本地文件 + """ + try: + exits_answers[captcha_img_hash] = answer + # 序列化数据 + formatted_data = json.dumps(exits_answers, indent=4) + with open(self._answer_file, 'w') as f: + f.write(formatted_data) + except (FileNotFoundError, IOError, OSError) as e: + logger.debug(f"签到成功写入本地文件失败:{str(e)}") + + @staticmethod + def _tohash(img, shape=(10, 10)): + """ + 获取图片hash + """ + img = img.resize(shape) + gray = img.convert('L') + s = 0 + hash_str = '' + for i in range(shape[1]): + for j in range(shape[0]): + s = s + gray.getpixel((j, i)) + avg = s / (shape[0] * shape[1]) + for i in range(shape[1]): + for j in range(shape[0]): + if gray.getpixel((j, i)) > avg: + hash_str = hash_str + '1' + else: + hash_str = hash_str + '0' + return hash_str + + @staticmethod + def _comparehash(hash1, hash2, shape=(10, 10)): + """ + 比较图片hash + 返回相似度 + """ + n = 0 + if len(hash1) != len(hash2): + return -1 + for i in range(len(hash1)): + if hash1[i] == hash2[i]: + n = n + 1 + return n / (shape[0] * shape[1]) diff --git a/plugins.v2/autosignin/sites/ttg.py b/plugins.v2/autosignin/sites/ttg.py new file mode 100644 index 0000000..d3470a6 --- /dev/null +++ b/plugins.v2/autosignin/sites/ttg.py @@ -0,0 +1,97 @@ +import re +from typing import Tuple + +from ruamel.yaml import CommentedMap + +from app.core.config import settings +from app.log import logger +from app.plugins.autosignin.sites import _ISiteSigninHandler +from app.utils.http import RequestUtils +from app.utils.string import StringUtils + + +class TTG(_ISiteSigninHandler): + """ + TTG签到 + """ + # 匹配的站点Url,每一个实现类都需要设置为自己的站点Url + site_url = "totheglory.im" + + # 已签到 + _sign_regex = ['已签到'] + _sign_text = '亲,您今天已签到过,不要太贪哦' + + # 签到成功 + _success_text = '您已连续签到' + + @classmethod + def match(cls, url: str) -> bool: + """ + 根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可 + :param url: 站点Url + :return: 是否匹配,如匹配则会调用该类的signin方法 + """ + return True if StringUtils.url_equal(url, cls.site_url) else False + + def signin(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行签到操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 签到结果信息 + """ + site = site_info.get("name") + site_cookie = site_info.get("cookie") + ua = site_info.get("ua") + proxy = site_info.get("proxy") + render = site_info.get("render") + + # 获取页面html + html_text = self.get_page_source(url="https://totheglory.im", + cookie=site_cookie, + ua=ua, + proxy=proxy, + render=render) + if not html_text: + logger.error(f"{site} 签到失败,请检查站点连通性") + return False, '签到失败,请检查站点连通性' + + if "login.php" in html_text: + logger.error(f"{site} 签到失败,Cookie已失效") + return False, '签到失败,Cookie已失效' + + # 判断是否已签到 + sign_status = self.sign_in_result(html_res=html_text, + regexs=self._sign_regex) + if sign_status: + logger.info(f"{site} 今日已签到") + return True, '今日已签到' + + # 获取签到参数 + signed_timestamp = re.search('(?<=signed_timestamp: ")\\d{10}', html_text).group() + signed_token = re.search('(?<=signed_token: ").*(?=")', html_text).group() + logger.debug(f"signed_timestamp={signed_timestamp} signed_token={signed_token}") + + data = { + 'signed_timestamp': signed_timestamp, + 'signed_token': signed_token + } + # 签到 + sign_res = RequestUtils(cookies=site_cookie, + ua=ua, + proxies=settings.PROXY if proxy else None + ).post_res(url="https://totheglory.im/signed.php", + data=data) + if not sign_res or sign_res.status_code != 200: + logger.error(f"{site} 签到失败,签到接口请求失败") + return False, '签到失败,签到接口请求失败' + + sign_res.encoding = "utf-8" + if self._success_text in sign_res.text: + logger.info(f"{site} 签到成功") + return True, '签到成功' + if self._sign_text in sign_res.text: + logger.info(f"{site} 今日已签到") + return True, '今日已签到' + + logger.error(f"{site} 签到失败,未知原因") + return False, '签到失败,未知原因' diff --git a/plugins.v2/autosignin/sites/u2.py b/plugins.v2/autosignin/sites/u2.py new file mode 100644 index 0000000..2c45c2c --- /dev/null +++ b/plugins.v2/autosignin/sites/u2.py @@ -0,0 +1,123 @@ +import datetime +import random +import re +from typing import Tuple + +from lxml import etree +from ruamel.yaml import CommentedMap + +from app.core.config import settings +from app.log import logger +from app.plugins.autosignin.sites import _ISiteSigninHandler +from app.utils.http import RequestUtils +from app.utils.string import StringUtils + + +class U2(_ISiteSigninHandler): + """ + U2签到 随机 + """ + # 匹配的站点Url,每一个实现类都需要设置为自己的站点Url + site_url = "u2.dmhy.org" + + # 已签到 + _sign_regex = ['已签到', + 'Show Up', + 'Показать', + '已簽到', + '已簽到'] + + # 签到成功 + _success_text = "window.location.href = 'showup.php';" + + @classmethod + def match(cls, url: str) -> bool: + """ + 根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可 + :param url: 站点Url + :return: 是否匹配,如匹配则会调用该类的signin方法 + """ + return True if StringUtils.url_equal(url, cls.site_url) else False + + def signin(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行签到操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 签到结果信息 + """ + site = site_info.get("name") + site_cookie = site_info.get("cookie") + ua = site_info.get("ua") + proxy = site_info.get("proxy") + render = site_info.get("render") + + now = datetime.datetime.now() + # 判断当前时间是否小于9点 + if now.hour < 9: + logger.error(f"{site} 签到失败,9点前不签到") + return False, '签到失败,9点前不签到' + + # 获取页面html + html_text = self.get_page_source(url="https://u2.dmhy.org/showup.php", + cookie=site_cookie, + ua=ua, + proxy=proxy, + render=render) + if not html_text: + logger.error(f"{site} 签到失败,请检查站点连通性") + return False, '签到失败,请检查站点连通性' + + if "login.php" in html_text: + logger.error(f"{site} 签到失败,Cookie已失效") + return False, '签到失败,Cookie已失效' + + # 判断是否已签到 + sign_status = self.sign_in_result(html_res=html_text, + regexs=self._sign_regex) + if sign_status: + logger.info(f"{site} 今日已签到") + return True, '今日已签到' + + # 没有签到则解析html + html = etree.HTML(html_text) + + if not html: + return False, '签到失败' + + # 获取签到参数 + req = html.xpath("//form//td/input[@name='req']/@value")[0] + hash_str = html.xpath("//form//td/input[@name='hash']/@value")[0] + form = html.xpath("//form//td/input[@name='form']/@value")[0] + submit_name = html.xpath("//form//td/input[@type='submit']/@name") + submit_value = html.xpath("//form//td/input[@type='submit']/@value") + if not re or not hash_str or not form or not submit_name or not submit_value: + logger.error("{site} 签到失败,未获取到相关签到参数") + return False, '签到失败' + + # 随机一个答案 + answer_num = random.randint(0, 3) + data = { + 'req': req, + 'hash': hash_str, + 'form': form, + 'message': '一切随缘~', + submit_name[answer_num]: submit_value[answer_num] + } + # 签到 + sign_res = RequestUtils(cookies=site_cookie, + ua=ua, + proxies=settings.PROXY if proxy else None + ).post_res(url="https://u2.dmhy.org/showup.php?action=show", + data=data) + if not sign_res or sign_res.status_code != 200: + logger.error(f"{site} 签到失败,签到接口请求失败") + return False, '签到失败,签到接口请求失败' + + # 判断是否签到成功 + # sign_res.text = "" + if self._success_text in sign_res.text: + logger.info(f"{site} 签到成功") + return True, '签到成功' + else: + logger.error(f"{site} 签到失败,未知原因") + return False, '签到失败,未知原因' diff --git a/plugins.v2/autosignin/sites/yema.py b/plugins.v2/autosignin/sites/yema.py new file mode 100644 index 0000000..879611f --- /dev/null +++ b/plugins.v2/autosignin/sites/yema.py @@ -0,0 +1,78 @@ +from typing import Tuple +from urllib.parse import urljoin + +from ruamel.yaml import CommentedMap + +from app.core.config import settings +from app.plugins.autosignin.sites import _ISiteSigninHandler +from app.utils.http import RequestUtils + + +class YemaPT(_ISiteSigninHandler): + """ + YemaPT 签到 + """ + # 匹配的站点Url,每一个实现类都需要设置为自己的站点Url + site_url = "yemapt.org" + + @classmethod + def match(cls, url: str) -> bool: + """ + 根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可 + :param url: 站点Url + :return: 是否匹配,如匹配则会调用该类的signin方法 + """ + return True if cls.site_url in url else False + + def signin(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行签到操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 签到结果信息 + """ + headers = { + "Content-Type": "application/json", + "User-Agent": site_info.get("ua"), + "Accept": "application/json, text/plain, */*", + } + # 获取用户信息,更新最后访问时间 + res = (RequestUtils(headers=headers, + timeout=15, + cookies=site_info.get("cookie"), + proxies=settings.PROXY if site_info.get("proxy") else None, + referer=site_info.get('url') + ).get_res(urljoin(site_info.get('url'), "api/consumer/checkIn"))) + + if res and res.json().get("success"): + return True, "签到成功" + elif res is not None: + return False, f"签到失败,签到结果:{res.json().get('errorMessage')}" + else: + return False, "签到失败,无法打开网站" + + def login(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行登录操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 登录结果信息 + """ + + headers = { + "Content-Type": "application/json", + "User-Agent": site_info.get("ua"), + "Accept": "application/json, text/plain, */*", + } + # 获取用户信息,更新最后访问时间 + res = (RequestUtils(headers=headers, + timeout=15, + cookies=site_info.get("cookie"), + proxies=settings.PROXY if site_info.get("proxy") else None, + referer=site_info.get('url') + ).get_res(urljoin(site_info.get('url'), "api/user/profile"))) + + if res and res.json().get("success"): + return True, "模拟登录成功" + elif res is not None: + return False, f"模拟登录失败,状态码:{res.status_code}" + else: + return False, "模拟登录失败,无法打开网站" diff --git a/plugins.v2/autosignin/sites/zhuque.py b/plugins.v2/autosignin/sites/zhuque.py new file mode 100644 index 0000000..f3375f5 --- /dev/null +++ b/plugins.v2/autosignin/sites/zhuque.py @@ -0,0 +1,88 @@ +import json +from typing import Tuple + +from lxml import etree +from ruamel.yaml import CommentedMap + +from app.core.config import settings +from app.log import logger +from app.plugins.autosignin.sites import _ISiteSigninHandler +from app.utils.http import RequestUtils +from app.utils.string import StringUtils + + +class ZhuQue(_ISiteSigninHandler): + """ + ZHUQUE签到 + """ + # 匹配的站点Url,每一个实现类都需要设置为自己的站点Url + site_url = "zhuque.in" + + @classmethod + def match(cls, url: str) -> bool: + """ + 根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可 + :param url: 站点Url + :return: 是否匹配,如匹配则会调用该类的signin方法 + """ + return True if StringUtils.url_equal(url, cls.site_url) else False + + def signin(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行签到操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 签到结果信息 + """ + site = site_info.get("name") + site_cookie = site_info.get("cookie") + ua = site_info.get("ua") + proxy = site_info.get("proxy") + render = site_info.get("render") + + # 获取页面html + html_text = self.get_page_source(url="https://zhuque.in", + cookie=site_cookie, + ua=ua, + proxy=proxy, + render=render) + if not html_text: + logger.error(f"{site} 模拟登录失败,请检查站点连通性") + return False, '模拟登录失败,请检查站点连通性' + + if "login.php" in html_text: + logger.error(f"{site} 模拟登录失败,Cookie已失效") + return False, '模拟登录失败,Cookie已失效' + + html = etree.HTML(html_text) + + if not html: + return False, '模拟登录失败' + + # 释放技能 + msg = '失败' + x_csrf_token = html.xpath("//meta[@name='x-csrf-token']/@content")[0] + if x_csrf_token: + data = { + "all": 1, + "resetModal": "true" + } + headers = { + "x-csrf-token": str(x_csrf_token), + "Content-Type": "application/json; charset=utf-8", + "User-Agent": ua + } + skill_res = RequestUtils(cookies=site_cookie, + headers=headers, + proxies=settings.PROXY if proxy else None + ).post_res(url="https://zhuque.in/api/gaming/fireGenshinCharacterMagic", json=data) + if not skill_res or skill_res.status_code != 200: + logger.error(f"模拟登录失败,释放技能失败") + + # '{"status":200,"data":{"code":"FIRE_GENSHIN_CHARACTER_MAGIC_SUCCESS","bonus":0}}' + skill_dict = json.loads(skill_res.text) + if skill_dict['status'] == 200: + bonus = int(skill_dict['data']['bonus']) + msg = f'成功,获得{bonus}魔力' + + logger.info(f'【{site}】模拟登录成功,技能释放{msg}') + return True, f'模拟登录成功,技能释放{msg}' From 5fcca6973ca1228ece2d0dadf205b2694fe06559 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Tue, 8 Oct 2024 01:24:52 +0800 Subject: [PATCH 066/218] feat(AutoSignIn): v2.5 --- package.json | 1 - package.v2.json | 12 ++++++++++++ plugins.v2/autosignin/__init__.py | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 5534a0a..d4d668b 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,6 @@ "icon": "signin.png", "author": "thsrite", "level": 2, - "v2": true, "history": { "v2.4.2": "修复PT时间签到失败问题", "v2.4.1": "修复海胆签到失败问题", diff --git a/package.v2.json b/package.v2.json index ee1042a..b3d55c3 100644 --- a/package.v2.json +++ b/package.v2.json @@ -22,5 +22,17 @@ "history": { "v3.9": "MoviePilot V2 版本站点刷流插件" } + }, + "AutoSignIn": { + "name": "站点自动签到", + "description": "自动模拟登录、签到站点。", + "labels": "站点", + "version": "2.5", + "icon": "signin.png", + "author": "thsrite", + "level": 2, + "history": { + "v2.5": "MoviePilot V2 版本站点自动签到插件" + } } } \ No newline at end of file diff --git a/plugins.v2/autosignin/__init__.py b/plugins.v2/autosignin/__init__.py index 25cb12b..4574daf 100644 --- a/plugins.v2/autosignin/__init__.py +++ b/plugins.v2/autosignin/__init__.py @@ -37,7 +37,7 @@ class AutoSignIn(_PluginBase): # 插件图标 plugin_icon = "signin.png" # 插件版本 - plugin_version = "2.4.2" + plugin_version = "2.5" # 插件作者 plugin_author = "thsrite" # 作者主页 From 6b56cb4920df098afe66d72ebbcc432073570184 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Tue, 8 Oct 2024 01:29:07 +0800 Subject: [PATCH 067/218] fix(BrushFlow): remove unintended code --- plugins.v2/brushflow/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/plugins.v2/brushflow/__init__.py b/plugins.v2/brushflow/__init__.py index 06f126a..8d0af67 100644 --- a/plugins.v2/brushflow/__init__.py +++ b/plugins.v2/brushflow/__init__.py @@ -246,9 +246,6 @@ class BrushFlow(_PluginBase): # 插件图标 plugin_icon = "brush.jpg" # 插件版本 - "history": { - "v3.9": "MoviePilot V2 版本站点数据统计插件" - } plugin_version = "3.9" # 插件作者 plugin_author = "jxxghp,InfinityPacer" From b340ccbb18ddf9557954f676947f2f49ad08f0fe Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Wed, 9 Oct 2024 01:56:53 +0800 Subject: [PATCH 068/218] feat(DownloadSiteTag): add support for v2 plugin --- plugins.v2/downloadsitetag/__init__.py | 163 +++++++++++++++++-------- 1 file changed, 113 insertions(+), 50 deletions(-) diff --git a/plugins.v2/downloadsitetag/__init__.py b/plugins.v2/downloadsitetag/__init__.py index 6c473e2..804a99c 100644 --- a/plugins.v2/downloadsitetag/__init__.py +++ b/plugins.v2/downloadsitetag/__init__.py @@ -1,25 +1,28 @@ import datetime -import pytz import threading from typing import List, Tuple, Dict, Any, Optional -from app.core.context import Context -from app.core.event import eventmanager, Event -from app.schemas.types import EventType, MediaType -from app.core.config import settings -from app.log import logger -from app.plugins import _PluginBase -from app.modules.qbittorrent import Qbittorrent -from app.modules.transmission import Transmission -from app.db.downloadhistory_oper import DownloadHistoryOper -from app.db.models.downloadhistory import DownloadHistory +import pytz +from app.helper.sites import SitesHelper from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger -from app.helper.sites import SitesHelper + +from app.core.config import settings +from app.core.context import Context +from app.core.event import eventmanager, Event +from app.db.downloadhistory_oper import DownloadHistoryOper +from app.db.models.downloadhistory import DownloadHistory +from app.helper.downloader import DownloaderHelper +from app.log import logger +from app.plugins import _PluginBase +from app.schemas import ServiceInfo +from app.schemas.types import EventType, MediaType from app.utils.string import StringUtils class DownloadSiteTag(_PluginBase): + # region 全局定义 + # 插件名称 plugin_name = "下载任务分类与标签" # 插件描述 @@ -44,10 +47,9 @@ class DownloadSiteTag(_PluginBase): # 退出事件 _event = threading.Event() # 私有属性 - downloader_qb = None - downloader_tr = None downloadhistory_oper = None sites_helper = None + downloaderhelper = None _scheduler = None _enabled = False _onlyonce = False @@ -61,11 +63,42 @@ class DownloadSiteTag(_PluginBase): _category_movie = None _category_tv = None _category_anime = None + _downloaders = None + + # Property + + @property + def service_infos(self) -> Optional[Dict[str, ServiceInfo]]: + """ + 服务信息 + """ + if not self._downloaders: + logger.warning("尚未配置下载器,请检查配置") + return None + + services = self.downloaderhelper.get_services(name_filters=self._downloaders) + if not services: + logger.warning("获取下载器实例失败,请检查配置") + return None + + active_services = {} + for service_name, service_info in services.items(): + if service_info.instance.is_inactive(): + logger.warning(f"下载器 {service_name} 未连接,请检查配置") + else: + active_services[service_name] = service_info + + if not active_services: + logger.warning("没有已连接的下载器,请检查配置") + return None + + return active_services + + # endregion def init_plugin(self, config: dict = None): - self.downloader_qb = Qbittorrent() - self.downloader_tr = Transmission() self.downloadhistory_oper = DownloadHistoryOper() + self.downloaderhelper = DownloaderHelper() self.sites_helper = SitesHelper() # 读取配置 if config: @@ -81,14 +114,7 @@ class DownloadSiteTag(_PluginBase): self._category_movie = config.get("category_movie") or "电影" self._category_tv = config.get("category_tv") or "电视" self._category_anime = config.get("category_anime") or "动漫" - if not ("interval_cron" in config): - # 新版本v1.6更新插件配置默认配置 - config["interval"] = self._interval - config["interval_cron"] = self._interval_cron - config["interval_time"] = self._interval_time - config["interval_unit"] = self._interval_unit - self.update_config(config) - logger.warn(f"{self.LOG_TAG}新版本v{self.plugin_version} 配置修正 ...") + self._downloaders = config.get("downloaders") # 停止现有任务 self.stop_service() @@ -179,6 +205,8 @@ class DownloadSiteTag(_PluginBase): """ 补全下载历史的标签与分类 """ + if not self.service_infos: + return logger.info(f"{self.LOG_TAG}开始执行 ...") # 记录处理的种子, 供辅种(无下载历史)使用 dispose_history = {} @@ -192,21 +220,21 @@ class DownloadSiteTag(_PluginBase): "agsvpt.trackers.work": "agsvpt.com", "tracker.cinefiles.info": "audiences.me", } - for DOWNLOADER in ["qbittorrent", "transmission"]: - logger.info(f"{self.LOG_TAG}开始扫描下载器 {DOWNLOADER} ...") + for name, service in self.service_infos.items(): + logger.info(f"{self.LOG_TAG}开始扫描下载器 {name} ...") # 获取下载器中的种子 - downloader_obj = self._get_downloader(DOWNLOADER) + downloader_obj = service.instance if not downloader_obj: - logger.error(f"{self.LOG_TAG} 获取下载器失败 {DOWNLOADER}") + logger.error(f"{self.LOG_TAG} 获取下载器失败 {name}") continue torrents, error = downloader_obj.get_torrents() # 如果下载器获取种子发生错误 或 没有种子 则跳过 if error or not torrents: continue - logger.info(f"{self.LOG_TAG}按时间重新排序 {DOWNLOADER} 种子数:{len(torrents)}") + logger.info(f"{self.LOG_TAG}按时间重新排序 {name} 种子数:{len(torrents)}") # 按添加时间进行排序, 时间靠前的按大小和名称加入处理历史, 判定为原始种子, 其他为辅种 - torrents = self._torrents_sort(torrents=torrents, dl_type=DOWNLOADER) - logger.info(f"{self.LOG_TAG}下载器 {DOWNLOADER} 分析种子信息中 ...") + torrents = self._torrents_sort(torrents=torrents, dl_type=service.type) + logger.info(f"{self.LOG_TAG}下载器 {name} 分析种子信息中 ...") for torrent in torrents: try: if self._event.is_set(): @@ -214,14 +242,14 @@ class DownloadSiteTag(_PluginBase): f"{self.LOG_TAG}停止服务") return # 获取已处理种子的key (size, name) - _key = self._torrent_key(torrent=torrent, dl_type=DOWNLOADER) + _key = self._torrent_key(torrent=torrent, dl_type=service.type) # 获取种子hash - _hash = self._get_hash(torrent=torrent, dl_type=DOWNLOADER) + _hash = self._get_hash(torrent=torrent, dl_type=service.type) if not _hash: continue # 获取种子当前标签 - torrent_tags = self._get_label(torrent=torrent, dl_type=DOWNLOADER) - torrent_cat = self._get_category(torrent=torrent, dl_type=DOWNLOADER) + torrent_tags = self._get_label(torrent=torrent, dl_type=service.type) + torrent_cat = self._get_category(torrent=torrent, dl_type=service.type) # 提取种子hash对应的下载历史 history: DownloadHistory = self.downloadhistory_oper.get_by_hash(_hash) if not history: @@ -241,7 +269,7 @@ class DownloadSiteTag(_PluginBase): history.torrent_site = None # 如果站点名称为空, 尝试通过trackers识别 elif not history.torrent_site: - trackers = self._get_trackers(torrent=torrent, dl_type=DOWNLOADER) + trackers = self._get_trackers(torrent=torrent, dl_type=service.type) for tracker in trackers: # 检查tracker是否包含特定的关键字,并进行相应的映射 for key, mapped_domain in tracker_mappings.items(): @@ -267,7 +295,7 @@ class DownloadSiteTag(_PluginBase): if self._enabled_media_tag and history.title: _tags.append(history.title) # 分类, 如果勾选开关的话 因允许mtype为空时运行到此, 因此需要判断mtype不为空。为防止不必要的识别, 种子已经存在分类torrent_cat时 也不执行 - if DOWNLOADER == "qbittorrent" and self._enabled_category and not torrent_cat and history.type: + if service.type == "qbittorrent" and self._enabled_category and not torrent_cat and history.type: # 如果是电视剧 需要区分是否动漫 genre_ids = None # 因允许tmdbid为空时运行到此, 因此需要判断tmdbid不为空 @@ -289,7 +317,7 @@ class DownloadSiteTag(_PluginBase): if not _cat and not _tags: continue # 执行通用方法, 设置种子标签与分类 - self._set_torrent_info(DOWNLOADER=DOWNLOADER, _hash=_hash, _torrent=torrent, _tags=_tags, _cat=_cat, + self._set_torrent_info(service=service, _hash=_hash, _torrent=torrent, _tags=_tags, _cat=_cat, _original_tags=torrent_tags) except Exception as e: logger.error( @@ -432,15 +460,16 @@ class DownloadSiteTag(_PluginBase): print(str(e)) return None - def _set_torrent_info(self, DOWNLOADER: str, _hash: str, _torrent: Any = None, _tags=None, _cat: str = None, + def _set_torrent_info(self, service: ServiceInfo, _hash: str, _torrent: Any = None, _tags=None, _cat: str = None, _original_tags: list = None): """ 设置种子标签与分类 """ - # 当前下载器 + if not service or not service.instance: + return if _tags is None: _tags = [] - downloader_obj = self._get_downloader(DOWNLOADER) + downloader_obj = service.instance if not _torrent: _torrent, error = downloader_obj.get_torrents(ids=_hash) if not _torrent or error: @@ -451,9 +480,9 @@ class DownloadSiteTag(_PluginBase): f"{self.LOG_TAG}设置种子标签与分类: {_hash} 查询到 {len(_torrent)} 个种子") _torrent = _torrent[0] # 判断是否可执行 - if DOWNLOADER and downloader_obj and _hash and _torrent: + if _hash and _torrent: # 下载器api不通用, 因此需分开处理 - if DOWNLOADER == "qbittorrent": + if service.type == "qbittorrent": # 设置标签 if _tags: downloader_obj.set_torrents_tag(ids=_hash, tags=_tags) @@ -463,7 +492,7 @@ class DownloadSiteTag(_PluginBase): try: _torrent.setCategory(category=_cat) except Exception as e: - logger.warn(f"下载器 {DOWNLOADER} 种子id: {_hash} 设置分类 {_cat} 失败:{str(e)}, " + logger.warn(f"下载器 {service.name} 种子id: {_hash} 设置分类 {_cat} 失败:{str(e)}, " f"尝试创建分类再设置 ...") downloader_obj.qbc.torrents_createCategory(name=_cat) _torrent.setCategory(category=_cat) @@ -472,16 +501,16 @@ class DownloadSiteTag(_PluginBase): if _tags: # _original_tags = None表示未指定, 因此需要获取原始标签 if _original_tags is None: - _original_tags = self._get_label(torrent=_torrent, dl_type=DOWNLOADER) + _original_tags = self._get_label(torrent=_torrent, dl_type=service.type) # 如果原始标签不是空的, 那么合并原始标签 if _original_tags: _tags = list(set(_original_tags).union(set(_tags))) downloader_obj.set_torrent_tag(ids=_hash, tags=_tags) logger.warn( - f"{self.LOG_TAG}下载器: {DOWNLOADER} 种子id: {_hash} {(' 标签: ' + ','.join(_tags)) if _tags else ''} {(' 分类: ' + _cat) if _cat else ''}") + f"{self.LOG_TAG}下载器: {service.name} 种子id: {_hash} {(' 标签: ' + ','.join(_tags)) if _tags else ''} {(' 分类: ' + _cat) if _cat else ''}") @eventmanager.register(EventType.DownloadAdded) - def DownloadAdded(self, event: Event): + def download_added(self, event: Event): """ 添加下载事件 """ @@ -492,6 +521,16 @@ class DownloadSiteTag(_PluginBase): return try: + downloader = event.event_data.get("downloader") + if not downloader: + logger.info("触发添加下载事件,但没有获取到下载器信息,跳过后续处理") + return + + service = self.service_infos.get(downloader) + if not service: + logger.info(f"触发添加下载事件,但没有监听下载器 {downloader},跳过后续处理") + return + context: Context = event.event_data.get("context") _hash = event.event_data.get("hash") _torrent = context.torrent_info @@ -509,7 +548,7 @@ class DownloadSiteTag(_PluginBase): _cat = self._genre_ids_get_cat(_media.type, _media.genre_ids) if _hash and (_tags or _cat): # 执行通用方法, 设置种子标签与分类 - self._set_torrent_info(DOWNLOADER=settings.DEFAULT_DOWNLOADER, _hash=_hash, _tags=_tags, _cat=_cat) + self._set_torrent_info(service=service, _hash=_hash, _tags=_tags, _cat=_cat) except Exception as e: logger.error( f"{self.LOG_TAG}分析下载事件时发生了错误: {str(e)}") @@ -597,8 +636,7 @@ class DownloadSiteTag(_PluginBase): { 'component': 'VCol', 'props': { - 'cols': 12, - 'md': 12 + 'cols': 12 }, 'content': [ { @@ -612,6 +650,31 @@ class DownloadSiteTag(_PluginBase): } ] }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': True, + 'chips': True, + 'clearable': True, + 'model': 'downloaders', + 'label': '下载器', + 'items': [{"title": config.name, "value": config.name} + for config in self.downloaderhelper.get_configs().values()] + } + } + ] + } + ] + }, { 'component': 'VRow', 'content': [ From fa2e8398d7c5d9c1eca4ff6e32520c795b640093 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Wed, 9 Oct 2024 01:57:07 +0800 Subject: [PATCH 069/218] feat(DownloadSiteTag): v2.2 --- package.v2.json | 12 ++++++++++++ plugins.v2/downloadsitetag/__init__.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/package.v2.json b/package.v2.json index b3d55c3..87b8990 100644 --- a/package.v2.json +++ b/package.v2.json @@ -34,5 +34,17 @@ "history": { "v2.5": "MoviePilot V2 版本站点自动签到插件" } + }, + "DownloadSiteTag": { + "name": "下载任务分类与标签", + "description": "自动给下载任务分类与打站点标签、剧集名称标签", + "labels": "下载管理", + "version": "2.2", + "icon": "Youtube-dl_B.png", + "author": "叮叮当", + "level": 1, + "history": { + "v2.2": "MoviePilot V2 版本下载任务分类与标签插件" + } } } \ No newline at end of file diff --git a/plugins.v2/downloadsitetag/__init__.py b/plugins.v2/downloadsitetag/__init__.py index 804a99c..4a93b21 100644 --- a/plugins.v2/downloadsitetag/__init__.py +++ b/plugins.v2/downloadsitetag/__init__.py @@ -30,7 +30,7 @@ class DownloadSiteTag(_PluginBase): # 插件图标 plugin_icon = "Youtube-dl_B.png" # 插件版本 - plugin_version = "2.1" + plugin_version = "2.2" # 插件作者 plugin_author = "叮叮当" # 作者主页 From f140c1197abcb7ca5a29636615052382e8172d43 Mon Sep 17 00:00:00 2001 From: Attente <19653207+wikrin@users.noreply.github.com> Date: Wed, 9 Oct 2024 22:13:33 +0800 Subject: [PATCH 070/218] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8Dtmdbapi?= =?UTF-8?q?=E8=BF=94=E5=9B=9ENone=E5=AF=BC=E8=87=B4=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=B4=A9=E6=BA=83=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 ++- plugins/bangumicoll/__init__.py | 15 ++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 651a73d..b648f0f 100644 --- a/package.json +++ b/package.json @@ -3,11 +3,12 @@ "name": "Bangumi收藏订阅", "description": "Bangumi用户收藏添加到订阅", "labels": "订阅", - "version": "1.2", + "version": "1.2.1", "icon": "https://raw.githubusercontent.com/wikrin/MoviePilot-Plugins/main/icons/bangumi_b.png", "author": "Attente", "level": 2, "history": { + "v1.2.1": "修复tmdb没有查询到条目导致插件崩溃的问题", "v1.2": "修复已完成订阅条目重复添加的问题", "v1.1": "新增根据收藏状态移除由此插件添加的订阅", "v1.0": "将bangumi用户收藏添加到 MP 订阅,部分功能未实现" diff --git a/plugins/bangumicoll/__init__.py b/plugins/bangumicoll/__init__.py index adddc34..01d48ca 100644 --- a/plugins/bangumicoll/__init__.py +++ b/plugins/bangumicoll/__init__.py @@ -29,7 +29,7 @@ class BangumiColl(_PluginBase): # 插件图标 plugin_icon = "https://raw.githubusercontent.com/wikrin/MoviePilot-Plugins/main/icons/bangumi_b.png" # 插件版本 - plugin_version = "1.2" + plugin_version = "1.2.1" # 插件作者 plugin_author = "Attente" # 作者主页 @@ -464,8 +464,12 @@ class BangumiColl(_PluginBase): if not meta.name: logger.warn(f"{item.get('name_cn')} 未识别到有效数据") continue - # 由于Bangumi的api不包含季度信息,不传入Bangumi条目id,默认使用tmdb + # 设置默认年份, 避免出现多个结果使用早期条目 + meta.year = item.get("date")[:4] mediainfo: MediaInfo = self.chain.recognize_media(meta=meta) + # 识别失败则跳过 + if not mediainfo: + continue # 对比Bangumi和tmdb的信息确定季度 for info in mediainfo.season_info: # 对比日期, 误差默认7天 @@ -548,12 +552,13 @@ class BangumiColl(_PluginBase): # 比较差异和阈值 return delta <= threshold - + @db_query def get_subscribe_history(self, db: Session = None) -> set: ''' 获取已完成的订阅 ''' - result = db.query(SubscribeHistory).filter(SubscribeHistory.bangumiid != None).all() + result = ( + db.query(SubscribeHistory).filter(SubscribeHistory.bangumiid != None).all() + ) return set([i.bangumiid for i in result]) - \ No newline at end of file From e9b25a9a784662ae2f652c290af9c31197dd828a Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Thu, 10 Oct 2024 02:10:53 +0800 Subject: [PATCH 071/218] feat(MediaServerRefresh): add support for v2 plugin --- plugins.v2/mediaserverrefresh/__init__.py | 107 ++++++++++++++++------ 1 file changed, 81 insertions(+), 26 deletions(-) diff --git a/plugins.v2/mediaserverrefresh/__init__.py b/plugins.v2/mediaserverrefresh/__init__.py index cc6578b..f860312 100644 --- a/plugins.v2/mediaserverrefresh/__init__.py +++ b/plugins.v2/mediaserverrefresh/__init__.py @@ -1,16 +1,14 @@ import time -from typing import Any, List, Dict, Tuple +from pathlib import Path +from typing import Any, List, Dict, Tuple, Optional -from app.core.config import settings from app.core.context import MediaInfo from app.core.event import eventmanager, Event -from app.modules.emby import Emby -from app.modules.jellyfin import Jellyfin -from app.modules.plex import Plex -from app.plugins import _PluginBase -from app.schemas import TransferInfo, RefreshMediaItem -from app.schemas.types import EventType +from app.helper.mediaserver import MediaServerHelper from app.log import logger +from app.plugins import _PluginBase +from app.schemas import TransferInfo, RefreshMediaItem, ServiceInfo +from app.schemas.types import EventType class MediaServerRefresh(_PluginBase): @@ -33,20 +31,47 @@ class MediaServerRefresh(_PluginBase): # 可使用的用户级别 auth_level = 1 + mediaserver_helper = None # 私有属性 _enabled = False _delay = 0 - _emby = None - _jellyfin = None - _plex = None + _mediaservers = None + + # Property + + @property + def service_infos(self) -> Optional[Dict[str, ServiceInfo]]: + """ + 服务信息 + """ + if not self._mediaservers: + logger.warning("尚未配置媒体服务器,请检查配置") + return None + + services = self.mediaserver_helper.get_services(name_filters=self._mediaservers) + if not services: + logger.warning("获取媒体服务器实例失败,请检查配置") + return None + + active_services = {} + for service_name, service_info in services.items(): + if service_info.instance.is_inactive(): + logger.warning(f"媒体服务器 {service_name} 未连接,请检查配置") + else: + active_services[service_name] = service_info + + if not active_services: + logger.warning("没有已连接的媒体服务器,请检查配置") + return None + + return active_services def init_plugin(self, config: dict = None): - self._emby = Emby() - self._jellyfin = Jellyfin() - self._plex = Plex() + self.mediaserver_helper = MediaServerHelper() if config: self._enabled = config.get("enabled") self._delay = config.get("delay") or 0 + self._mediaservers = config.get("mediaservers") or [] def get_state(self) -> bool: return self._enabled @@ -87,6 +112,31 @@ class MediaServerRefresh(_PluginBase): } ] }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': True, + 'chips': True, + 'clearable': True, + 'model': 'mediaservers', + 'label': '媒体服务器', + 'items': [{"title": config.name, "value": config.name} + for config in self.mediaserver_helper.get_configs().values()] + } + } + ] + } + ] + }, { 'component': 'VRow', 'content': [ @@ -131,7 +181,7 @@ class MediaServerRefresh(_PluginBase): return # 刷新媒体库 - if not settings.MEDIASERVER: + if not self.service_infos: return if self._delay: @@ -140,6 +190,9 @@ class MediaServerRefresh(_PluginBase): # 入库数据 transferinfo: TransferInfo = event_info.get("transferinfo") + if not transferinfo or not transferinfo.target_diritem or not transferinfo.target_diritem.path: + return + mediainfo: MediaInfo = event_info.get("mediainfo") items = [ RefreshMediaItem( @@ -147,21 +200,23 @@ class MediaServerRefresh(_PluginBase): year=mediainfo.year, type=mediainfo.type, category=mediainfo.category, - target_path=transferinfo.target_path + target_path=Path(transferinfo.target_diritem.path) ) ] - # Emby - if "emby" in settings.MEDIASERVER: - self._emby.refresh_library_by_items(items) - # Jeyllyfin - if "jellyfin" in settings.MEDIASERVER: - # FIXME Jellyfin未找到刷新单个项目的API - self._jellyfin.refresh_root_library() + for name, service in self.service_infos.items(): + # Emby + if self.mediaserver_helper.is_emby(service=service): + service.instance.refresh_library_by_items(items) - # Plex - if "plex" in settings.MEDIASERVER: - self._plex.refresh_library_by_items(items) + # Jeyllyfin + if self.mediaserver_helper.is_jellyfin(service=service): + # FIXME Jellyfin未找到刷新单个项目的API + service.instance.refresh_root_library() + + # Plex + if self.mediaserver_helper.is_plex(service=service): + service.instance.refresh_library_by_items(items) def stop_service(self): """ From aee0a654752806c33008a02c1fa39d5eadcb8cfd Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Thu, 10 Oct 2024 02:11:58 +0800 Subject: [PATCH 072/218] feat(MediaServerRefresh): v1.3 --- package.v2.json | 12 ++++++++++++ plugins.v2/mediaserverrefresh/__init__.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/package.v2.json b/package.v2.json index 87b8990..824313a 100644 --- a/package.v2.json +++ b/package.v2.json @@ -46,5 +46,17 @@ "history": { "v2.2": "MoviePilot V2 版本下载任务分类与标签插件" } + }, + "MediaServerRefresh": { + "name": "媒体库服务器刷新", + "description": "入库后自动刷新Emby/Jellyfin/Plex服务器海报墙。", + "labels": "媒体库", + "version": "1.3", + "icon": "refresh2.png", + "author": "jxxghp", + "level": 1, + "history": { + "v1.3": "MoviePilot V2 版本媒体库服务器刷新插件" + } } } \ No newline at end of file diff --git a/plugins.v2/mediaserverrefresh/__init__.py b/plugins.v2/mediaserverrefresh/__init__.py index f860312..75b1b53 100644 --- a/plugins.v2/mediaserverrefresh/__init__.py +++ b/plugins.v2/mediaserverrefresh/__init__.py @@ -19,7 +19,7 @@ class MediaServerRefresh(_PluginBase): # 插件图标 plugin_icon = "refresh2.png" # 插件版本 - plugin_version = "1.2" + plugin_version = "1.3" # 插件作者 plugin_author = "jxxghp" # 作者主页 From 0773bff16d7176229cc4563cd9326b4c46cd559b Mon Sep 17 00:00:00 2001 From: XiLin Date: Thu, 10 Oct 2024 13:16:48 +0800 Subject: [PATCH 073/218] add dingdingmsg --- icons/Dingding_A.png | Bin 0 -> 15938 bytes package.json | 10 + package.v2.json | 12 ++ plugins.v2/downloadsitetag/__init__.py | 165 ++++++++++----- plugins/dingdingmsg/__init__.py | 269 +++++++++++++++++++++++++ 5 files changed, 405 insertions(+), 51 deletions(-) create mode 100644 icons/Dingding_A.png create mode 100644 plugins/dingdingmsg/__init__.py diff --git a/icons/Dingding_A.png b/icons/Dingding_A.png new file mode 100644 index 0000000000000000000000000000000000000000..bfa27a65ee8ad490210fefb6291cc760606218aa GIT binary patch literal 15938 zcmb7rcOX^&|NlisnMty#NQ00)ukqG+3zZTnGb=M!M#d#kl3lLMGAm>jGOj(s6|%Q` zxny43z3#og*S&gw#`pXE=l91w-J^5Qc)gyl=kxg(C;W!N71kqsM<5Ug>$R(Y-GV@9 zz*iaw69f3?zQ;#9${y_26>Z2Dl)wVmIb^S;uLXe=M>FqR(S!ZN?pIA=5C~f%*MqDY#tHBT<&(&6)$v@{yYtdd zYFBse1O$+Fe%0LBN%^vg7yH_8Z07R+wV|`pvwgUb^x?AV9N$;g_l}I5YlnDpi>`NZ zhAW)#`QiXO{A)n`4EyT@%(SJHo&=(#+R~rj_#^MtGMmGg$Lt%y*q6qlBgex=&R?oT zD^J^Iz@9}Lek1grZ;yrVy})2vH4?9<6x(=g=dX%lJNI-wNP5Vfly}$P!5@1}KXZyd z$Lwl&@!n<-wiq+hZ0MV|QJr{y#(cec#x&;s61VRd(}|wA!BYGNCqLBK&8oR{-v&!CkXDgK4 ztv)hq)1UEny}BE-nX-hA?_T=;$m4hB=$ZJmNm*sLiRpIC9zyiQ#iv7tF`=j9vz>iz zJu1p!QnKU4*~AiKsb3%CPDFL`b_#Z$>=f%f*ICfh!0}4^EW-zpw!M31h!$0u+xOmW zqT`o$%q904%a#Mg$WJc$);}RlA+rR2F5SPXXKt_0qT6~QIfG#4SzWU5%c4`VQ>IhC zQ@OL|vQ|v9Nax+O#;>+=6OK=N;maqSB2!BA)8Jm0Y;j8obZ4gN+7_4hZBLg=Sw-fQ z?{n%&5&vsP=KefZXijAA)Z9aYbsg;cH-#3}YiBeJDvhs3eP2v%SB6Dzsgjz%F~&eU zW>)2W3HD#h_iN6$k@L3`kkb1pO=C?0E@MGWDFw`0mlsoZ#jlTFtekqA*DnYSO{hjC zX`S*?E5-+kQ?}>p?yEO(l_UkVpycdc$-1j(;(B#g$=yqL%+jL zasl}k*8gj#f_3iVoV(M>kKGK_xT!reCSa{mt1F!fu$D`tP4YzGFaa}Je!UC*4Iq;>k=cU#-s z?bzwkiMxD~2|=%S`{+~?){S^PZgQf;*JI+sVDyrNM%}=nn85IC#KGs6o=N6{4Usa$ z)XyhG(dE8YyjX(y!RHpdHXWU~7}@d6%brfh@zNguApYaaWA=hE*Ui=7ov1~I%Emp} z$dau)$3~I=Y|jUNu|X-K7)HkVdCsFT?2~wOeDvyfI}cO`R_ug>>L*sM2(iM_u<(9t z^GMok`AZk!yzYpv>K$%JM>UMoPdNGQ4-W^foMybY%F1%*C=*?5J5sx(`x8Ijh-8+e znj=`KXQQ5Utv@Xz8Jy&5W)W9w=*xRF z*+fp2 zeG-sw_u6G19p(8*`(B)^b=U1(%4%MU#@fvpFO$97 z;MC6@1ns50$5&v@m=FsS)pv5_fdrZ`+bY4jz;J3HWLuot;v9I`qnE$kD;4_`#B|N3 zUY2=7lEl;Seh?A2C<_uq4mdboSE_jA#TLvTCF0Ge`+imUsK+|mGaL8OZL9yT{uxxw zd*zCxq@*Pg?--rTXr@@R0MkR-FMigy_z|y_czBGwP8Y}Kgw)3k`o6Cb{RX#P%scb% ztytShOz-?8`{p&5lQK|3|d zZG-e6!t3GWegDBSa==KX;#B#N{-;%de2aL!%Ze;eaXkycF8D=X# zu>KJ#nLLc>>l9C3fsX$EyGGxzxW#^tQP9ah0XZnril4d#;YS<6_lxTJ+M5t0>lMv~ z^0rY8sg`Z_0O>BD=DCaRE182v{Mj{!%NKG2%=4GQXk%Q!k|p>Oxl4%eG+JXoGGAb2fBP9;Be^wZD^=U7V+_v0k-z+$@axjjLdl;@1EM? z9-|}xb3Bbl!DH_C;~WzG+HW0++vI8bRTnQMZkHeyCy+BhdKSx4e)1@3oOgNmN{ZR# zmN^%2G4M-`LJQ8k@)(?Geh)Zp_adU{x8y^$!%jSy`|K!M4*#Wca(j2GQceivQQmi6 zG9b%WIV8af8*Bu{?DX~RVqoe@%uMtm{)ED~YVroZDEz*h>R#q5mnOEM$2aOtM-(!0}YdJESJ<4`JaXLky|VXV(m%o?WJe(Qkk!3fh=qXsa#r+i-++{ z93NW0q)z%Noz)7JwT%)6zZD=l)!&k$Ybb{r$n&`oUA}dFZ*S6t{X%`!S>d2$hgpQl z(}nK3ULiHKRKwiVgJ38BF)!kDcgZfLf^=MVwP>Jb>3{D`uD|P6$4t$S2K&nvxiiy4 z6b1OCNCKW4h$O*2m7(5Yg{I$P#(o`>^lsnJc1=8Uu0MsX4UC8$*C$s*4os|{&|liN z-r~%wnUzW!T5kR=)>hxrRkvn+;~YJNW_E#muf5n%Fx%@qyFKWpOdFeiGfy2G%z0;4 zMBEe-hW@)!QmT8K7RI_ALGuCfyo&5Ys_^m{8B1>Uyvn8t5}tdq#Ins+9LqA?=WbH5 z*wFJAsr;>z5MF$rcE0&7Pf+hm>Rd|7qWkq;xk_2-bAy``Qhs^gIY9jV8`>bFh@L7k zziVxv$G{9CS+L`k00f>83KIW=Ce;aX$9G=S@u-5As?9&XRV}z(c@+J`M!NC`s z5rV*97k?Yl9g#iM_(|7i&%O(D2Q z-ujJ;zBn2wTsh>!S0_b(+!(^~vXG3MsdtK-97DS4*=sY3Q6oEgOHTr1D=s2ycaG`3 z65_HhR-plz#y#qG(N~>1Yt-6L&Gdhcr5q260H1z(bM*bc3=}*{*9edgkTP>JIdJ6h zBSxDZh9;Z@GK-d|qal-5VsGx@8UdC6mi!goWec5`1QpJr*X=1YsD6xX&77-rB1~V- zad36>JuWCV@aT=WTjqIcjq#vuB0?M1ivf*as%M@pf-SjHmb6O5p$jm$RVtn76p^MkEX#;MW)?feBe;&rH`$VCp;qRuEITd_d4aiYL1#X z&@dT7_@p2KvoSkzs-6szV02%^T(Qp`9y+bM?S_h6L|prRNQlghx7}}R?>X`8NC2nw z_Tjo(BQFP4OOiwcl>36YIh?^Y;9^&k5TB6lmzUVOd85u<3AhBMdx$tLRR|6N9Yyv7 zHB1&sR0Q$i+3Nt>wGFe{p59NV92GSO-h_|UHW4oRUg~NP8jb8$DL)e^8AeD)c)qr2 z5wz6jT~qixl=c@`qe+*hT$-F$HPhq8)DBP4{@i8O9CK0I3{L-?Iv>{O*uH2=Xsb^6>jO^>9A zai9O)h&bk6F*v2j9LU9UM04?UU1n~oLJr-siju=ao^JcXYqEvWEUQQMRY^w=ZGRDmdYzC*2c}l-AQ9k@eZV}&k zIq>cjLw0xSy!C#UEie9a__D$0kP#NG?5qm_pVv>c6)?Kj9|S z7aSr?8QA~qq`=A!#PU1J<*K_6y9xbT-a_%`%3nQF~F_Q(8w83Y-D zxyV1q>ExF%nW0Z0u3VQbh+du%Pw_i0bn+Zg*wij~^r7Q_HPZ+Ve&z5nXN-fD6kR)Q zkYeP3o`L_u2N^-f7h@+mK}&K_HpLQZBgyKBEYkt3*CGpcphb!$6nAyxwN&Xr9}@Df zrYGTFf2jA4>u$eHb9U3E?gnJd?&ev@qPTb$-1_>Jl7^5Um+CZfen6Q~HqpC*wY<=a z8LvNhgw5}I>zbk|(|Lti(}!RBE=^s;p$`x_b2@`K*0}f+YI$DP97+I$rS%&wd0$Dg zqO+7W-?o?By&^iWD?xs56WP7Kb3t4xQ6nyy9?3SA1CdA>_F}}@o9=Uiq9GjdTKr@e z-d?rb=P~8C8{A6e>J!JAZ2G5UC_P0hUJ^h+C^xSm$Y#Qnrt!rS%tLy5$SNO!w#dPt zl&&%CYa{(#jkk7#K|`l3ifY3rkKbsyq>b5q5v3&v^(eUsiOJ074q}au=SJpxX`>(h zt5*TogN1naZ@e&#?dZuJrSdnI(qxLxM3|o%r9iA~(6)fUD1I95OzojbUMWj3&Pr2j z8i#!`5e1L+8Tw5A91xg)I&P3`2c?eU@20Mdb?!MDvnJ~MH&xC)rhC=Wcj+?g0N00u zE}YuX=q^(F_PCrDEXILG^9%Pq$YnBD#Z3rze(p!I^l{<6{N$o>AIibd2O2DE9 zO-t=rh~8N}U)Pj0yS|{2l)EW}d0YJMBZi|WC%8vKC;t_~P*)Q6)7CU=6l_xh&8v{&YMtb&cZBQc4aBVg7sp90lckp*4^dP>(U(dg$P=A zT1k16czW0P>;O>gH3^a8%8E{!ldLgbvvOGD#wywo6L}0NzUU0|*cm;7Nln)LHh@jR zqbAE2EZpiFNbUyk>NV_l@qsif&nEI)Y! z<)9ZyKvP!srg%hb zVN3pa(C`ib1VceFe~@0lYoru&5Pj><8PmXP;rLc;);rDZ{SH(BZ8z$;y5>?W$G9ikm5#_j+rQrVR@!Ik z#v?=ySa~HE_Okr7McTIIc>&I<@e?(JU^E(QXJjI9C$xKTHfYnQ9q9-I+yzNUFxml3 z1TQUVVc)aGzU(mj*~zGLlRJYyeP+E(mR3sxf}1RkvEguT%+ZyD=)y8c8NW08hkh=Mn8&W_=AE+5@oA+aIgdgxZq8oMvgR0 zO`b(3xQ385g@*Q?hrRaDsG7r4zkY5TZx8-#9a9ysr{igic6}shA;#sif9FS(u#PSL z6pt-Bc&ZWL3nMXKf{x3#&Rqq|^6hnPEL&}3zUZlfgb2@I3V`Aiwr1J_B+U!xl?fivQ3Fo5LlwC1}rY&dDoM%wB|27Y*N{a=W_4@KZ+A8fxYUiy^bS zR8TpxUl}#7Fa?FOHS%`l*k_~p-lNl#FA-!p=-*H734-o)Yjp+rF7F$2uF6hAj6_-@k%eHvmW^udn{p#V`$5G6(16)|(eQ!aW4WvLTYCca6=|HD3ws66!MR}tvuB?AU znBXMfEN_noWxkEH7b|O~@aL2YZ;>$%^>$Qo@^kR9UQ1R@^<#C-+0&3*9-cjqO*=R5 z(ri8yX{cImpdvIe*=0+?Zs1w0wwvAZd% zohP0RP7C|&))siAz!)bd&T@n}71z*CF>P*4n5>W}b+o3eu2PD$Iuu~eL@!A0yj@w7#ub!F zNk-@5DaCoTCG2m&wsQEX*=%v^ zwnl`On}X=3OK{JXGch_OAgi}eyJb-+k@_jXp`uEoJ)OpRff<6wPcx@f1wbZ+6Zc=j zE}-W`wyQ3`Kxa~yI6y~lk3)vTwVrwN6qcSj4T^Wb8Ss1OyU^*eE4jOFMw64>7Qe^H ztV4wy<2xxZ{^{PZ?SMhBPzX`M5|Assl=%=#m{`A3(p9-2Aqdeq6|n4qHCAe=6bv}& z<8Jqx2jWK(!;)CZNi2k#K3qvu;cOva9yB5SQ$FN5%HjQKEAaeQ>%ReZ-R!l1U|EI? zpXw*0$JoDkyA~Wq1@v3AsBLQho`*Hsvbxq@3?&Lh<%;e$;HJCf;5|*QQiW-tnim}^ zK^>0F;*)DMj1dedhb5;?D4nk3Z4epSV^=LdGLe2as+m*Yf-7he+_32}3LF1@Y+@ij z#m`nVy#}4UMVo$`JR@bV;rjum2u)SN54j7|h3cttSI)2RDz=ZBa~>-MfvnQUI`3ct z3ZP1Cc}-TGkKg@wC=`u?+PS)bXGqpMV({=rDq&MMaB`;|*ZtR@!f)B8jH~tZ8ZdrG zTsOwHG>i5W-7=+7dZxfPP<()4sj32n$_$0?-$adHg5>^ev_q)RjpgBLHJ=J3@w7U< zrY-b{fGi-DZHg-I@dDc#oSNPF-MDY>;W zL;RzRp@TBODy($H5e}oS<$4d zZb0tE-33CyrJ}OIz}4=^nBxbXycSBJg9~a$Su=Igvp}|gI;w| zlbhRQ9ty1sh;wIQ=GGY8c}d7UMxRu9-CNt+)@bjaZr)!&FwKsU4W76bUP$#5oVd-r ze8^3CxH5BvXCJ=ZQ|N^!;sb?sUZ6j5=5_1kE^lQ)etnd{7IxTm>e*X7s<;rD9F+Qs zX5@o=ihK9a_SYwFK6*DsCv|i|fx0o`VA2{l7y<%=nrRn6xlAHWR47zMgFlvC5PY?9_Hv1K1X% zSC@Ugz1d7&xFaJxI@-FZyC3E907_werUVMAD7*Si>H=T>0Q*ed#HLUR_jxT?q`K zk)&Wky9BWi{fd?idln7P20i!4vItlc2jKERv%^_T7h;_j3uhLQrKgt_eNm?1}omTRs6KqV_@&Y;%Hq_?FE@img8NqnSU?LIa z0TN}A`cdAlsC`en0psw1DG3$s8K1KJ(wwWWCd_kv)XzZ}`#_%VGvpedZ1b_Gl}>&4 z^{s2g5+97^R5SuesOvWzY39A^D1t!9dms=L5+~j+uoM)?Rqfxzwqz5kLf3c&qvvil z0=Xy@z;XTj+7en3)tjPfBA|5=r-;O-*eWzqKYcy!y0g1Tl3yF$8^O(jl2X{-hx~9B z5@$gv6o6ayYn$GR4{lf{+bNYRko1Qj0_5v!Joz{rbdOr#n*BvOZRU(-f@e zr)QWhIXg`Lju#BhU|A&~N%Nk<*x30!i)l!m__Ra042$aifi`zen;CBG` z+Fn&-u*{eNG4bS^iVG56e1UQv>-=0$s-L_(maM$@B3Nw$Swg$U{7+BZ2s&#Jb9%_E zEBM*{Clk?Myj;zRt4+84eu$IL>{{KaB#Ty4fkdwp0G;CWL8^8C5NH1gj1=BX2f1<+ zCFrk1q9gNQMs6t8@X-fNTbv~_}QwB&b24_K{wplJfFy^S+kZZsv%?r!=hY(qs98vPC|4$OZX5QD>)K-TP& zKs}K}W;syAQtD?GkyZPcWHyhY*}d5ti@`bt^67LuRsD}c0>t59itXMH5i{f(@k2@N zg`3DTbYNcS95Ss0f%}0P2LCiV|AYnD_nG{Se01Mvr&K;3x-7ecto#-8N6w~VKQKL3 z`k=5atF)y%{+X;lNtB9eETHRiavk_zcHKMe(cXm8*HjaIWSF$fg&$FmGb==nu10vc-KV?YX?^l)`0#Az7v??&MdP z@R~hjxA?UILCP8)$#EcAfOele{@DV7fQ5N!=ER4-TU6-|OTgs+KE&Il5tcz+s&MBK^-GfkvRz2eGRJ`{E8EkCm^x-b1nI)pzCx zejw{DDtbCKO@jlNfRboyaym$Y0Y*t$bdQ+1RdyOXQq1^rOL>eiyUAb`*31H{5?+yu zUb94)lFhH@*G{%eryXaTC+`qddCg6pvXeMtT7HQ(?wY;q-%*u8|DPa9u6VQ~&(L@E z5LP3=&U?wD7G_9TTrnTY@&j=ReRsU2!RC)Rwn@=a*MGk|(hcF3SNuU?bh-a1C+sE* zbuE$Q=UK@c?&Zvzq)qwaR$1do$+q_2Cs!>#kW)nI|Dzbaqv zPD$RjPWm9mYmOz(rl*=Q1PX%#OvUcB^v)?Xtx+& z{idd7XxC-)#RG($q$V+ z>Y4PGlL#TczSz%Gsd2W9to-z^$w^oGu1gNB$yrV6rI`IaY-hzKO$yE#-;Iqgpob`% zInbCi_nn1Iro-Gyb)djH!1zax;kCH9@IqVI7gbphWl-MNijHnksZBth-`-MIz6XiP z%RU~&8W&(U-s@Edh$Fa!FM8HRs(8kfWa80e_Jr(VzNfx;5o=sOx$Fe219Ja)z(}(W zm_@ydkV2euj6ZfS@1DvXS&sc}yqpblN##cB=$=4;nf&pP6lX37#-W|wa&;j-UU)Z> z)>(~$I^<0HB0+LHwRs>~tZq=aItjX4EE4e9t9aaUSZHIPDL*&QjK|0h1=!W9Q zvEG~voO>2KCQ$*Am_1ByY1@4-Z7aSeO-&ufX!8&A{ErKYs&4?VS?^Z%{yP4X;~cjo zvZ}rk=ku+5^vGy8^FK=TA0HLogwl%eU_?DiV>8?OZ$M%iADEqmbogo0m`Dwyr!!kT z-%F4p0FTRe#&~kIDe6iOyx=`Qyt^B=Vzm3|;)MC|S+wItdFw5r!2pgTD`ryW#)rnF zuhhI`W1LY=RWw!*(=A7_eo>nk-TCavQ}%@ZZEx47G>Rm5hT;h=jhv-u(OqrfUD+-- zKvslOC7P$e>N6swJbV_?)}zf_r^NoO0Hd|`BwnzP=ml#m385Op*1wg%jI{V>9#=~2 zfnSrY&@(G*t0Y%8RNA_ageMjg!~6CAb;wtB56;Trwwsvq9^926e0;vzk3S@YTk!M+ zmkc2&RQ0J(PyM)+@f)bqe22#h^pj`D7}ty*(5aBVd<6etO87Ic)AW|g-Fa$Eq%ceX zG!R~@Q`|#K}$h^cHRmmDcoJo}x1qfhEim61Qq zO>1LqEwr%7cRpmd?!m-`o(Qs4zwp#}ry5oIxTL!yI?{Qc;3!`7KNy>TGKUG$J~CZN z(Z(RD=jirN7VNh*1Z&sOJ%7^mjRRxFAN7W!;86T!Ng5HtRC~i253b5rDUv`-*L`*a zV!V-$9zMG_uyHp}@U<>KM{DVRTwot4(GtieLLgiRoZYJh6yVoL9YdSf;rvq?Ybs9G zMHD>;xj>)f--?Q3g5`%t?hnFrKz<-7k^0 zm`J2DAgo53dbTTN@QCC;`AS_N23rqJ%U@987a-qi=k@c8dgC$(fXy&d0@K$s^I@Rnoa{_eYlW@QHOpS z5zOaC7r6AM`|I)z6FXi_3EkG!9iKQhoVTiK*Aj~t<_StNI$2L}=80|(PL%qeoRph; zUJeO4$>e(Gxi<};6kS1Wk6pV)#GLq~o@%gIp-rXePjR%xhWIc^`K(;J(zWQG;{lgQ zX+0Y_is>d{jq^s)4ahxV`ZMjxaW!`13cZJgioWV(K@+NRfB0qzml;90P13aBQqqf5 zzsBIFgjicecNWS2GIvJ2-YGCGj{*-94G9UQo|Ulkt1m6j(opmQONXNe4avCTX5$^MS=c0tqrTE#=h--`l7I$In8sY3AATGg!~+3 zzZV-Kra==FN-L6nC_lY=u50ybZK=Df9-|}%0@A?$<@VG@+P~n38?C@r`hC6`;uCl% zUYS4sjDK2gN*@VOD#dWwz=sj?D8i{iMvet(AAzt0H^c|IpXOKvDvOs zta2C4-#>ebFk2b>zTuG;WwmlJ!j@=EI{TQ$@Tm9Sbg8U*U_szBs7>^rVqG&cIh!xHQ8T{njh%Q7 zZ`38t-EyK%z=4su?~l!NyiSH;ghp9w9btL^(WHz4kMPeXR%}8@V=xAm znM>Y35tPjh@#X#LBgx}^s$BI6A{G^(x-6PdEu=z$um_lJ@M$x0ggwQNXxP^+_NunB za|W?_k79HIXz6Le;Jz@fa-f1BY)+OWZVHFJpO(Za$F?whO(p`DC=`7fmBL#Z!pzG~LbkIppJ~PYZ0A z+tEm3Na*>@RQjpx=lXHBhyeSN#SL#!zwA_YJ@eKQ{(K)_3eX=sP?ISy6WA2b%mT+= zfH|ByMz8HtODY^=(p^govC{G9<}hbzm=s0!Yz#b%5cXFYcIvrc!(g+_LBW!06gwN} z4FQSQ%E@mCR_Ou*RTL;bkc}ykhHKdkU^|K6>n6sUwaH-a`QUPU-N=?7M%GLrE0@D| zXeX9@{nndRqaE4u1t&qn2l&7LG?xE|(>z9}E&H-TD3;`pMXrN$3|T5M*U4TDROLY4 z_`cBQ*fE$^ouRDc5=7A)ZWkaZ#u)1ZFBmXV&I7~e!oUBF?e{Hrmg%h<5aAv@o zk>*ceWWMu4ir(FbmYmF@cy2AD!7N^0VMJy4$y`kr&9D-5A;F@6i3P z78a@a(onV!mp+74=(I;O;4XD<7(8f{ zd#;k)``DEmqxTyu2wUUjaOsP*O8SSv30pW_EvFEAS0d{0qb>-4;vV6bVB8CJs@uYj z;$CI6+Ha%5XgjT+1~=6vxEyOSDF&A|J=CDDRl}73ZCauIt}ycRETmbeE@O*gT%qKq zY>QiEl)*mGqV--4P;B~UfpZ;{*GE*u;gjY&<)uHbrF`)`Pg{W+Rx|Y)_7+ziQ#(0q z({tgs!K>j9HSd>5Zv~-JwmXGOyS@vb;gpwGY*rLqcTb*B5rbQp?|5IzW17M z9*b7}_P4?$fw|qFdqUpMe))i2{1KIoC?VYS+OK{yjx0gzNw>-j%3%3n!pcGSH4RvF z*BiB8JRZq7PBS)ECupEHS66CKy;h`K(s{{DT$s>T-|ot6?kvP{*vp!tAM*UeG>R$I zQW7WF1uun7QaMjIY->u(Sc^dGeYZ~E&HxMqjQIyKa$O2uk2#g0glP(OM#yc7SKQpQ zZq%THp<&@CRBX{@2lueG^`hI#ucRa`1{g1l4^|~Zy+xUwSh_ZP%wJV*fzmN8bWDm2 zjiC=#|9#$GGsct4dDzsIA$?J1-5_-e4`@W4X>m+da1H08_C$~MD z@JR9hy%$u8*MQo~c=N-Q{1Y{)s;m571BoB1MW_j;pxWCFO+teMKhTJbum$mISKG4m-O7F zInUhzMDG4HEAoRr$+CoLO-xIPzpjhp{)y?_X!p<0g|#r+YiSQ7^~v&kKeN)$SR_5Y zQ?l4lDJcHe(xEuOLjKQuN43OWi)iUq@2qDhRK8RmR_ez#wT?9lwA^n2v&}Y%_y2+X zOB7xt-FAbih^}Fh{dp-3xSV%4xE#OO?#ZSbOztY7i|58}46B$fYzbferanz*&s|kI zz4!ZdRr~|)E@9YD>nXZ1kD^hIyj5@O)?;9+63GZ-hOxj{VQjEMoJ%yga(E>#r!b5g zb^^u&!bHV?@x=~)_8ipY)N4kGZiXV*nxDOo=qYBJ&*uzs-PJ?+&wnhdXnDjHBI;{V z(mMjKtKzWpuquN6npcINbqP9NQB<|d_tNkaT*jQ1o!1A-OJC>AEaZ!c7r^!rdH;TW zO8r>9K*Rlpl!mbe0e=0bo$8%how}XZI`unmzS~XgNbK`3kV0xDZ(76j0QZEI~V662wen zkiG{0=`I+`f6fPXWYG8n&wKpLQD(Ly28|QbhjnRk?JE=-wVD$VCs-EX4CbMyVT z;e`QN~3@+Z@St+eGc}`Y3jL2BobAt$Ifu^D-t~n$>%5bM;z(&9=}q zJ0&VrD238}xN+#lGF>8vdb2of$0hxHp`2a29~atQI=mUQO_T?{+Utc$<0K+ z#E`@Y%JU{>8qb7`jPR_Anld%0(~ey|+ziPzo$iF>nk${HK)?F@nlAK6qGvM! zpqY0PY!~Ys1=zgKazpwxf)3x5^;wf)X_XhS7uay$mpMUsO6?3Al;w&go7JDE40U_j zSrfB4Jts8hkh)9HvnsdL Optional[Dict[str, ServiceInfo]]: + """ + 服务信息 + """ + if not self._downloaders: + logger.warning("尚未配置下载器,请检查配置") + return None + + services = self.downloaderhelper.get_services(name_filters=self._downloaders) + if not services: + logger.warning("获取下载器实例失败,请检查配置") + return None + + active_services = {} + for service_name, service_info in services.items(): + if service_info.instance.is_inactive(): + logger.warning(f"下载器 {service_name} 未连接,请检查配置") + else: + active_services[service_name] = service_info + + if not active_services: + logger.warning("没有已连接的下载器,请检查配置") + return None + + return active_services + + # endregion def init_plugin(self, config: dict = None): - self.downloader_qb = Qbittorrent() - self.downloader_tr = Transmission() self.downloadhistory_oper = DownloadHistoryOper() + self.downloaderhelper = DownloaderHelper() self.sites_helper = SitesHelper() # 读取配置 if config: @@ -81,14 +114,7 @@ class DownloadSiteTag(_PluginBase): self._category_movie = config.get("category_movie") or "电影" self._category_tv = config.get("category_tv") or "电视" self._category_anime = config.get("category_anime") or "动漫" - if not ("interval_cron" in config): - # 新版本v1.6更新插件配置默认配置 - config["interval"] = self._interval - config["interval_cron"] = self._interval_cron - config["interval_time"] = self._interval_time - config["interval_unit"] = self._interval_unit - self.update_config(config) - logger.warn(f"{self.LOG_TAG}新版本v{self.plugin_version} 配置修正 ...") + self._downloaders = config.get("downloaders") # 停止现有任务 self.stop_service() @@ -179,6 +205,8 @@ class DownloadSiteTag(_PluginBase): """ 补全下载历史的标签与分类 """ + if not self.service_infos: + return logger.info(f"{self.LOG_TAG}开始执行 ...") # 记录处理的种子, 供辅种(无下载历史)使用 dispose_history = {} @@ -192,21 +220,21 @@ class DownloadSiteTag(_PluginBase): "agsvpt.trackers.work": "agsvpt.com", "tracker.cinefiles.info": "audiences.me", } - for DOWNLOADER in ["qbittorrent", "transmission"]: - logger.info(f"{self.LOG_TAG}开始扫描下载器 {DOWNLOADER} ...") + for name, service in self.service_infos.items(): + logger.info(f"{self.LOG_TAG}开始扫描下载器 {name} ...") # 获取下载器中的种子 - downloader_obj = self._get_downloader(DOWNLOADER) + downloader_obj = service.instance if not downloader_obj: - logger.error(f"{self.LOG_TAG} 获取下载器失败 {DOWNLOADER}") + logger.error(f"{self.LOG_TAG} 获取下载器失败 {name}") continue torrents, error = downloader_obj.get_torrents() # 如果下载器获取种子发生错误 或 没有种子 则跳过 if error or not torrents: continue - logger.info(f"{self.LOG_TAG}按时间重新排序 {DOWNLOADER} 种子数:{len(torrents)}") + logger.info(f"{self.LOG_TAG}按时间重新排序 {name} 种子数:{len(torrents)}") # 按添加时间进行排序, 时间靠前的按大小和名称加入处理历史, 判定为原始种子, 其他为辅种 - torrents = self._torrents_sort(torrents=torrents, dl_type=DOWNLOADER) - logger.info(f"{self.LOG_TAG}下载器 {DOWNLOADER} 分析种子信息中 ...") + torrents = self._torrents_sort(torrents=torrents, dl_type=service.type) + logger.info(f"{self.LOG_TAG}下载器 {name} 分析种子信息中 ...") for torrent in torrents: try: if self._event.is_set(): @@ -214,14 +242,14 @@ class DownloadSiteTag(_PluginBase): f"{self.LOG_TAG}停止服务") return # 获取已处理种子的key (size, name) - _key = self._torrent_key(torrent=torrent, dl_type=DOWNLOADER) + _key = self._torrent_key(torrent=torrent, dl_type=service.type) # 获取种子hash - _hash = self._get_hash(torrent=torrent, dl_type=DOWNLOADER) + _hash = self._get_hash(torrent=torrent, dl_type=service.type) if not _hash: continue # 获取种子当前标签 - torrent_tags = self._get_label(torrent=torrent, dl_type=DOWNLOADER) - torrent_cat = self._get_category(torrent=torrent, dl_type=DOWNLOADER) + torrent_tags = self._get_label(torrent=torrent, dl_type=service.type) + torrent_cat = self._get_category(torrent=torrent, dl_type=service.type) # 提取种子hash对应的下载历史 history: DownloadHistory = self.downloadhistory_oper.get_by_hash(_hash) if not history: @@ -241,7 +269,7 @@ class DownloadSiteTag(_PluginBase): history.torrent_site = None # 如果站点名称为空, 尝试通过trackers识别 elif not history.torrent_site: - trackers = self._get_trackers(torrent=torrent, dl_type=DOWNLOADER) + trackers = self._get_trackers(torrent=torrent, dl_type=service.type) for tracker in trackers: # 检查tracker是否包含特定的关键字,并进行相应的映射 for key, mapped_domain in tracker_mappings.items(): @@ -267,7 +295,7 @@ class DownloadSiteTag(_PluginBase): if self._enabled_media_tag and history.title: _tags.append(history.title) # 分类, 如果勾选开关的话 因允许mtype为空时运行到此, 因此需要判断mtype不为空。为防止不必要的识别, 种子已经存在分类torrent_cat时 也不执行 - if DOWNLOADER == "qbittorrent" and self._enabled_category and not torrent_cat and history.type: + if service.type == "qbittorrent" and self._enabled_category and not torrent_cat and history.type: # 如果是电视剧 需要区分是否动漫 genre_ids = None # 因允许tmdbid为空时运行到此, 因此需要判断tmdbid不为空 @@ -289,7 +317,7 @@ class DownloadSiteTag(_PluginBase): if not _cat and not _tags: continue # 执行通用方法, 设置种子标签与分类 - self._set_torrent_info(DOWNLOADER=DOWNLOADER, _hash=_hash, _torrent=torrent, _tags=_tags, _cat=_cat, + self._set_torrent_info(service=service, _hash=_hash, _torrent=torrent, _tags=_tags, _cat=_cat, _original_tags=torrent_tags) except Exception as e: logger.error( @@ -432,15 +460,16 @@ class DownloadSiteTag(_PluginBase): print(str(e)) return None - def _set_torrent_info(self, DOWNLOADER: str, _hash: str, _torrent: Any = None, _tags=None, _cat: str = None, + def _set_torrent_info(self, service: ServiceInfo, _hash: str, _torrent: Any = None, _tags=None, _cat: str = None, _original_tags: list = None): """ 设置种子标签与分类 """ - # 当前下载器 + if not service or not service.instance: + return if _tags is None: _tags = [] - downloader_obj = self._get_downloader(DOWNLOADER) + downloader_obj = service.instance if not _torrent: _torrent, error = downloader_obj.get_torrents(ids=_hash) if not _torrent or error: @@ -451,9 +480,9 @@ class DownloadSiteTag(_PluginBase): f"{self.LOG_TAG}设置种子标签与分类: {_hash} 查询到 {len(_torrent)} 个种子") _torrent = _torrent[0] # 判断是否可执行 - if DOWNLOADER and downloader_obj and _hash and _torrent: + if _hash and _torrent: # 下载器api不通用, 因此需分开处理 - if DOWNLOADER == "qbittorrent": + if service.type == "qbittorrent": # 设置标签 if _tags: downloader_obj.set_torrents_tag(ids=_hash, tags=_tags) @@ -463,7 +492,7 @@ class DownloadSiteTag(_PluginBase): try: _torrent.setCategory(category=_cat) except Exception as e: - logger.warn(f"下载器 {DOWNLOADER} 种子id: {_hash} 设置分类 {_cat} 失败:{str(e)}, " + logger.warn(f"下载器 {service.name} 种子id: {_hash} 设置分类 {_cat} 失败:{str(e)}, " f"尝试创建分类再设置 ...") downloader_obj.qbc.torrents_createCategory(name=_cat) _torrent.setCategory(category=_cat) @@ -472,16 +501,16 @@ class DownloadSiteTag(_PluginBase): if _tags: # _original_tags = None表示未指定, 因此需要获取原始标签 if _original_tags is None: - _original_tags = self._get_label(torrent=_torrent, dl_type=DOWNLOADER) + _original_tags = self._get_label(torrent=_torrent, dl_type=service.type) # 如果原始标签不是空的, 那么合并原始标签 if _original_tags: _tags = list(set(_original_tags).union(set(_tags))) downloader_obj.set_torrent_tag(ids=_hash, tags=_tags) logger.warn( - f"{self.LOG_TAG}下载器: {DOWNLOADER} 种子id: {_hash} {(' 标签: ' + ','.join(_tags)) if _tags else ''} {(' 分类: ' + _cat) if _cat else ''}") + f"{self.LOG_TAG}下载器: {service.name} 种子id: {_hash} {(' 标签: ' + ','.join(_tags)) if _tags else ''} {(' 分类: ' + _cat) if _cat else ''}") @eventmanager.register(EventType.DownloadAdded) - def DownloadAdded(self, event: Event): + def download_added(self, event: Event): """ 添加下载事件 """ @@ -492,6 +521,16 @@ class DownloadSiteTag(_PluginBase): return try: + downloader = event.event_data.get("downloader") + if not downloader: + logger.info("触发添加下载事件,但没有获取到下载器信息,跳过后续处理") + return + + service = self.service_infos.get(downloader) + if not service: + logger.info(f"触发添加下载事件,但没有监听下载器 {downloader},跳过后续处理") + return + context: Context = event.event_data.get("context") _hash = event.event_data.get("hash") _torrent = context.torrent_info @@ -509,7 +548,7 @@ class DownloadSiteTag(_PluginBase): _cat = self._genre_ids_get_cat(_media.type, _media.genre_ids) if _hash and (_tags or _cat): # 执行通用方法, 设置种子标签与分类 - self._set_torrent_info(DOWNLOADER=settings.DEFAULT_DOWNLOADER, _hash=_hash, _tags=_tags, _cat=_cat) + self._set_torrent_info(service=service, _hash=_hash, _tags=_tags, _cat=_cat) except Exception as e: logger.error( f"{self.LOG_TAG}分析下载事件时发生了错误: {str(e)}") @@ -597,8 +636,7 @@ class DownloadSiteTag(_PluginBase): { 'component': 'VCol', 'props': { - 'cols': 12, - 'md': 12 + 'cols': 12 }, 'content': [ { @@ -612,6 +650,31 @@ class DownloadSiteTag(_PluginBase): } ] }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': True, + 'chips': True, + 'clearable': True, + 'model': 'downloaders', + 'label': '下载器', + 'items': [{"title": config.name, "value": config.name} + for config in self.downloaderhelper.get_configs().values()] + } + } + ] + } + ] + }, { 'component': 'VRow', 'content': [ diff --git a/plugins/dingdingmsg/__init__.py b/plugins/dingdingmsg/__init__.py new file mode 100644 index 0000000..280548f --- /dev/null +++ b/plugins/dingdingmsg/__init__.py @@ -0,0 +1,269 @@ +import re +import time +import hmac +import hashlib +import base64 +import urllib.parse + +from app.plugins import _PluginBase +from app.core.event import eventmanager, Event +from app.schemas.types import EventType, NotificationType +from app.utils.http import RequestUtils +from typing import Any, List, Dict, Tuple +from app.log import logger + + +class DingdingMsg(_PluginBase): + # 插件名称 + plugin_name = "钉钉机器人" + # 插件描述 + plugin_desc = "支持使用钉钉机器人发送消息通知。" + # 插件图标 + plugin_icon = "Dingding_A.png" + # 插件版本 + plugin_version = "1.12" + # 插件作者 + plugin_author = "nnlegenda" + # 作者主页 + author_url = "https://github.com/nnlegenda" + # 插件配置项ID前缀 + plugin_config_prefix = "dingdingmsg_" + # 加载顺序 + plugin_order = 25 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled = False + _token = None + _secret = None + _msgtypes = [] + + def init_plugin(self, config: dict = None): + if config: + self._enabled = config.get("enabled") + self._token = config.get("token") + self._secret = config.get("secret") + self._msgtypes = config.get("msgtypes") or [] + + def get_state(self) -> bool: + return self._enabled and (True if self._token else False) and (True if self._secret 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 枚举,生成消息类型选项 + MsgTypeOptions = [] + for item in NotificationType: + MsgTypeOptions.append({ + "title": item.value, + "value": item.name + }) + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'token', + 'label': '钉钉机器人token', + 'placeholder': 'xxxxxx', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'secret', + 'label': '加签', + 'placeholder': 'SECxxx', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': True, + 'chips': True, + 'model': 'msgtypes', + 'label': '消息类型', + 'items': MsgTypeOptions + } + } + ] + } + ] + }, + ] + } + ], { + "enabled": False, + 'token': '', + 'msgtypes': [] + } + + 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: + return + # 类型 + msg_type: NotificationType = msg_body.get("type") + # 标题 + title = msg_body.get("title") + # 文本 + text = msg_body.get("text") + # 封面 + cover = 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 + + sc_url = self.url_sign(self._token, self._secret) + + try: + + if text: + # 对text进行Markdown特殊字符转义 + text = re.sub(r"([_`])", r"\\\1", text) + else: + text = "" + + if cover: + data = { + "msgtype": "markdown", + "markdown": { + "title": title, + "text": "### %s\n\n" + "![Cover](%s)\n\n" + "> %s\n\n > MoviePilot %s\n" % (title, cover, text, msg_type.value) + } + } + else: + data = { + "msgtype": "markdown", + "markdown": { + "title": title, + "text": "### %s\n\n" + "> %s\n\n > MoviePilot %s\n" % (title, text, msg_type.value) + } + } + res = RequestUtils(content_type="application/json").post_res(sc_url, json=data) + if res and res.status_code == 200: + ret_json = res.json() + errno = ret_json.get('errcode') + error = ret_json.get('errmsg') + 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 + + def url_sign(self, access_token: str, secret: str) -> str: + """ + 加签 + """ + # 生成时间戳和签名 + timestamp = str(round(time.time() * 1000)) + secret_enc = secret.encode('utf-8') + string_to_sign = '{}\n{}'.format(timestamp, secret) + string_to_sign_enc = string_to_sign.encode('utf-8') + hmac_code = hmac.new(secret_enc, string_to_sign_enc, digestmod=hashlib.sha256).digest() + sign = urllib.parse.quote_plus(base64.b64encode(hmac_code)) + # 组合请求的完整 URL + full_url = f'https://oapi.dingtalk.com/robot/send?access_token={access_token}×tamp={timestamp}&sign={sign}' + return full_url From 27b2adef0a261823ac655d2374b458b3a45d5957 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Thu, 10 Oct 2024 15:52:05 +0800 Subject: [PATCH 074/218] fix SiteStatistic --- package.v2.json | 4 ++-- plugins.v2/sitestatistic/__init__.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.v2.json b/package.v2.json index 824313a..0c27974 100644 --- a/package.v2.json +++ b/package.v2.json @@ -3,12 +3,12 @@ "name": "站点数据统计", "description": "站点统计数据图表。", "labels": "站点,仪表板", - "version": "1.0.1", + "version": "1.0.2", "icon": "statistic.png", "author": "lightolly,jxxghp", "level": 2, "history": { - "v1.0.1": "MoviePilot V2 版本站点数据统计插件" + "v1.0.2": "MoviePilot V2 版本站点数据统计插件" } }, "BrushFlow": { diff --git a/plugins.v2/sitestatistic/__init__.py b/plugins.v2/sitestatistic/__init__.py index b5a373f..0e29e93 100644 --- a/plugins.v2/sitestatistic/__init__.py +++ b/plugins.v2/sitestatistic/__init__.py @@ -32,7 +32,7 @@ class SiteStatistic(_PluginBase): # 插件图标 plugin_icon = "statistic.png" # 插件版本 - plugin_version = "1.0.1" + plugin_version = "1.0.2" # 插件作者 plugin_author = "lightolly,jxxghp" # 作者主页 @@ -206,8 +206,8 @@ class SiteStatistic(_PluginBase): data_list: List[SiteUserData] = self.siteoper.get_userdata() if not data_list: return "", [], [] - # 每个日期只保留最后一条数据 - data_list = list({data.updated_day: data for data in data_list}.values()) + # 每个日期、每个站点只保留最后一条数据 + data_list = list({f"{data.updated_day}_{data.name}": data for data in data_list}.values()) # 按日期倒序排序 data_list.sort(key=lambda x: x.updated_day, reverse=True) # 今天的日期 From 44673b6bf84438cf0e8b28cbfa137ec3b665f094 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Thu, 10 Oct 2024 19:07:59 +0800 Subject: [PATCH 075/218] =?UTF-8?q?ChatGPT=20=E9=80=82=E9=85=8DMoviePilot?= =?UTF-8?q?=20V2=20=E7=89=88=E6=9C=AC=EF=BC=8C=E9=87=87=E7=94=A8=E9=93=BE?= =?UTF-8?q?=E5=BC=8F=E4=BA=8B=E4=BB=B6=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 +- package.v2.json | 12 ++ plugins.v2/chatgpt/__init__.py | 261 +++++++++++++++++++++++++++++++++ plugins.v2/chatgpt/openai.py | 206 ++++++++++++++++++++++++++ 4 files changed, 480 insertions(+), 2 deletions(-) create mode 100644 plugins.v2/chatgpt/__init__.py create mode 100644 plugins.v2/chatgpt/openai.py diff --git a/package.json b/package.json index 910c2d9..c06de77 100644 --- a/package.json +++ b/package.json @@ -256,8 +256,7 @@ "version": "1.3", "icon": "Chatgpt_A.png", "author": "jxxghp", - "level": 1, - "v2": true + "level": 1 }, "NAStoolSync": { "name": "历史记录同步", diff --git a/package.v2.json b/package.v2.json index 0c27974..1dd5632 100644 --- a/package.v2.json +++ b/package.v2.json @@ -58,5 +58,17 @@ "history": { "v1.3": "MoviePilot V2 版本媒体库服务器刷新插件" } + }, + "ChatGPT": { + "name": "ChatGPT", + "description": "消息交互支持与ChatGPT对话。", + "labels": "消息通知,识别", + "version": "2.0", + "icon": "Chatgpt_A.png", + "author": "jxxghp", + "level": 1, + "history": { + "v2.0": "适配MoviePilot V2 版本,采用链式事件机制" + } } } \ No newline at end of file diff --git a/plugins.v2/chatgpt/__init__.py b/plugins.v2/chatgpt/__init__.py new file mode 100644 index 0000000..3ba05b6 --- /dev/null +++ b/plugins.v2/chatgpt/__init__.py @@ -0,0 +1,261 @@ +from typing import Any, List, Dict, Tuple + +from app.core.config import settings +from app.core.event import eventmanager, Event +from app.log import logger +from app.plugins import _PluginBase +from app.plugins.chatgpt.openai import OpenAi +from app.schemas.types import EventType, ChainEventType + + +class ChatGPT(_PluginBase): + # 插件名称 + plugin_name = "ChatGPT" + # 插件描述 + plugin_desc = "消息交互支持与ChatGPT对话。" + # 插件图标 + plugin_icon = "Chatgpt_A.png" + # 插件版本 + plugin_version = "2.0" + # 插件作者 + plugin_author = "jxxghp" + # 作者主页 + author_url = "https://github.com/jxxghp" + # 插件配置项ID前缀 + plugin_config_prefix = "chatgpt_" + # 加载顺序 + plugin_order = 15 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + openai = None + _enabled = False + _proxy = False + _recognize = False + _openai_url = None + _openai_key = None + _model = None + + def init_plugin(self, config: dict = None): + if config: + self._enabled = config.get("enabled") + self._proxy = config.get("proxy") + self._recognize = config.get("recognize") + self._openai_url = config.get("openai_url") + self._openai_key = config.get("openai_key") + self._model = config.get("model") + if self._openai_url and self._openai_key: + self.openai = OpenAi(api_key=self._openai_key, api_url=self._openai_url, + proxy=settings.PROXY if self._proxy else None, + model=self._model) + + def get_state(self) -> bool: + return self._enabled + + @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、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'proxy', + 'label': '使用代理服务器', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'recognize', + 'label': '辅助识别', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'openai_url', + 'label': 'OpenAI API Url', + 'placeholder': 'https://api.openai.com', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'openai_key', + 'label': 'sk-xxx' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'model', + 'label': '自定义模型', + 'placeholder': 'gpt-3.5-turbo', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '开启插件后,消息交互时使用请[问帮你]开头,或者以?号结尾,或者超过10个汉字/单词,则会触发ChatGPT回复。' + '开启辅助识别后,内置识别功能无法正常识别种子/文件名称时,将使用ChatGTP进行AI辅助识别,可以提升动漫等非规范命名的识别成功率。' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "proxy": False, + "recognize": False, + "openai_url": "https://api.openai.com", + "openai_key": "", + "model": "gpt-3.5-turbo" + } + + def get_page(self) -> List[dict]: + pass + + @eventmanager.register(EventType.UserMessage) + def talk(self, event: Event): + """ + 监听用户消息,获取ChatGPT回复 + """ + if not self._enabled: + return + if not self.openai: + return + text = event.event_data.get("text") + userid = event.event_data.get("userid") + channel = event.event_data.get("channel") + if not text: + return + response = self.openai.get_response(text=text, userid=userid) + if response: + self.post_message(channel=channel, title=response, userid=userid) + + @eventmanager.register(ChainEventType.NameRecognize) + def recognize(self, event: Event): + """ + 监听识别事件,使用ChatGPT辅助识别名称 + """ + if not event.event_data: + return + title = event.event_data.get("title") + if not title: + return + # 调用ChatGPT + response = self.openai.get_media_name(filename=title) + logger.info(f"ChatGPT返回结果:{response}") + if response: + event.event_data = { + 'title': title, + 'name': response.get("title"), + 'year': response.get("year"), + 'season': response.get("season"), + 'episode': response.get("episode") + } + else: + event.event_data = {} + + def stop_service(self): + """ + 退出插件 + """ + pass diff --git a/plugins.v2/chatgpt/openai.py b/plugins.v2/chatgpt/openai.py new file mode 100644 index 0000000..937ecea --- /dev/null +++ b/plugins.v2/chatgpt/openai.py @@ -0,0 +1,206 @@ +import json +import time +from typing import List, Union + +import openai +from cacheout import Cache + +OpenAISessionCache = Cache(maxsize=100, ttl=3600, timer=time.time, default=None) + + +class OpenAi: + _api_key: str = None + _api_url: str = None + _model: str = "gpt-3.5-turbo" + + def __init__(self, api_key: str = None, api_url: str = None, proxy: dict = None, model: str = None): + self._api_key = api_key + self._api_url = api_url + openai.api_base = self._api_url + "/v1" + openai.api_key = self._api_key + if proxy and proxy.get("https"): + openai.proxy = proxy.get("https") + if model: + self._model = model + + def get_state(self) -> bool: + return True if self._api_key else False + + @staticmethod + def __save_session(session_id: str, message: str): + """ + 保存会话 + :param session_id: 会话ID + :param message: 消息 + :return: + """ + seasion = OpenAISessionCache.get(session_id) + if seasion: + seasion.append({ + "role": "assistant", + "content": message + }) + OpenAISessionCache.set(session_id, seasion) + + @staticmethod + def __get_session(session_id: str, message: str) -> List[dict]: + """ + 获取会话 + :param session_id: 会话ID + :return: 会话上下文 + """ + seasion = OpenAISessionCache.get(session_id) + if seasion: + seasion.append({ + "role": "user", + "content": message + }) + else: + seasion = [ + { + "role": "system", + "content": "请在接下来的对话中请使用中文回复,并且内容尽可能详细。" + }, + { + "role": "user", + "content": message + }] + OpenAISessionCache.set(session_id, seasion) + return seasion + + def __get_model(self, message: Union[str, List[dict]], + prompt: str = None, + user: str = "MoviePilot", + **kwargs): + """ + 获取模型 + """ + if not isinstance(message, list): + if prompt: + message = [ + { + "role": "system", + "content": prompt + }, + { + "role": "user", + "content": message + } + ] + else: + message = [ + { + "role": "user", + "content": message + } + ] + return openai.ChatCompletion.create( + model=self._model, + user=user, + messages=message, + **kwargs + ) + + @staticmethod + def __clear_session(session_id: str): + """ + 清除会话 + :param session_id: 会话ID + :return: + """ + if OpenAISessionCache.get(session_id): + OpenAISessionCache.delete(session_id) + + def get_media_name(self, filename: str): + """ + 从文件名中提取媒体名称等要素 + :param filename: 文件名 + :return: Json + """ + if not self.get_state(): + return None + result = "" + try: + _filename_prompt = "I will give you a movie/tvshow file name.You need to return a Json." \ + "\nPay attention to the correct identification of the film name." \ + "\n{\"title\":string,\"version\":string,\"part\":string,\"year\":string,\"resolution\":string,\"season\":number|null,\"episode\":number|null}" + completion = self.__get_model(prompt=_filename_prompt, message=filename) + result = completion.choices[0].message.content + return json.loads(result) + except Exception as e: + print(f"{str(e)}:{result}") + return {} + + def get_response(self, text: str, userid: str): + """ + 聊天对话,获取答案 + :param text: 输入文本 + :param userid: 用户ID + :return: + """ + if not self.get_state(): + return "" + try: + if not userid: + return "用户信息错误" + else: + userid = str(userid) + if text == "#清除": + self.__clear_session(userid) + return "会话已清除" + # 获取历史上下文 + messages = self.__get_session(userid, text) + completion = self.__get_model(message=messages, user=userid) + result = completion.choices[0].message.content + if result: + self.__save_session(userid, text) + return result + except openai.error.RateLimitError as e: + return f"请求被ChatGPT拒绝了,{str(e)}" + except openai.error.APIConnectionError as e: + return f"ChatGPT网络连接失败:{str(e)}" + except openai.error.Timeout as e: + return f"没有接收到ChatGPT的返回消息:{str(e)}" + except Exception as e: + return f"请求ChatGPT出现错误:{str(e)}" + + def translate_to_zh(self, text: str): + """ + 翻译为中文 + :param text: 输入文本 + """ + if not self.get_state(): + return False, None + system_prompt = "You are a translation engine that can only translate text and cannot interpret it." + user_prompt = f"translate to zh-CN:\n\n{text}" + result = "" + try: + completion = self.__get_model(prompt=system_prompt, + message=user_prompt, + temperature=0, + top_p=1, + frequency_penalty=0, + presence_penalty=0) + result = completion.choices[0].message.content.strip() + return True, result + except Exception as e: + print(f"{str(e)}:{result}") + return False, str(e) + + def get_question_answer(self, question: str): + """ + 从给定问题和选项中获取正确答案 + :param question: 问题及选项 + :return: Json + """ + if not self.get_state(): + return None + result = "" + try: + _question_prompt = "下面我们来玩一个游戏,你是老师,我是学生,你需要回答我的问题,我会给你一个题目和几个选项,你的回复必须是给定选项中正确答案对应的序号,请直接回复数字" + completion = self.__get_model(prompt=_question_prompt, message=question) + result = completion.choices[0].message.content + return result + except Exception as e: + print(f"{str(e)}:{result}") + return {} From 465fef34bd0e8149c7f3d2871278a54467404fe0 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Thu, 10 Oct 2024 20:32:18 +0800 Subject: [PATCH 076/218] style: keep consistent code style and formatting --- plugins.v2/brushflow/__init__.py | 102 +++++++++++----------- plugins.v2/downloadsitetag/__init__.py | 66 +++++++------- plugins.v2/mediaserverrefresh/__init__.py | 16 ++-- 3 files changed, 87 insertions(+), 97 deletions(-) diff --git a/plugins.v2/brushflow/__init__.py b/plugins.v2/brushflow/__init__.py index 8d0af67..0ec911c 100644 --- a/plugins.v2/brushflow/__init__.py +++ b/plugins.v2/brushflow/__init__.py @@ -259,11 +259,11 @@ class BrushFlow(_PluginBase): auth_level = 2 # 私有属性 - siteshelper = None - siteoper = None - torrentschain = None - subscribeoper = None - downloaderhelper = None + sites_helper = None + site_oper = None + torrents_chain = None + subscribe_oper = None + downloader_helper = None # 刷流配置 _brush_config = None # Brush任务是否启动 @@ -280,40 +280,14 @@ class BrushFlow(_PluginBase): # tabs _tabs = None - # Property - - @property - def service_info(self) -> Optional[ServiceInfo]: - """ - 服务信息 - """ - brush_config = self.__get_brush_config() - service = self.downloaderhelper.get_service(name=brush_config.downloader) - if not service: - self.__log_and_notify_error("站点刷流任务出错,获取下载器实例失败,请检查配置") - return None - - if service.instance.is_inactive(): - self.__log_and_notify_error("站点刷流任务出错,下载器未连接") - return None - - return service - - @property - def downloader(self) -> Optional[Union[Qbittorrent, Transmission]]: - """ - 下载器实例 - """ - return self.service_info.instance if self.service_info else None - # endregion def init_plugin(self, config: dict = None): - self.siteshelper = SitesHelper() - self.siteoper = SiteOper() - self.torrentschain = TorrentsChain() - self.subscribeoper = SubscribeOper() - self.downloaderhelper = DownloaderHelper() + self.sites_helper = SitesHelper() + self.site_oper = SiteOper() + self.torrents_chain = TorrentsChain() + self.subscribe_oper = SubscribeOper() + self.downloader_helper = DownloaderHelper() self._task_brush_enable = False if not config: @@ -335,7 +309,7 @@ class BrushFlow(_PluginBase): # 这里先过滤掉已删除的站点并保存,特别注意的是,这里保留了界面选择站点时的顺序,以便后续站点随机刷流或顺序刷流 if brush_config.brushsites: - site_id_to_public_status = {site.get("id"): site.get("public") for site in self.siteshelper.get_indexers()} + site_id_to_public_status = {site.get("id"): site.get("public") for site in self.sites_helper.get_indexers()} brush_config.brushsites = [ site_id for site_id in brush_config.brushsites if site_id in site_id_to_public_status and not site_id_to_public_status[site_id] @@ -401,6 +375,30 @@ class BrushFlow(_PluginBase): self._scheduler.print_jobs() self._scheduler.start() + @property + def service_info(self) -> Optional[ServiceInfo]: + """ + 服务信息 + """ + brush_config = self.__get_brush_config() + service = self.downloader_helper.get_service(name=brush_config.downloader) + if not service: + self.__log_and_notify_error("站点刷流任务出错,获取下载器实例失败,请检查配置") + return None + + if service.instance.is_inactive(): + self.__log_and_notify_error("站点刷流任务出错,下载器未连接") + return None + + return service + + @property + def downloader(self) -> Optional[Union[Qbittorrent, Transmission]]: + """ + 下载器实例 + """ + return self.service_info.instance if self.service_info else None + def get_state(self) -> bool: brush_config = self.__get_brush_config() return True if brush_config and brush_config.enabled else False @@ -795,10 +793,10 @@ class BrushFlow(_PluginBase): # 站点选项 site_options = [{"title": site.get("name"), "value": site.get("id")} - for site in self.siteshelper.get_indexers()] + for site in self.sites_helper.get_indexers()] # 下载器选项 downloader_options = [{"title": config.name, "value": config.name} - for config in self.downloaderhelper.get_configs().values()] + for config in self.downloader_helper.get_configs().values()] return [ { 'component': 'VForm', @@ -1908,7 +1906,7 @@ class BrushFlow(_PluginBase): # 获取所有站点的信息,并过滤掉不存在的站点 site_infos = [] for siteid in brush_config.brushsites: - siteinfo = self.siteoper.get(siteid) + siteinfo = self.site_oper.get(siteid) if siteinfo: site_infos.append(siteinfo) @@ -1943,13 +1941,13 @@ class BrushFlow(_PluginBase): """ 针对站点进行刷流 """ - siteinfo = self.siteoper.get(siteid) + siteinfo = self.site_oper.get(siteid) if not siteinfo: logger.warn(f"站点不存在:{siteid}") return True logger.info(f"开始获取站点 {siteinfo.name} 的新种子 ...") - torrents = self.torrentschain.browse(domain=siteinfo.domain) + torrents = self.torrents_chain.browse(domain=siteinfo.domain) if not torrents: logger.info(f"站点 {siteinfo.name} 没有获取到种子") return True @@ -2314,7 +2312,7 @@ class BrushFlow(_PluginBase): if need_delete_hashes: # 如果是QB,则重新汇报Tracker - if self.downloaderhelper.is_qbittorrent(service=self.service_info): + if self.downloader_helper.is_qbittorrent(service=self.service_info): self.__qb_torrents_reannounce(torrent_hashes=need_delete_hashes) # 删除种子 if downloader.delete_torrents(ids=need_delete_hashes, delete_file=True): @@ -2356,7 +2354,7 @@ class BrushFlow(_PluginBase): seeding_torrents_dict: Dict[str, Any]): brush_config = self.__get_brush_config() - if not self.downloaderhelper.is_qbittorrent(service=self.service_info): + if not self.downloader_helper.is_qbittorrent(service=self.service_info): logger.info("同步种子刷流标签记录目前仅支持qbittorrent") return @@ -3045,7 +3043,7 @@ class BrushFlow(_PluginBase): if not downloader: return None - if self.downloaderhelper.is_qbittorrent(service=self.service_info): + if self.downloader_helper.is_qbittorrent(service=self.service_info): # 限速值转为bytes up_speed = up_speed * 1024 if up_speed else None down_speed = down_speed * 1024 if down_speed else None @@ -3079,7 +3077,7 @@ class BrushFlow(_PluginBase): return torrent_hash return None - elif self.downloaderhelper.is_transmission(service=self.service_info): + elif self.downloader_helper.is_transmission(service=self.service_info): # 如果开启代理下载以及种子地址不是磁力地址,则请求种子到内存再传入下载器 if not torrent_content.startswith("magnet"): response = RequestUtils(cookies=cookies, @@ -3127,7 +3125,7 @@ class BrushFlow(_PluginBase): 获取种子hash """ try: - return torrent.get("hash") if self.downloaderhelper.is_qbittorrent(service=self.service_info) \ + return torrent.get("hash") if self.downloader_helper.is_qbittorrent(service=self.service_info) \ else torrent.hashString except Exception as e: print(str(e)) @@ -3144,7 +3142,7 @@ class BrushFlow(_PluginBase): all_hashes = [] for torrent in torrents: # 根据下载器类型获取Hash值 - hash_value = torrent.get("hash") if self.downloaderhelper.is_qbittorrent(service=self.service_info) \ + hash_value = torrent.get("hash") if self.downloader_helper.is_qbittorrent(service=self.service_info) \ else torrent.hashString if hash_value: all_hashes.append(hash_value) @@ -3159,7 +3157,7 @@ class BrushFlow(_PluginBase): """ try: return [str(tag).strip() for tag in torrent.get("tags").split(',')] \ - if self.downloaderhelper.is_qbittorrent(service=self.service_info) else torrent.labels or [] + if self.downloader_helper.is_qbittorrent(service=self.service_info) else torrent.labels or [] except Exception as e: print(str(e)) return [] @@ -3170,7 +3168,7 @@ class BrushFlow(_PluginBase): """ date_now = int(time.time()) # QB - if self.downloaderhelper.is_qbittorrent(service=self.service_info): + if self.downloader_helper.is_qbittorrent(service=self.service_info): """ { "added_on": 1693359031, @@ -3551,7 +3549,7 @@ class BrushFlow(_PluginBase): if not self._subscribe_infos: self._subscribe_infos = {} - subscribes = self.subscribeoper.list() + subscribes = self.subscribe_oper.list() if subscribes: # 遍历订阅 for subscribe in subscribes: @@ -3807,7 +3805,7 @@ class BrushFlow(_PluginBase): # 使用StringUtils工具类获取tracker的域名 domain = StringUtils.get_url_domain(tracker) - site_info = self.siteshelper.get_indexer(domain) + site_info = self.sites_helper.get_indexer(domain) if site_info: return site_info.get("id"), site_info.get("name") diff --git a/plugins.v2/downloadsitetag/__init__.py b/plugins.v2/downloadsitetag/__init__.py index 4a93b21..4c18550 100644 --- a/plugins.v2/downloadsitetag/__init__.py +++ b/plugins.v2/downloadsitetag/__init__.py @@ -21,8 +21,6 @@ from app.utils.string import StringUtils class DownloadSiteTag(_PluginBase): - # region 全局定义 - # 插件名称 plugin_name = "下载任务分类与标签" # 插件描述 @@ -49,7 +47,7 @@ class DownloadSiteTag(_PluginBase): # 私有属性 downloadhistory_oper = None sites_helper = None - downloaderhelper = None + downloader_helper = None _scheduler = None _enabled = False _onlyonce = False @@ -65,40 +63,9 @@ class DownloadSiteTag(_PluginBase): _category_anime = None _downloaders = None - # Property - - @property - def service_infos(self) -> Optional[Dict[str, ServiceInfo]]: - """ - 服务信息 - """ - if not self._downloaders: - logger.warning("尚未配置下载器,请检查配置") - return None - - services = self.downloaderhelper.get_services(name_filters=self._downloaders) - if not services: - logger.warning("获取下载器实例失败,请检查配置") - return None - - active_services = {} - for service_name, service_info in services.items(): - if service_info.instance.is_inactive(): - logger.warning(f"下载器 {service_name} 未连接,请检查配置") - else: - active_services[service_name] = service_info - - if not active_services: - logger.warning("没有已连接的下载器,请检查配置") - return None - - return active_services - - # endregion - def init_plugin(self, config: dict = None): self.downloadhistory_oper = DownloadHistoryOper() - self.downloaderhelper = DownloaderHelper() + self.downloader_helper = DownloaderHelper() self.sites_helper = SitesHelper() # 读取配置 if config: @@ -137,6 +104,33 @@ class DownloadSiteTag(_PluginBase): self._scheduler.print_jobs() self._scheduler.start() + @property + def service_infos(self) -> Optional[Dict[str, ServiceInfo]]: + """ + 服务信息 + """ + if not self._downloaders: + logger.warning("尚未配置下载器,请检查配置") + return None + + services = self.downloader_helper.get_services(name_filters=self._downloaders) + if not services: + logger.warning("获取下载器实例失败,请检查配置") + return None + + active_services = {} + for service_name, service_info in services.items(): + if service_info.instance.is_inactive(): + logger.warning(f"下载器 {service_name} 未连接,请检查配置") + else: + active_services[service_name] = service_info + + if not active_services: + logger.warning("没有已连接的下载器,请检查配置") + return None + + return active_services + def get_state(self) -> bool: return self._enabled @@ -668,7 +662,7 @@ class DownloadSiteTag(_PluginBase): 'model': 'downloaders', 'label': '下载器', 'items': [{"title": config.name, "value": config.name} - for config in self.downloaderhelper.get_configs().values()] + for config in self.downloader_helper.get_configs().values()] } } ] diff --git a/plugins.v2/mediaserverrefresh/__init__.py b/plugins.v2/mediaserverrefresh/__init__.py index 75b1b53..edd6cab 100644 --- a/plugins.v2/mediaserverrefresh/__init__.py +++ b/plugins.v2/mediaserverrefresh/__init__.py @@ -31,13 +31,18 @@ class MediaServerRefresh(_PluginBase): # 可使用的用户级别 auth_level = 1 - mediaserver_helper = None # 私有属性 + mediaserver_helper = None _enabled = False _delay = 0 _mediaservers = None - # Property + def init_plugin(self, config: dict = None): + self.mediaserver_helper = MediaServerHelper() + if config: + self._enabled = config.get("enabled") + self._delay = config.get("delay") or 0 + self._mediaservers = config.get("mediaservers") or [] @property def service_infos(self) -> Optional[Dict[str, ServiceInfo]]: @@ -66,13 +71,6 @@ class MediaServerRefresh(_PluginBase): return active_services - def init_plugin(self, config: dict = None): - self.mediaserver_helper = MediaServerHelper() - if config: - self._enabled = config.get("enabled") - self._delay = config.get("delay") or 0 - self._mediaservers = config.get("mediaservers") or [] - def get_state(self) -> bool: return self._enabled From 96e0a8e9fd9edaf9fc64b1df9f66c12ca613859d Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Thu, 10 Oct 2024 22:08:27 +0800 Subject: [PATCH 077/218] feat(MediaServerMsg): add support for v2 plugin --- plugins.v2/mediaservermsg/__init__.py | 113 ++++++++++++++++++++------ 1 file changed, 89 insertions(+), 24 deletions(-) diff --git a/plugins.v2/mediaservermsg/__init__.py b/plugins.v2/mediaservermsg/__init__.py index 315f1d1..2449962 100644 --- a/plugins.v2/mediaservermsg/__init__.py +++ b/plugins.v2/mediaservermsg/__init__.py @@ -1,13 +1,11 @@ import time -from typing import Any, List, Dict, Tuple +from typing import Any, List, Dict, Tuple, Optional from app.core.event import eventmanager, Event +from app.helper.mediaserver import MediaServerHelper from app.log import logger -from app.modules.emby import Emby -from app.modules.jellyfin import Jellyfin -from app.modules.plex import Plex from app.plugins import _PluginBase -from app.schemas import WebhookEventInfo +from app.schemas import WebhookEventInfo, ServiceInfo from app.schemas.types import EventType, MediaType, MediaImageType, NotificationType from app.utils.web import WebUtils @@ -32,13 +30,11 @@ class MediaServerMsg(_PluginBase): # 可使用的用户级别 auth_level = 1 - # 对像 - plex = None - emby = None - jellyfin = None - # 私有属性 + mediaserver_helper = None _enabled = False + _add_play_link = False + _mediaservers = None _types = [] _webhook_msg_keys = {} @@ -63,13 +59,38 @@ class MediaServerMsg(_PluginBase): } def init_plugin(self, config: dict = None): + self.mediaserver_helper = MediaServerHelper() if config: self._enabled = config.get("enabled") self._types = config.get("types") or [] - if self._enabled: - self.emby = Emby() - self.plex = Plex() - self.jellyfin = Jellyfin() + self._mediaservers = config.get("mediaservers") or [] + self._add_play_link = config.get("add_play_link", False) + + def service_infos(self, type_filter: Optional[str] = None) -> Optional[Dict[str, ServiceInfo]]: + """ + 服务信息 + """ + if not self._mediaservers: + logger.warning("尚未配置媒体服务器,请检查配置") + return None + + services = self.mediaserver_helper.get_services(type_filter=type_filter, name_filters=self._mediaservers) + if not services: + logger.warning("获取媒体服务器实例失败,请检查配置") + return None + + active_services = {} + for service_name, service_info in services.items(): + if service_info.instance.is_inactive(): + logger.warning(f"媒体服务器 {service_name} 未连接,请检查配置") + else: + active_services[service_name] = service_info + + if not active_services: + logger.warning("没有已连接的媒体服务器,请检查配置") + return None + + return active_services def get_state(self) -> bool: return self._enabled @@ -116,6 +137,47 @@ class MediaServerMsg(_PluginBase): } } ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'add_play_link', + 'label': '添加播放链接', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': True, + 'chips': True, + 'clearable': True, + 'model': 'mediaservers', + 'label': '媒体服务器', + 'items': [{"title": config.name, "value": config.name} + for config in self.mediaserver_helper.get_configs().values()] + } + } + ] } ] }, @@ -156,7 +218,7 @@ class MediaServerMsg(_PluginBase): 'props': { 'type': 'info', 'variant': 'tonal', - 'text': '需要设置媒体服务器Webhook,回调相对路径为 /api/v1/webhook?token=moviepilot(3001端口),其中 moviepilot 为设置的 API_TOKEN。' + 'text': '需要设置媒体服务器Webhook,回调相对路径为 /api/v1/webhook?token=API_TOKEN&source=媒体服务器名(3001端口),其中 API_TOKEN 为设置的 API_TOKEN。' } } ] @@ -253,15 +315,18 @@ class MediaServerMsg(_PluginBase): if not image_url: image_url = self._webhook_images.get(event_info.channel) - # 获取链接地址 - if event_info.channel == "emby": - play_link = self.emby.get_play_url(event_info.item_id) - elif event_info.channel == "plex": - play_link = self.plex.get_play_url(event_info.item_id) - elif event_info.channel == "jellyfin": - play_link = self.jellyfin.get_play_url(event_info.item_id) - else: - play_link = None + play_link = None + if self._add_play_link: + if event_info.server_name: + service = self.service_infos().get(event_info.server_name) + if service: + play_link = service.instance.get_play_url(event_info.item_id) + elif event_info.channel: + services = self.mediaserver_helper.get_services(type_filter=event_info.channel) + for service in services.values(): + play_link = service.instance.get_play_url(event_info.item_id) + if play_link: + break if str(event_info.event) == "playback.stop": # 停止播放消息,添加到过期字典 From c53d1e666276a6f8fc2b6455335aaf807662ac42 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Thu, 10 Oct 2024 22:18:08 +0800 Subject: [PATCH 078/218] feat(MediaServerMsg): v1.4 --- package.v2.json | 12 ++++++++++++ plugins.v2/mediaservermsg/__init__.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/package.v2.json b/package.v2.json index 1dd5632..9d33f37 100644 --- a/package.v2.json +++ b/package.v2.json @@ -59,6 +59,18 @@ "v1.3": "MoviePilot V2 版本媒体库服务器刷新插件" } }, + "MediaServerMsg": { + "name": "媒体库服务器通知", + "description": "发送Emby/Jellyfin/Plex服务器的播放、入库等通知消息。", + "labels": "消息通知,媒体库", + "version": "1.4", + "icon": "mediaplay.png", + "author": "jxxghp", + "level": 1, + "history": { + "v1.4": "MoviePilot V2 版本媒体库服务器通知插件" + } + }, "ChatGPT": { "name": "ChatGPT", "description": "消息交互支持与ChatGPT对话。", diff --git a/plugins.v2/mediaservermsg/__init__.py b/plugins.v2/mediaservermsg/__init__.py index 2449962..f927b6f 100644 --- a/plugins.v2/mediaservermsg/__init__.py +++ b/plugins.v2/mediaservermsg/__init__.py @@ -18,7 +18,7 @@ class MediaServerMsg(_PluginBase): # 插件图标 plugin_icon = "mediaplay.png" # 插件版本 - plugin_version = "1.3" + plugin_version = "1.4" # 插件作者 plugin_author = "jxxghp" # 作者主页 From cdc1079af695bc9591182a0a353af1024de4e973 Mon Sep 17 00:00:00 2001 From: Attente <19653207+wikrin@users.noreply.github.com> Date: Fri, 11 Oct 2024 01:37:40 +0800 Subject: [PATCH 079/218] =?UTF-8?q?feat:=20=E8=AE=A2=E9=98=85=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E7=9A=84=E6=9D=A1=E7=9B=AE=E6=B1=87=E6=80=BB=E8=BE=93?= =?UTF-8?q?=E5=87=BA=E5=88=B0=E6=97=A5=E5=BF=97=20feat:=20=E5=AF=B9?= =?UTF-8?q?=E5=B7=B2=E5=AD=98=E5=9C=A8=E7=9A=84=E6=9D=A1=E7=9B=AE=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0Bangumi=E6=9D=A1=E7=9B=AEID=20fix:=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E9=80=9A=E8=BF=87=E5=85=B6=E4=BB=96=E6=96=B9=E5=BC=8F?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=9A=84=E8=AE=A2=E9=98=85=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 9 +++--- plugins/bangumicoll/__init__.py | 57 ++++++++++++++++++++++----------- 2 files changed, 43 insertions(+), 23 deletions(-) diff --git a/package.json b/package.json index b648f0f..07ae074 100644 --- a/package.json +++ b/package.json @@ -3,15 +3,14 @@ "name": "Bangumi收藏订阅", "description": "Bangumi用户收藏添加到订阅", "labels": "订阅", - "version": "1.2.1", + "version": "1.2.2", "icon": "https://raw.githubusercontent.com/wikrin/MoviePilot-Plugins/main/icons/bangumi_b.png", "author": "Attente", - "level": 2, + "level": 1, "history": { + "v1.2.2": "新增: 订阅添加失败总览 修复: 其他方式添加的订阅反复添加的问题", "v1.2.1": "修复tmdb没有查询到条目导致插件崩溃的问题", - "v1.2": "修复已完成订阅条目重复添加的问题", - "v1.1": "新增根据收藏状态移除由此插件添加的订阅", - "v1.0": "将bangumi用户收藏添加到 MP 订阅,部分功能未实现" + "v1.1": "新增根据收藏状态移除由此插件添加的订阅" } } } diff --git a/plugins/bangumicoll/__init__.py b/plugins/bangumicoll/__init__.py index 01d48ca..3f6d76a 100644 --- a/plugins/bangumicoll/__init__.py +++ b/plugins/bangumicoll/__init__.py @@ -29,7 +29,7 @@ class BangumiColl(_PluginBase): # 插件图标 plugin_icon = "https://raw.githubusercontent.com/wikrin/MoviePilot-Plugins/main/icons/bangumi_b.png" # 插件版本 - plugin_version = "1.2.1" + plugin_version = "1.2.2" # 插件作者 plugin_author = "Attente" # 作者主页 @@ -39,7 +39,7 @@ class BangumiColl(_PluginBase): # 加载顺序 plugin_order = 23 # 可使用的用户级别 - auth_level = 2 + auth_level = 1 # 私有变量 _scheduler: Optional[BackgroundScheduler] = None @@ -325,8 +325,7 @@ class BangumiColl(_PluginBase): def get_page(self): pass - # 注册定时任务 - + # 注册定时任务 def get_service(self) -> List[Dict[str, Any]]: """ 注册插件公共服务 @@ -418,11 +417,11 @@ class BangumiColl(_PluginBase): date = item['subject'].get('date') ## 这里在后面添加排除规则 items.update({subject_id: {"name": name, "name_cn": name_cn, "date": date}}) - ## 获取此插件添加的订阅 + ## 获取已添加的订阅 db_sub = { i.bangumiid: i.id for i in self.subscribechain.subscribeoper.list() - if i.bangumiid and i.username == "Bangumi订阅" + if i.bangumiid } ## 获取历史订阅 db_hist = self.get_subscribe_history() @@ -447,21 +446,28 @@ class BangumiColl(_PluginBase): # bgm条目id为键,bgm条目信息为值 new_sub = {i: items[i] for i in new_sub} logger.info(f"开始添加订阅...") - self.add_subscribe(new_sub) + msg = self.add_subscribe(new_sub) + if msg: + # 订阅失败的条目打印至日志 + logger.info("\n".ljust(49, ' ').join(list(msg.values()))) # 结束 logger.info(f"Bangumi收藏订阅执行完成") - # 添加订阅 - - def add_subscribe(self, items: Dict[int, Dict[str, Any]]): + # 添加订阅 + def add_subscribe(self, items: Dict[int, Dict[str, Any]]) -> Dict: ''' 添加订阅 :param items: bgm条目id为键,bgm条目信息为值 ''' + # 记录失败条目 + fail_items = {} for subject_id, item in items.items(): meta = MetaInfo(item.get("name_cn")) if not meta.name: + fail_items.update( + {subject_id: f"{item.get('name_cn')} 未识别到有效数据"} + ) logger.warn(f"{item.get('name_cn')} 未识别到有效数据") continue # 设置默认年份, 避免出现多个结果使用早期条目 @@ -469,6 +475,9 @@ class BangumiColl(_PluginBase): mediainfo: MediaInfo = self.chain.recognize_media(meta=meta) # 识别失败则跳过 if not mediainfo: + fail_items.update( + {subject_id: f"{item.get('name_cn')} 媒体信息识别失败"} + ) continue # 对比Bangumi和tmdb的信息确定季度 for info in mediainfo.season_info: @@ -481,19 +490,28 @@ class BangumiColl(_PluginBase): # 更新集数信息 mediainfo.number_of_episodes = info.get("episode_count") - # 检查是否已经订阅 - subflag = self.subscribechain.exists(mediainfo=mediainfo, meta=meta) - if subflag: - logger.info(f'{mediainfo.title_year} {meta.season} 正在订阅中') + # 检查是否已经订阅, 添加bangumiid + sid = self.subscribeoper.list_by_tmdbid( + mediainfo.tmdb_id, mediainfo.number_of_seasons + ) + if sid: + logger.info(f"{mediainfo.title_year} {meta.season} 正在订阅中") + if len(sid) == 1: + self.subscribeoper.update( + sid=sid[0].id, payload={"bangumiid": subject_id} + ) + logger.info( + f"{mediainfo.title_year} {meta.season} Bangumi条目id更新成功" + ) continue # 额外参数 kwargs = { "save_path": self._save_path, - "sites": str(self._sites), + "sites": self._sites, } # 添加到订阅 - self.subscribechain.add( + sid, msg = self.subscribechain.add( title=mediainfo.title, year=mediainfo.year, mtype=mediainfo.type, @@ -504,9 +522,12 @@ class BangumiColl(_PluginBase): username="Bangumi订阅", **kwargs, ) + if not sid: + fail_items.update({subject_id: f"{item.get('name_cn')} {msg}"}) + continue + return fail_items - # 移除订阅 - + # 移除订阅 def delete_subscribe(self, del_items: Dict[int, int]): ''' 删除订阅 From edd3697028f86cdcfd771a3f37ecedbb13fad03d Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Fri, 11 Oct 2024 10:19:49 +0800 Subject: [PATCH 080/218] feat(TorrentTransfer): sync v1 plugin --- plugins.v2/torrenttransfer/__init__.py | 51 +++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/plugins.v2/torrenttransfer/__init__.py b/plugins.v2/torrenttransfer/__init__.py index fa22714..87dd38f 100644 --- a/plugins.v2/torrenttransfer/__init__.py +++ b/plugins.v2/torrenttransfer/__init__.py @@ -27,7 +27,7 @@ class TorrentTransfer(_PluginBase): # 插件图标 plugin_icon = "seed.png" # 插件版本 - plugin_version = "1.5" + plugin_version = "1.6" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -55,6 +55,7 @@ class TorrentTransfer(_PluginBase): _notify = False _nolabels = None _includelabels = None + _includecategory = None _nopaths = None _deletesource = False _deleteduplicate = False @@ -79,6 +80,7 @@ class TorrentTransfer(_PluginBase): self._notify = config.get("notify") self._nolabels = config.get("nolabels") self._includelabels = config.get("includelabels") + self._includecategory = config.get("includecategory") self._frompath = config.get("frompath") self._topath = config.get("topath") self._fromdownloader = config.get("fromdownloader") @@ -128,6 +130,7 @@ class TorrentTransfer(_PluginBase): "notify": self._notify, "nolabels": self._nolabels, "includelabels": self._includelabels, + "includecategory": self._includecategory, "frompath": self._frompath, "topath": self._topath, "fromdownloader": self._fromdownloader, @@ -250,7 +253,7 @@ class TorrentTransfer(_PluginBase): 'component': 'VCol', 'props': { 'cols': 12, - 'md': 4 + 'md': 6 }, 'content': [ { @@ -262,12 +265,28 @@ class TorrentTransfer(_PluginBase): } } ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'includecategory', + 'label': '转移种子分类', + } + } + ] }, { 'component': 'VCol', 'props': { 'cols': 12, - 'md': 4 + 'md': 6 }, 'content': [ { @@ -282,7 +301,7 @@ class TorrentTransfer(_PluginBase): 'component': 'VCol', 'props': { 'cols': 12, - 'md': 4 + 'md': 6 }, 'content': [ { @@ -494,6 +513,7 @@ class TorrentTransfer(_PluginBase): "cron": "", "nolabels": "", "includelabels": "", + "includecategory": "", "frompath": "", "topath": "", "fromdownloader": "", @@ -600,12 +620,19 @@ class TorrentTransfer(_PluginBase): # 获取种子标签 torrent_labels = self.__get_label(torrent, downloader) - + # 获取种子分类 + torrent_category = self.__get_category(torrent, downloader) # 种子为无标签,则进行规范化 is_torrent_labels_empty = torrent_labels == [''] or torrent_labels == [] or torrent_labels is None if is_torrent_labels_empty: torrent_labels = [] - + + # 如果分类项存在数值,则进行判断 + if self._includecategory: + # 排除未标记的分类 + if torrent_category not in self._includecategory.split(','): + logger.info(f"种子 {hash_str} 不含有转移分类 {self._includecategory},跳过 ...") + continue #根据设置决定是否转移无标签的种子 if is_torrent_labels_empty: if not self._transferemptylabel: @@ -870,6 +897,18 @@ class TorrentTransfer(_PluginBase): print(str(e)) return [] + @staticmethod + def __get_category(torrent: Any, dl_type: str): + """ + 获取种子分类 + """ + try: + return torrent.get("category").strip() \ + if dl_type == "qbittorrent" else "" + except Exception as e: + print(str(e)) + return "" + @staticmethod def __get_save_path(torrent: Any, dl_type: str): """ From f9ba0489e25869282ab8d694549ab8156ef1f662 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Fri, 11 Oct 2024 14:41:02 +0800 Subject: [PATCH 081/218] feat(TorrentTransfer): add support for v2 plugin --- plugins.v2/torrenttransfer/__init__.py | 250 ++++++++++++++----------- 1 file changed, 136 insertions(+), 114 deletions(-) diff --git a/plugins.v2/torrenttransfer/__init__.py b/plugins.v2/torrenttransfer/__init__.py index 87dd38f..35833f3 100644 --- a/plugins.v2/torrenttransfer/__init__.py +++ b/plugins.v2/torrenttransfer/__init__.py @@ -2,7 +2,7 @@ import os from datetime import datetime, timedelta from pathlib import Path from threading import Event -from typing import Any, List, Dict, Tuple, Optional +from typing import Any, List, Dict, Tuple, Optional, Union import pytz from apscheduler.schedulers.background import BackgroundScheduler @@ -10,12 +10,13 @@ from apscheduler.triggers.cron import CronTrigger from bencode import bdecode, bencode from app.core.config import settings +from app.helper.downloader import DownloaderHelper from app.helper.torrent import TorrentHelper from app.log import logger from app.modules.qbittorrent import Qbittorrent from app.modules.transmission import Transmission from app.plugins import _PluginBase -from app.schemas import NotificationType +from app.schemas import NotificationType, ServiceInfo from app.utils.string import StringUtils @@ -41,9 +42,8 @@ class TorrentTransfer(_PluginBase): # 私有属性 _scheduler = None - qb = None - tr = None - torrent = None + torrent_helper = None + downloader_helper = None # 开关 _enabled = False _cron = None @@ -68,10 +68,11 @@ class TorrentTransfer(_PluginBase): _recheck_torrents = {} _is_recheck_running = False # 任务标签 - _torrent_tags = ["已整理", "转移做种"] + _torrent_tags = [] def init_plugin(self, config: dict = None): - self.torrent = TorrentHelper() + self.torrent_helper = TorrentHelper() + self.downloader_helper = DownloaderHelper() # 读取配置 if config: self._enabled = config.get("enabled") @@ -91,22 +92,20 @@ class TorrentTransfer(_PluginBase): self._nopaths = config.get("nopaths") self._autostart = config.get("autostart") self._transferemptylabel = config.get("transferemptylabel") + self._add_torrent_tags = config.get("add_torrent_tags") or "" + self._torrent_tags = self._add_torrent_tags.strip().split(",") # 停止现有任务 self.stop_service() # 启动定时任务 & 立即运行一次 if self.get_state() or self._onlyonce: - self.qb = Qbittorrent() - self.tr = Transmission() - # 检查配置 - if self._fromtorrentpath and not Path(self._fromtorrentpath).exists(): - logger.error(f"源下载器种子文件保存路径不存在:{self._fromtorrentpath}") - self.systemmessage.put(f"源下载器种子文件保存路径不存在:{self._fromtorrentpath}", title="自动转移做种") - return - if self._fromdownloader == self._todownloader: - logger.error(f"源下载器和目的下载器不能相同") - self.systemmessage.put(f"源下载器和目的下载器不能相同", title="自动转移做种") + if not self.__validate_config(): + self._enabled = False + self._onlyonce = False + config["enabled"] = self._enabled + config["onlyonce"] = self._onlyonce + self.update_config(config=config) return # 定时服务 @@ -114,40 +113,40 @@ class TorrentTransfer(_PluginBase): if self._autostart: # 追加种子校验服务 - self._scheduler.add_job(self.check_recheck, 'interval', minutes=3) + self._scheduler.add_job(self.check_recheck, 'interval', minutes=0.5) if self._onlyonce: logger.info(f"转移做种服务启动,立即运行一次") self._scheduler.add_job(self.transfer, 'date', run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta( seconds=3)) - # 关闭一次性开关 self._onlyonce = False - self.update_config({ - "enabled": self._enabled, - "onlyonce": self._onlyonce, - "cron": self._cron, - "notify": self._notify, - "nolabels": self._nolabels, - "includelabels": self._includelabels, - "includecategory": self._includecategory, - "frompath": self._frompath, - "topath": self._topath, - "fromdownloader": self._fromdownloader, - "todownloader": self._todownloader, - "deletesource": self._deletesource, - "deleteduplicate": self._deleteduplicate, - "fromtorrentpath": self._fromtorrentpath, - "nopaths": self._nopaths, - "autostart": self._autostart, - "transferemptylabel": self._transferemptylabel - }) - + config["onlyonce"] = self._onlyonce + self.update_config(config=config) # 启动服务 if self._scheduler.get_jobs(): self._scheduler.print_jobs() self._scheduler.start() + def service_info(self, name: str) -> Optional[ServiceInfo]: + """ + 服务信息 + """ + if not name: + logger.warning("尚未配置下载器,请检查配置") + return None + + service = self.downloader_helper.get_service(name) + if not service or not service.instance: + logger.warning(f"获取下载器 {name} 实例失败,请检查配置") + return None + + if service.instance.is_inactive(): + logger.warning(f"下载器 {name} 未连接,请检查配置") + return None + + return service + def get_state(self): return True if self._enabled \ and self._cron \ @@ -189,6 +188,8 @@ class TorrentTransfer(_PluginBase): """ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 """ + downloader_options = [{"title": config.name, "value": config.name} + for config in self.downloader_helper.get_configs().values()] return [ { 'component': 'VForm', @@ -253,7 +254,7 @@ class TorrentTransfer(_PluginBase): 'component': 'VCol', 'props': { 'cols': 12, - 'md': 6 + 'md': 4 }, 'content': [ { @@ -266,11 +267,28 @@ class TorrentTransfer(_PluginBase): } ] }, - { + { 'component': 'VCol', 'props': { 'cols': 12, - 'md': 6 + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'add_torrent_tags', + 'label': '添加种子标签', + 'placeholder': '已整理,转移做种' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 }, 'content': [ { @@ -329,10 +347,7 @@ class TorrentTransfer(_PluginBase): 'props': { 'model': 'fromdownloader', 'label': '源下载器', - 'items': [ - {'title': 'Qbittorrent', 'value': 'qbittorrent'}, - {'title': 'Transmission', 'value': 'transmission'} - ] + 'items': downloader_options } } ] @@ -387,10 +402,7 @@ class TorrentTransfer(_PluginBase): 'props': { 'model': 'todownloader', 'label': '目的下载器', - 'items': [ - {'title': 'Qbittorrent', 'value': 'qbittorrent'}, - {'title': 'Transmission', 'value': 'transmission'} - ] + 'items': downloader_options } } ] @@ -523,56 +535,61 @@ class TorrentTransfer(_PluginBase): "fromtorrentpath": "", "nopaths": "", "autostart": True, - "transferemptylabel": False + "transferemptylabel": False, + "add_torrent_tags": "已整理,转移做种" } def get_page(self) -> List[dict]: pass - def __get_downloader(self, dtype: str): + def __validate_config(self) -> bool: """ - 根据类型返回下载器实例 + 校验配置 """ - if dtype == "qbittorrent": - return self.qb - elif dtype == "transmission": - return self.tr - else: - return None + # 检查配置 + if self._fromtorrentpath and not Path(self._fromtorrentpath).exists(): + logger.error(f"源下载器种子文件保存路径不存在:{self._fromtorrentpath}") + self.systemmessage.put(f"源下载器种子文件保存路径不存在:{self._fromtorrentpath}", title="自动转移做种") + return False + if self._fromdownloader == self._todownloader: + logger.error(f"源下载器和目的下载器不能相同") + self.systemmessage.put(f"源下载器和目的下载器不能相同", title="自动转移做种") + return False + return True - def __download(self, downloader: str, content: bytes, + def __download(self, downloader: Union[Qbittorrent, Transmission], content: bytes, save_path: str) -> Optional[str]: """ 添加下载任务 """ - if downloader == "qbittorrent": + if self.downloader_helper.is_qbittorrent(instance=downloader): # 生成随机Tag tag = StringUtils.generate_random_str(10) - state = self.qb.add_torrent(content=content, - download_dir=save_path, - is_paused=True, - tag=["已整理", "转移做种", tag]) + state = downloader.add_torrent(content=content, + download_dir=save_path, + is_paused=True, + tag=self._torrent_tags + [tag]) if not state: return None else: # 获取种子Hash - torrent_hash = self.qb.get_torrent_id_by_tag(tags=tag) + torrent_hash = downloader.get_torrent_id_by_tag(tags=tag) if not torrent_hash: logger.error(f"{downloader} 下载任务添加成功,但获取任务信息失败!") return None return torrent_hash - elif downloader == "transmission": + elif self.downloader_helper.is_transmission(instance=downloader): # 添加任务 - torrent = self.tr.add_torrent(content=content, - download_dir=save_path, - is_paused=True, - labels=["已整理", "转移做种"]) + torrent = downloader.add_torrent(content=content, + download_dir=save_path, + is_paused=True, + labels=self._torrent_tags) if not torrent: return None else: return torrent.hashString - logger.error(f"不支持的下载器:{downloader}") + logger.error(f"不支持的下载器类型") return None def transfer(self): @@ -581,18 +598,22 @@ class TorrentTransfer(_PluginBase): """ logger.info("开始转移做种任务 ...") - # 源下载器 - downloader = self._fromdownloader - # 目的下载器 - todownloader = self._todownloader + if not self.__validate_config(): + return - # 获取下载器中已完成的种子 - downloader_obj = self.__get_downloader(downloader) - torrents = downloader_obj.get_completed_torrents() + from_service = self.service_info(self._fromdownloader) + from_downloader: Optional[Union[Qbittorrent, Transmission]] = from_service.instance if from_service else None + to_service = self.service_info(self._todownloader) + to_downloader: Optional[Union[Qbittorrent, Transmission]] = to_service.instance if to_service else None + + if not from_downloader or not to_downloader: + return + + torrents = from_downloader.get_completed_torrents() if torrents: - logger.info(f"下载器 {downloader} 已完成种子数:{len(torrents)}") + logger.info(f"下载器 {from_service.name} 已完成种子数:{len(torrents)}") else: - logger.info(f"下载器 {downloader} 没有已完成种子") + logger.info(f"下载器 {from_service.name} 没有已完成种子") return # 过滤种子,记录保存目录 @@ -603,9 +624,9 @@ class TorrentTransfer(_PluginBase): return # 获取种子hash - hash_str = self.__get_hash(torrent, downloader) + hash_str = self.__get_hash(torrent, from_service.type) # 获取保存路径 - save_path = self.__get_save_path(torrent, downloader) + save_path = self.__get_save_path(torrent, from_service.type) if self._nopaths and save_path: # 过滤不需要转移的路径 @@ -619,9 +640,9 @@ class TorrentTransfer(_PluginBase): continue # 获取种子标签 - torrent_labels = self.__get_label(torrent, downloader) + torrent_labels = self.__get_label(torrent, from_service.type) # 获取种子分类 - torrent_category = self.__get_category(torrent, downloader) + torrent_category = self.__get_category(torrent, from_service.type) # 种子为无标签,则进行规范化 is_torrent_labels_empty = torrent_labels == [''] or torrent_labels == [] or torrent_labels is None if is_torrent_labels_empty: @@ -633,7 +654,7 @@ class TorrentTransfer(_PluginBase): if torrent_category not in self._includecategory.split(','): logger.info(f"种子 {hash_str} 不含有转移分类 {self._includecategory},跳过 ...") continue - #根据设置决定是否转移无标签的种子 + # 根据设置决定是否转移无标签的种子 if is_torrent_labels_empty: if not self._transferemptylabel: continue @@ -690,13 +711,12 @@ class TorrentTransfer(_PluginBase): continue # 查询hash值是否已经在目的下载器中 - todownloader_obj = self.__get_downloader(todownloader) - torrent_info, _ = todownloader_obj.get_torrents(ids=[torrent_item.get('hash')]) + torrent_info, _ = to_downloader.get_torrents(ids=[torrent_item.get('hash')]) if torrent_info: # 删除重复的源种子,不能删除文件! if self._deleteduplicate: logger.info(f"删除重复的源下载器任务(不含文件):{torrent_item.get('hash')} ...") - downloader_obj.delete_torrents(delete_file=False, ids=[torrent_item.get('hash')]) + to_downloader.delete_torrents(delete_file=False, ids=[torrent_item.get('hash')]) del_dup += 1 else: logger.info(f"{torrent_item.get('hash')} 已在目的下载器中,跳过 ...") @@ -715,7 +735,7 @@ class TorrentTransfer(_PluginBase): continue # 如果源下载器是QB检查是否有Tracker,没有的话额外获取 - if downloader == "qbittorrent": + if self.downloader_helper.is_qbittorrent(from_service): # 读取种子内容、解析种子文件 content = torrent_file.read_bytes() if not content: @@ -764,8 +784,8 @@ class TorrentTransfer(_PluginBase): continue # 发送到另一个下载器中下载:默认暂停、传输下载路径、关闭自动管理模式 - logger.info(f"添加转移做种任务到下载器 {todownloader}:{torrent_file}") - download_id = self.__download(downloader=todownloader, + logger.info(f"添加转移做种任务到下载器 {to_service.name}:{torrent_file}") + download_id = self.__download(downloader=to_downloader, content=torrent_file.read_bytes(), save_path=download_dir) if not download_id: @@ -778,28 +798,28 @@ class TorrentTransfer(_PluginBase): logger.info(f"成功添加转移做种任务,种子文件:{torrent_file}") # TR会自动校验,QB需要手动校验 - if todownloader == "qbittorrent": + if self.downloader_helper.is_qbittorrent(to_service): logger.info(f"qbittorrent 开始校验 {download_id} ...") - todownloader_obj.recheck_torrents(ids=[download_id]) + to_downloader.recheck_torrents(ids=[download_id]) # 追加校验任务 logger.info(f"添加校验检查任务:{download_id} ...") - if not self._recheck_torrents.get(todownloader): - self._recheck_torrents[todownloader] = [] - self._recheck_torrents[todownloader].append(download_id) + if not self._recheck_torrents.get(to_service.name): + self._recheck_torrents[to_service.name] = [] + self._recheck_torrents[to_service.name].append(download_id) # 删除源种子,不能删除文件! if self._deletesource: logger.info(f"删除源下载器任务(不含文件):{torrent_item.get('hash')} ...") - downloader_obj.delete_torrents(delete_file=False, ids=[torrent_item.get('hash')]) + from_downloader.delete_torrents(delete_file=False, ids=[torrent_item.get('hash')]) # 成功计数 success += 1 # 插入转种记录 - history_key = "%s-%s" % (self._fromdownloader, torrent_item.get('hash')) + history_key = f"{from_service.name}-{torrent_item.get('hash')}" self.save_data(key=history_key, value={ - "to_download": self._todownloader, + "to_download": to_service.name, "to_download_id": download_id, "delete_source": self._deletesource, "delete_duplicate": self._deleteduplicate, @@ -831,46 +851,48 @@ class TorrentTransfer(_PluginBase): return # 校验下载器 - downloader = self._todownloader + to_service = self.service_info(self._todownloader) + to_downloader: Optional[Union[Qbittorrent, Transmission]] = to_service.instance if to_service else None + + if not to_downloader: + return # 需要检查的种子 - recheck_torrents = self._recheck_torrents.get(downloader, []) + recheck_torrents = self._recheck_torrents.get(to_service.name, []) if not recheck_torrents: return - logger.info(f"开始检查下载器 {downloader} 的校验任务 ...") + logger.info(f"开始检查下载器 {to_service.name} 的校验任务 ...") # 运行状态 self._is_recheck_running = True - # 获取任务 - downloader_obj = self.__get_downloader(downloader) - torrents, _ = downloader_obj.get_torrents(ids=recheck_torrents) + torrents, _ = to_downloader.get_torrents(ids=recheck_torrents) if torrents: # 可做种的种子 can_seeding_torrents = [] for torrent in torrents: # 获取种子hash - hash_str = self.__get_hash(torrent, downloader) + hash_str = self.__get_hash(torrent, to_service.type) # 判断是否可做种 - if self.__can_seeding(torrent, downloader): + if self.__can_seeding(torrent, to_service.type): can_seeding_torrents.append(hash_str) if can_seeding_torrents: logger.info(f"共 {len(can_seeding_torrents)} 个任务校验完成,开始做种") # 开始做种 - downloader_obj.start_torrents(ids=can_seeding_torrents) + to_downloader.start_torrents(ids=can_seeding_torrents) # 去除已经处理过的种子 - self._recheck_torrents[downloader] = list( + self._recheck_torrents[to_service.name] = list( set(recheck_torrents).difference(set(can_seeding_torrents))) else: logger.info(f"没有新的任务校验完成,将在下次个周期继续检查 ...") elif torrents is None: - logger.info(f"下载器 {downloader} 查询校验任务失败,将在下次继续查询 ...") + logger.info(f"下载器 {to_service.name} 查询校验任务失败,将在下次继续查询 ...") else: - logger.info(f"下载器 {downloader} 中没有需要检查的校验任务,清空待处理列表") - self._recheck_torrents[downloader] = [] + logger.info(f"下载器 {to_service.name} 中没有需要检查的校验任务,清空待处理列表") + self._recheck_torrents[to_service.name] = [] self._is_recheck_running = False @@ -908,7 +930,7 @@ class TorrentTransfer(_PluginBase): except Exception as e: print(str(e)) return "" - + @staticmethod def __get_save_path(torrent: Any, dl_type: str): """ From cad8897cb0b6c9d5ad5bdba846c9b75687ac578a Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Fri, 11 Oct 2024 14:42:47 +0800 Subject: [PATCH 082/218] feat(TorrentTransfer): v1.7 --- package.v2.json | 12 ++++++++++++ plugins.v2/torrenttransfer/__init__.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/package.v2.json b/package.v2.json index 9d33f37..fc7f586 100644 --- a/package.v2.json +++ b/package.v2.json @@ -82,5 +82,17 @@ "history": { "v2.0": "适配MoviePilot V2 版本,采用链式事件机制" } + }, + "TorrentTransfer": { + "name": "自动转移做种", + "description": "定期转移下载器中的做种任务到另一个下载器。", + "labels": "做种", + "version": "1.7", + "icon": "seed.png", + "author": "jxxghp", + "level": 2, + "history": { + "v1.7": "MoviePilot V2 版本媒体库服务器通知插件" + } } } \ No newline at end of file diff --git a/plugins.v2/torrenttransfer/__init__.py b/plugins.v2/torrenttransfer/__init__.py index 35833f3..0a9d9e5 100644 --- a/plugins.v2/torrenttransfer/__init__.py +++ b/plugins.v2/torrenttransfer/__init__.py @@ -28,7 +28,7 @@ class TorrentTransfer(_PluginBase): # 插件图标 plugin_icon = "seed.png" # 插件版本 - plugin_version = "1.6" + plugin_version = "1.7" # 插件作者 plugin_author = "jxxghp" # 作者主页 From 5f04973c911d6a950aab8b10bbca43030ae969fa Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Fri, 11 Oct 2024 15:29:35 +0800 Subject: [PATCH 083/218] fix(TorrentTransfer): support category transfer --- package.json | 5 +- plugins.v2/torrenttransfer/__init__.py | 3 +- plugins/torrenttransfer/__init__.py | 98 ++++++++++++++++---------- 3 files changed, 66 insertions(+), 40 deletions(-) diff --git a/package.json b/package.json index c06de77..e7c679b 100644 --- a/package.json +++ b/package.json @@ -345,12 +345,13 @@ "name": "自动转移做种", "description": "定期转移下载器中的做种任务到另一个下载器。", "labels": "做种", - "version": "1.5", + "version": "1.6", "icon": "seed.png", "author": "jxxghp", "level": 2, "history": { - "v1.5": "修复在转移时只保留了第一个tracker,导致红种问题。此修复确保保留所有的tracker,以提高在不同网络条件下的可达性。", + "v1.6": "支持根据种子类别进行转移,并允许修改转移后的默认标签", + "v1.5": "修复在转移时只保留了第一个tracker,导致红种问题。此修复确保保留所有的tracker,以提高在不同网络条件下的可达性", "v1.4": "支持自动删除源下载器在目的下载器中存在的种子" } }, diff --git a/plugins.v2/torrenttransfer/__init__.py b/plugins.v2/torrenttransfer/__init__.py index 0a9d9e5..dd520d4 100644 --- a/plugins.v2/torrenttransfer/__init__.py +++ b/plugins.v2/torrenttransfer/__init__.py @@ -62,6 +62,7 @@ class TorrentTransfer(_PluginBase): _fromtorrentpath = None _autostart = False _transferemptylabel = False + _add_torrent_tags = None # 退出事件 _event = Event() # 待检查种子清单 @@ -93,7 +94,7 @@ class TorrentTransfer(_PluginBase): self._autostart = config.get("autostart") self._transferemptylabel = config.get("transferemptylabel") self._add_torrent_tags = config.get("add_torrent_tags") or "" - self._torrent_tags = self._add_torrent_tags.strip().split(",") + self._torrent_tags = self._add_torrent_tags.strip().split(",") if self._add_torrent_tags else [] # 停止现有任务 self.stop_service() diff --git a/plugins/torrenttransfer/__init__.py b/plugins/torrenttransfer/__init__.py index 0ad8316..2a1eba3 100644 --- a/plugins/torrenttransfer/__init__.py +++ b/plugins/torrenttransfer/__init__.py @@ -62,13 +62,14 @@ class TorrentTransfer(_PluginBase): _fromtorrentpath = None _autostart = False _transferemptylabel = False + _add_torrent_tags = None # 退出事件 _event = Event() # 待检查种子清单 _recheck_torrents = {} _is_recheck_running = False # 任务标签 - _torrent_tags = ["已整理", "转移做种"] + _torrent_tags = [] def init_plugin(self, config: dict = None): self.torrent = TorrentHelper() @@ -91,6 +92,12 @@ class TorrentTransfer(_PluginBase): self._nopaths = config.get("nopaths") self._autostart = config.get("autostart") self._transferemptylabel = config.get("transferemptylabel") + self._add_torrent_tags = config.get("add_torrent_tags") + if self._add_torrent_tags is None: + self._add_torrent_tags = "已整理,转移做种" + config["add_torrent_tags"] = self._add_torrent_tags + self.update_config(config=config) + self._torrent_tags = self._add_torrent_tags.strip().split(",") if self._add_torrent_tags else [] # 停止现有任务 self.stop_service() @@ -99,14 +106,12 @@ class TorrentTransfer(_PluginBase): if self.get_state() or self._onlyonce: self.qb = Qbittorrent() self.tr = Transmission() - # 检查配置 - if self._fromtorrentpath and not Path(self._fromtorrentpath).exists(): - logger.error(f"源下载器种子文件保存路径不存在:{self._fromtorrentpath}") - self.systemmessage.put(f"源下载器种子文件保存路径不存在:{self._fromtorrentpath}", title="自动转移做种") - return - if self._fromdownloader == self._todownloader: - logger.error(f"源下载器和目的下载器不能相同") - self.systemmessage.put(f"源下载器和目的下载器不能相同", title="自动转移做种") + if not self.__validate_config(): + self._enabled = False + self._onlyonce = False + config["enabled"] = self._enabled + config["onlyonce"] = self._onlyonce + self.update_config(config=config) return # 定时服务 @@ -123,25 +128,8 @@ class TorrentTransfer(_PluginBase): seconds=3)) # 关闭一次性开关 self._onlyonce = False - self.update_config({ - "enabled": self._enabled, - "onlyonce": self._onlyonce, - "cron": self._cron, - "notify": self._notify, - "nolabels": self._nolabels, - "includelabels": self._includelabels, - "includecategory": self._includecategory, - "frompath": self._frompath, - "topath": self._topath, - "fromdownloader": self._fromdownloader, - "todownloader": self._todownloader, - "deletesource": self._deletesource, - "deleteduplicate": self._deleteduplicate, - "fromtorrentpath": self._fromtorrentpath, - "nopaths": self._nopaths, - "autostart": self._autostart, - "transferemptylabel": self._transferemptylabel - }) + config["onlyonce"] = self._onlyonce + self.update_config(config=config) # 启动服务 if self._scheduler.get_jobs(): @@ -253,7 +241,7 @@ class TorrentTransfer(_PluginBase): 'component': 'VCol', 'props': { 'cols': 12, - 'md': 6 + 'md': 4 }, 'content': [ { @@ -266,11 +254,28 @@ class TorrentTransfer(_PluginBase): } ] }, - { + { 'component': 'VCol', 'props': { 'cols': 12, - 'md': 6 + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'add_torrent_tags', + 'label': '添加种子标签', + 'placeholder': '已整理,转移做种' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 }, 'content': [ { @@ -523,7 +528,8 @@ class TorrentTransfer(_PluginBase): "fromtorrentpath": "", "nopaths": "", "autostart": True, - "transferemptylabel": False + "transferemptylabel": False, + "add_torrent_tags": "已整理,转移做种" } def get_page(self) -> List[dict]: @@ -540,6 +546,21 @@ class TorrentTransfer(_PluginBase): else: return None + def __validate_config(self) -> bool: + """ + 校验配置 + """ + # 检查配置 + if self._fromtorrentpath and not Path(self._fromtorrentpath).exists(): + logger.error(f"源下载器种子文件保存路径不存在:{self._fromtorrentpath}") + self.systemmessage.put(f"源下载器种子文件保存路径不存在:{self._fromtorrentpath}", title="自动转移做种") + return False + if self._fromdownloader == self._todownloader: + logger.error(f"源下载器和目的下载器不能相同") + self.systemmessage.put(f"源下载器和目的下载器不能相同", title="自动转移做种") + return False + return True + def __download(self, downloader: str, content: bytes, save_path: str) -> Optional[str]: """ @@ -551,7 +572,7 @@ class TorrentTransfer(_PluginBase): state = self.qb.add_torrent(content=content, download_dir=save_path, is_paused=True, - tag=["转移做种", tag]) + tag=self._torrent_tags + [tag]) if not state: return None else: @@ -566,7 +587,7 @@ class TorrentTransfer(_PluginBase): torrent = self.tr.add_torrent(content=content, download_dir=save_path, is_paused=True, - labels=["转移做种"]) + labels=self._torrent_tags) if not torrent: return None else: @@ -581,6 +602,9 @@ class TorrentTransfer(_PluginBase): """ logger.info("开始转移做种任务 ...") + if not self.__validate_config(): + return + # 源下载器 downloader = self._fromdownloader # 目的下载器 @@ -633,7 +657,7 @@ class TorrentTransfer(_PluginBase): if torrent_category not in self._includecategory.split(','): logger.info(f"种子 {hash_str} 不含有转移分类 {self._includecategory},跳过 ...") continue - #根据设置决定是否转移无标签的种子 + # 根据设置决定是否转移无标签的种子 if is_torrent_labels_empty: if not self._transferemptylabel: continue @@ -903,12 +927,12 @@ class TorrentTransfer(_PluginBase): 获取种子分类 """ try: - return str(torrent.get("category")).strip() \ + return torrent.get("category").strip() \ if dl_type == "qbittorrent" else "" except Exception as e: print(str(e)) return "" - + @staticmethod def __get_save_path(torrent: Any, dl_type: str): """ From f92683a43caa7a684018edeb2a34495cc54c9454 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Fri, 11 Oct 2024 15:32:41 +0800 Subject: [PATCH 084/218] fix(TorrentTransfer): update package.v2.json --- package.v2.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.v2.json b/package.v2.json index fc7f586..ecf5049 100644 --- a/package.v2.json +++ b/package.v2.json @@ -92,7 +92,7 @@ "author": "jxxghp", "level": 2, "history": { - "v1.7": "MoviePilot V2 版本媒体库服务器通知插件" + "v1.7": "MoviePilot V2 版本自动转移做种插件" } } } \ No newline at end of file From d0952687fb4302515c97b66b695b8e0afdd025c5 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Fri, 11 Oct 2024 23:43:05 +0800 Subject: [PATCH 085/218] fix(TorrentTransfer): adjust type-checking logic --- plugins.v2/torrenttransfer/__init__.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/plugins.v2/torrenttransfer/__init__.py b/plugins.v2/torrenttransfer/__init__.py index dd520d4..e372125 100644 --- a/plugins.v2/torrenttransfer/__init__.py +++ b/plugins.v2/torrenttransfer/__init__.py @@ -558,12 +558,15 @@ class TorrentTransfer(_PluginBase): return False return True - def __download(self, downloader: Union[Qbittorrent, Transmission], content: bytes, + def __download(self, service: ServiceInfo, content: bytes, save_path: str) -> Optional[str]: """ 添加下载任务 """ - if self.downloader_helper.is_qbittorrent(instance=downloader): + if not service or not service.instance: + return + downloader = service.instance + if self.downloader_helper.is_qbittorrent(service): # 生成随机Tag tag = StringUtils.generate_random_str(10) state = downloader.add_torrent(content=content, @@ -579,7 +582,7 @@ class TorrentTransfer(_PluginBase): logger.error(f"{downloader} 下载任务添加成功,但获取任务信息失败!") return None return torrent_hash - elif self.downloader_helper.is_transmission(instance=downloader): + elif self.downloader_helper.is_transmission(service): # 添加任务 torrent = downloader.add_torrent(content=content, download_dir=save_path, @@ -786,7 +789,7 @@ class TorrentTransfer(_PluginBase): # 发送到另一个下载器中下载:默认暂停、传输下载路径、关闭自动管理模式 logger.info(f"添加转移做种任务到下载器 {to_service.name}:{torrent_file}") - download_id = self.__download(downloader=to_downloader, + download_id = self.__download(service=to_service, content=torrent_file.read_bytes(), save_path=download_dir) if not download_id: From f4ba85aaa36850d8b8c61f5d78b9d9d1b313af92 Mon Sep 17 00:00:00 2001 From: Attente <19653207+wikrin@users.noreply.github.com> Date: Sat, 12 Oct 2024 03:40:14 +0800 Subject: [PATCH 086/218] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E8=AE=A2?= =?UTF-8?q?=E9=98=85,=20=E5=BD=93=E4=BF=A1=E6=81=AF=E4=B8=8D=E4=B8=80?= =?UTF-8?q?=E8=87=B4=E4=BC=98=E5=85=88BGM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 +- plugins/bangumicoll/__init__.py | 107 ++++++++++++++++++++++++++------ 2 files changed, 89 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index 07ae074..fae2e2c 100644 --- a/package.json +++ b/package.json @@ -3,11 +3,12 @@ "name": "Bangumi收藏订阅", "description": "Bangumi用户收藏添加到订阅", "labels": "订阅", - "version": "1.2.2", + "version": "1.3", "icon": "https://raw.githubusercontent.com/wikrin/MoviePilot-Plugins/main/icons/bangumi_b.png", "author": "Attente", "level": 1, "history": { + "v1.3": "添加订阅逻辑优化", "v1.2.2": "新增: 订阅添加失败总览 修复: 其他方式添加的订阅反复添加的问题", "v1.2.1": "修复tmdb没有查询到条目导致插件崩溃的问题", "v1.1": "新增根据收藏状态移除由此插件添加的订阅" diff --git a/plugins/bangumicoll/__init__.py b/plugins/bangumicoll/__init__.py index 3f6d76a..e09c33b 100644 --- a/plugins/bangumicoll/__init__.py +++ b/plugins/bangumicoll/__init__.py @@ -29,7 +29,7 @@ class BangumiColl(_PluginBase): # 插件图标 plugin_icon = "https://raw.githubusercontent.com/wikrin/MoviePilot-Plugins/main/icons/bangumi_b.png" # 插件版本 - plugin_version = "1.2.2" + plugin_version = "1.3" # 插件作者 plugin_author = "Attente" # 作者主页 @@ -383,19 +383,11 @@ class BangumiColl(_PluginBase): logger.error("请设置UID") return - addr = f"https://api.bgm.tv/v0/users/{self._uid}/collections?subject_type=2" - headers = { - "User-Agent": "wikrin/MoviePilot-Plugins (https://github.com/wikrin/MoviePilot-Plugins)" - } - - try: - logger.info(f"查询bangumi条目信息:{addr}") - res = RequestUtils(headers=headers).get_res(url=addr) - res = res.json().get("data") - if not res: - logger.error(f"Bangumi用户:{self._uid} ,未查询到数据") - except Exception as e: - logger.error(f"获取Bangumi收藏数据失败:{addr} 失败:{str(e)}") + # 获取收藏列表 + res = self.get_bgm_res(addr="UserCollections", id=self._uid) + res = res.json().get("data") + if not res: + logger.error(f"Bangumi用户:{self._uid} ,没有任何收藏") # 解析出必要数据 items: Dict[int, Dict[str, Any]] = {} @@ -415,8 +407,19 @@ class BangumiColl(_PluginBase): name_cn = item['subject'].get('name_cn') # 放送时间 date = item['subject'].get('date') + # 集数 + eps = item['subject'].get('eps') ## 这里在后面添加排除规则 - items.update({subject_id: {"name": name, "name_cn": name_cn, "date": date}}) + items.update( + { + subject_id: { + "name": name, + "name_cn": name_cn, + "date": date, + "eps": eps, + } + } + ) ## 获取已添加的订阅 db_sub = { i.bangumiid: i.id @@ -460,6 +463,7 @@ class BangumiColl(_PluginBase): 添加订阅 :param items: bgm条目id为键,bgm条目信息为值 ''' + # 记录失败条目 fail_items = {} for subject_id, item in items.items(): @@ -490,6 +494,63 @@ class BangumiColl(_PluginBase): # 更新集数信息 mediainfo.number_of_episodes = info.get("episode_count") + # 总集数 + total_episode = len( + mediainfo.seasons.get(mediainfo.number_of_seasons) or [] + ) + # 额外参数 + kwargs = { + "save_path": self._save_path, + "sites": self._sites, + "total_episode": total_episode, + } + + # 对比BGM 和 TMDB 季度和集数 + if ( + meta.begin_season + and mediainfo.number_of_seasons != meta.begin_season + or (item.get('eps') != 0 and total_episode != item.get('eps')) + or (item.get('eps') == 0 and not total_episode >= 12) + ): + ''' + 1. 标题识别到季度且与tmdb不一致 + 2. eps 字段为0且总集数小于12 + 3. eps 字段不为0且总集数与Bangumi集数不一致 + ''' + + def get_eps(id: str, addr: str = "getEpisodes") -> tuple: + """ + 获取Bangumi条目的集数信息 + :param id: bangumi条目id + :param addr: API地址 + """ + ep: int = 1 + sort: int = 1 + total: int = 24 + res = self.get_bgm_res(addr=addr, id=id) + res = res.json() + data = res.get("data") + if data: + # 当前季的集数 + ep = data[0].get("ep") + # 系列的累计集数 + sort = data[0].get("sort") + # 当前集的总集数 + total = res.get("total") + begin_ep = sort + total_ep = sort + total - ep + return begin_ep, total_ep + + if meta.begin_season: + # 使用标题识别到的季号 + mediainfo.number_of_seasons = meta.begin_season + + begin_ep, total_ep = get_eps(id=subject_id) + logger.info( + f"{mediainfo.title_year} 识别到Bangumi与TMDB的季数和集数不一致" + ) + kwargs.update({"start_episode": begin_ep, "total_episode": total_ep}) + # 检查是否已经订阅, 添加bangumiid sid = self.subscribeoper.list_by_tmdbid( mediainfo.tmdb_id, mediainfo.number_of_seasons @@ -505,11 +566,6 @@ class BangumiColl(_PluginBase): ) continue - # 额外参数 - kwargs = { - "save_path": self._save_path, - "sites": self._sites, - } # 添加到订阅 sid, msg = self.subscribechain.add( title=mediainfo.title, @@ -553,6 +609,17 @@ class BangumiColl(_PluginBase): image=subscribe.backdrop, ) + @staticmethod + def get_bgm_res(addr: str, id: int | str): + url = { + "UserCollections": f"https://api.bgm.tv/v0/users/{str(id)}/collections?subject_type=2", + "getEpisodes": f"https://api.bgm.tv/v0/episodes?subject_id={str(id)}&type=0&limit=1", + } + headers = { + "User-Agent": "wikrin/MoviePilot-Plugins (https://github.com/wikrin/MoviePilot-Plugins)" + } + return RequestUtils(headers=headers).get_res(url=url[addr]) + @staticmethod def are_dates(date_str1, date_str2, threshold_days: int = 7) -> bool: """ From b89a94736998e9df99423dd790999fd4e5d1e532 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 12 Oct 2024 11:54:49 +0800 Subject: [PATCH 087/218] =?UTF-8?q?fix=20=E6=8F=92=E4=BB=B6=E5=85=BC?= =?UTF-8?q?=E5=AE=B9V2=E6=9C=80=E6=96=B0=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 15 +++++++++------ plugins.v2/brushflow/__init__.py | 22 +++++++++++----------- plugins.v2/downloadsitetag/__init__.py | 11 ----------- plugins.v2/mediaserverrefresh/__init__.py | 8 ++++---- plugins.v2/torrenttransfer/__init__.py | 10 +++++----- 5 files changed, 29 insertions(+), 37 deletions(-) diff --git a/package.v2.json b/package.v2.json index ecf5049..8f618a4 100644 --- a/package.v2.json +++ b/package.v2.json @@ -15,12 +15,13 @@ "name": "站点刷流", "description": "自动托管刷流,将会提高对应站点的访问频率。", "labels": "刷流,仪表板", - "version": "3.9", + "version": "3.9.1", "icon": "brush.jpg", "author": "jxxghp,InfinityPacer", "level": 2, "history": { - "v3.9": "MoviePilot V2 版本站点刷流插件" + "v3.9": "MoviePilot V2 版本站点刷流插件", + "v3.9.1": "修复兼容性问题" } }, "AutoSignIn": { @@ -51,12 +52,13 @@ "name": "媒体库服务器刷新", "description": "入库后自动刷新Emby/Jellyfin/Plex服务器海报墙。", "labels": "媒体库", - "version": "1.3", + "version": "1.3.1", "icon": "refresh2.png", "author": "jxxghp", "level": 1, "history": { - "v1.3": "MoviePilot V2 版本媒体库服务器刷新插件" + "v1.3": "MoviePilot V2 版本媒体库服务器刷新插件", + "v1.3.1": "修复兼容性问题" } }, "MediaServerMsg": { @@ -87,12 +89,13 @@ "name": "自动转移做种", "description": "定期转移下载器中的做种任务到另一个下载器。", "labels": "做种", - "version": "1.7", + "version": "1.7.1", "icon": "seed.png", "author": "jxxghp", "level": 2, "history": { - "v1.7": "MoviePilot V2 版本自动转移做种插件" + "v1.7": "MoviePilot V2 版本自动转移做种插件", + "v1.7.1": "修复兼容性问题" } } } \ No newline at end of file diff --git a/plugins.v2/brushflow/__init__.py b/plugins.v2/brushflow/__init__.py index 0ec911c..2f50f73 100644 --- a/plugins.v2/brushflow/__init__.py +++ b/plugins.v2/brushflow/__init__.py @@ -246,7 +246,7 @@ class BrushFlow(_PluginBase): # 插件图标 plugin_icon = "brush.jpg" # 插件版本 - plugin_version = "3.9" + plugin_version = "3.9.1" # 插件作者 plugin_author = "jxxghp,InfinityPacer" # 作者主页 @@ -2312,7 +2312,7 @@ class BrushFlow(_PluginBase): if need_delete_hashes: # 如果是QB,则重新汇报Tracker - if self.downloader_helper.is_qbittorrent(service=self.service_info): + if self.downloader_helper.is_downloader("qbittorrent", service=self.service_info): self.__qb_torrents_reannounce(torrent_hashes=need_delete_hashes) # 删除种子 if downloader.delete_torrents(ids=need_delete_hashes, delete_file=True): @@ -2354,7 +2354,7 @@ class BrushFlow(_PluginBase): seeding_torrents_dict: Dict[str, Any]): brush_config = self.__get_brush_config() - if not self.downloader_helper.is_qbittorrent(service=self.service_info): + if not self.downloader_helper.is_downloader("qbittorrent", service=self.service_info): logger.info("同步种子刷流标签记录目前仅支持qbittorrent") return @@ -2508,7 +2508,6 @@ class BrushFlow(_PluginBase): """ 根据条件删除种子并获取已删除列表 """ - brush_config = self.__get_brush_config() delete_hashes = [] for torrent in torrents: @@ -2543,7 +2542,6 @@ class BrushFlow(_PluginBase): """ 根据动态删除前置条件排除H&R种子后删除种子并获取已删除列表 """ - brush_config = self.__get_brush_config() delete_hashes = [] for torrent in torrents: @@ -3043,7 +3041,7 @@ class BrushFlow(_PluginBase): if not downloader: return None - if self.downloader_helper.is_qbittorrent(service=self.service_info): + if self.downloader_helper.is_downloader("qbittorrent", service=self.service_info): # 限速值转为bytes up_speed = up_speed * 1024 if up_speed else None down_speed = down_speed * 1024 if down_speed else None @@ -3077,7 +3075,7 @@ class BrushFlow(_PluginBase): return torrent_hash return None - elif self.downloader_helper.is_transmission(service=self.service_info): + elif self.downloader_helper.is_downloader("transmission", service=self.service_info): # 如果开启代理下载以及种子地址不是磁力地址,则请求种子到内存再传入下载器 if not torrent_content.startswith("magnet"): response = RequestUtils(cookies=cookies, @@ -3125,7 +3123,7 @@ class BrushFlow(_PluginBase): 获取种子hash """ try: - return torrent.get("hash") if self.downloader_helper.is_qbittorrent(service=self.service_info) \ + return torrent.get("hash") if self.downloader_helper.is_downloader("qbittorrent", service=self.service_info) \ else torrent.hashString except Exception as e: print(str(e)) @@ -3142,7 +3140,8 @@ class BrushFlow(_PluginBase): all_hashes = [] for torrent in torrents: # 根据下载器类型获取Hash值 - hash_value = torrent.get("hash") if self.downloader_helper.is_qbittorrent(service=self.service_info) \ + hash_value = torrent.get("hash") if self.downloader_helper.is_downloader("qbittorrent", + service=self.service_info) \ else torrent.hashString if hash_value: all_hashes.append(hash_value) @@ -3157,7 +3156,8 @@ class BrushFlow(_PluginBase): """ try: return [str(tag).strip() for tag in torrent.get("tags").split(',')] \ - if self.downloader_helper.is_qbittorrent(service=self.service_info) else torrent.labels or [] + if self.downloader_helper.is_downloader("qbittorrent", + service=self.service_info) else torrent.labels or [] except Exception as e: print(str(e)) return [] @@ -3168,7 +3168,7 @@ class BrushFlow(_PluginBase): """ date_now = int(time.time()) # QB - if self.downloader_helper.is_qbittorrent(service=self.service_info): + if self.downloader_helper.is_downloader("qbittorrent", service=self.service_info): """ { "added_on": 1693359031, diff --git a/plugins.v2/downloadsitetag/__init__.py b/plugins.v2/downloadsitetag/__init__.py index 4c18550..74d16ea 100644 --- a/plugins.v2/downloadsitetag/__init__.py +++ b/plugins.v2/downloadsitetag/__init__.py @@ -338,17 +338,6 @@ class DownloadSiteTag(_PluginBase): _cat = self._category_tv return _cat - def _get_downloader(self, dtype: str): - """ - 根据类型返回下载器实例 - """ - if dtype == "qbittorrent": - return self.downloader_qb - elif dtype == "transmission": - return self.downloader_tr - else: - return None - @staticmethod def _torrent_key(torrent: Any, dl_type: str) -> Optional[Tuple[int, str]]: """ diff --git a/plugins.v2/mediaserverrefresh/__init__.py b/plugins.v2/mediaserverrefresh/__init__.py index edd6cab..69d1b4b 100644 --- a/plugins.v2/mediaserverrefresh/__init__.py +++ b/plugins.v2/mediaserverrefresh/__init__.py @@ -19,7 +19,7 @@ class MediaServerRefresh(_PluginBase): # 插件图标 plugin_icon = "refresh2.png" # 插件版本 - plugin_version = "1.3" + plugin_version = "1.3.1" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -204,16 +204,16 @@ class MediaServerRefresh(_PluginBase): for name, service in self.service_infos.items(): # Emby - if self.mediaserver_helper.is_emby(service=service): + if self.mediaserver_helper.is_media_server("emby", service=service): service.instance.refresh_library_by_items(items) # Jeyllyfin - if self.mediaserver_helper.is_jellyfin(service=service): + if self.mediaserver_helper.is_media_server("jellyfin", service=service): # FIXME Jellyfin未找到刷新单个项目的API service.instance.refresh_root_library() # Plex - if self.mediaserver_helper.is_plex(service=service): + if self.mediaserver_helper.is_media_server("plex", service=service): service.instance.refresh_library_by_items(items) def stop_service(self): diff --git a/plugins.v2/torrenttransfer/__init__.py b/plugins.v2/torrenttransfer/__init__.py index e372125..ca6ca7b 100644 --- a/plugins.v2/torrenttransfer/__init__.py +++ b/plugins.v2/torrenttransfer/__init__.py @@ -28,7 +28,7 @@ class TorrentTransfer(_PluginBase): # 插件图标 plugin_icon = "seed.png" # 插件版本 - plugin_version = "1.7" + plugin_version = "1.7.1" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -566,7 +566,7 @@ class TorrentTransfer(_PluginBase): if not service or not service.instance: return downloader = service.instance - if self.downloader_helper.is_qbittorrent(service): + if self.downloader_helper.is_downloader("qbittorrent", service=service): # 生成随机Tag tag = StringUtils.generate_random_str(10) state = downloader.add_torrent(content=content, @@ -582,7 +582,7 @@ class TorrentTransfer(_PluginBase): logger.error(f"{downloader} 下载任务添加成功,但获取任务信息失败!") return None return torrent_hash - elif self.downloader_helper.is_transmission(service): + elif self.downloader_helper.is_downloader("transmission", service=service): # 添加任务 torrent = downloader.add_torrent(content=content, download_dir=save_path, @@ -739,7 +739,7 @@ class TorrentTransfer(_PluginBase): continue # 如果源下载器是QB检查是否有Tracker,没有的话额外获取 - if self.downloader_helper.is_qbittorrent(from_service): + if self.downloader_helper.is_downloader("qbittorrent", service=from_service): # 读取种子内容、解析种子文件 content = torrent_file.read_bytes() if not content: @@ -802,7 +802,7 @@ class TorrentTransfer(_PluginBase): logger.info(f"成功添加转移做种任务,种子文件:{torrent_file}") # TR会自动校验,QB需要手动校验 - if self.downloader_helper.is_qbittorrent(to_service): + if self.downloader_helper.is_downloader("qbittorrent", service=to_service): logger.info(f"qbittorrent 开始校验 {download_id} ...") to_downloader.recheck_torrents(ids=[download_id]) From 2d79912a963243df19288ef54dcdb7a3f872c714 Mon Sep 17 00:00:00 2001 From: Attente <19653207+wikrin@users.noreply.github.com> Date: Sat, 12 Oct 2024 23:24:35 +0800 Subject: [PATCH 088/218] =?UTF-8?q?frat:=20=E5=85=BC=E5=AE=B9V2=20fix:=20?= =?UTF-8?q?=E6=92=A4=E9=94=80=E5=AD=A3=E5=8F=B7=E8=B7=9F=E9=9A=8F=E5=89=A7?= =?UTF-8?q?=E9=9B=86=E6=A0=87=E9=A2=98,=20=E4=BC=9A=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E6=9C=AA=E4=B8=8B=E8=BD=BD=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E5=B0=B1=E5=AE=8C=E6=88=90=E8=AE=A2=E9=98=85=20other:=20?= =?UTF-8?q?=E5=8C=85=E5=AF=BC=E5=85=A5=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 7 +-- plugins/bangumicoll/__init__.py | 89 +++++++++++++++++++++++---------- 2 files changed, 67 insertions(+), 29 deletions(-) diff --git a/package.json b/package.json index fae2e2c..0c43c28 100644 --- a/package.json +++ b/package.json @@ -3,15 +3,16 @@ "name": "Bangumi收藏订阅", "description": "Bangumi用户收藏添加到订阅", "labels": "订阅", - "version": "1.3", + "version": "1.3.1", "icon": "https://raw.githubusercontent.com/wikrin/MoviePilot-Plugins/main/icons/bangumi_b.png", "author": "Attente", "level": 1, + "v2": true, "history": { + "v1.3.1": "修复因修改季号导致未下载剧集而完成订阅的问题", "v1.3": "添加订阅逻辑优化", "v1.2.2": "新增: 订阅添加失败总览 修复: 其他方式添加的订阅反复添加的问题", - "v1.2.1": "修复tmdb没有查询到条目导致插件崩溃的问题", - "v1.1": "新增根据收藏状态移除由此插件添加的订阅" + "v1.2.1": "修复tmdb没有查询到条目导致插件崩溃的问题" } } } diff --git a/plugins/bangumicoll/__init__.py b/plugins/bangumicoll/__init__.py index e09c33b..41009a1 100644 --- a/plugins/bangumicoll/__init__.py +++ b/plugins/bangumicoll/__init__.py @@ -1,23 +1,25 @@ import datetime -from typing import Optional, Any, List, Dict +import json import pytz +from typing import Any, Dict, List, Optional, Type + +from app.chain.subscribe import SubscribeChain, Subscribe +from app.core.config import settings +from app.core.context import MediaInfo +from app.core.metainfo import MetaInfo +from app.db.models.subscribehistory import SubscribeHistory +from app.db.site_oper import SiteOper +from app.db.subscribe_oper import SubscribeOper +from app.db import db_query +from app.helper.subscribe import SubscribeHelper +from app.log import logger +from app.plugins import _PluginBase +from app.schemas.types import NotificationType +from app.utils.http import RequestUtils from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger -from app.chain.subscribe import SubscribeChain -from app.core.config import settings - -from app.core.context import MediaInfo -from app.core.metainfo import MetaInfo -from app.log import logger -from app.plugins import _PluginBase -from app.db.site_oper import SiteOper -from app.utils.http import RequestUtils -from app.db.subscribe_oper import SubscribeOper -from app.helper.subscribe import SubscribeHelper -from app.schemas.types import NotificationType -from app.db import db_query -from app.db.models.subscribehistory import SubscribeHistory +from sqlalchemy import JSON from sqlalchemy.orm import Session @@ -29,7 +31,7 @@ class BangumiColl(_PluginBase): # 插件图标 plugin_icon = "https://raw.githubusercontent.com/wikrin/MoviePilot-Plugins/main/icons/bangumi_b.png" # 插件版本 - plugin_version = "1.3" + plugin_version = "1.3.1" # 插件作者 plugin_author = "Attente" # 作者主页 @@ -466,6 +468,13 @@ class BangumiColl(_PluginBase): # 记录失败条目 fail_items = {} + # 格式化站点 + sites = ( + self._sites + if self.are_types_equal(attribute_name='sites') + else json.dumps(self._sites) + ) + for subject_id, item in items.items(): meta = MetaInfo(item.get("name_cn")) if not meta.name: @@ -501,7 +510,7 @@ class BangumiColl(_PluginBase): # 额外参数 kwargs = { "save_path": self._save_path, - "sites": self._sites, + "sites": sites, "total_episode": total_episode, } @@ -517,6 +526,7 @@ class BangumiColl(_PluginBase): 2. eps 字段为0且总集数小于12 3. eps 字段不为0且总集数与Bangumi集数不一致 ''' + logger.info(f"{mediainfo.title_year} 与Bangumi的集数不一致") def get_eps(id: str, addr: str = "getEpisodes") -> tuple: """ @@ -537,19 +547,29 @@ class BangumiColl(_PluginBase): sort = data[0].get("sort") # 当前集的总集数 total = res.get("total") - begin_ep = sort - total_ep = sort + total - ep + begin_ep = sort - ep + 1 + total_ep = sort - ep + total return begin_ep, total_ep - if meta.begin_season: - # 使用标题识别到的季号 - mediainfo.number_of_seasons = meta.begin_season - begin_ep, total_ep = get_eps(id=subject_id) - logger.info( - f"{mediainfo.title_year} 识别到Bangumi与TMDB的季数和集数不一致" + prev_eps = [i for i in range(1, begin_ep)] + # 更新参数 + note = ( + prev_eps + if self.are_types_equal(attribute_name='note') + else json.dumps(prev_eps) + ) + kwargs.update( + { + "total_episode": total_ep, # 总集数 + "start_episode": begin_ep, # 开始集数 + "lack_episode": total_ep - begin_ep + 1, # 缺失集数 + "note": note, # 忽略之前季度的集数 + } + ) + logger.info( + f"{mediainfo.title_year} 更新总集数为: {total_ep},开始集数为: {begin_ep}" ) - kwargs.update({"start_episode": begin_ep, "total_episode": total_ep}) # 检查是否已经订阅, 添加bangumiid sid = self.subscribeoper.list_by_tmdbid( @@ -650,3 +670,20 @@ class BangumiColl(_PluginBase): db.query(SubscribeHistory).filter(SubscribeHistory.bangumiid != None).all() ) return set([i.bangumiid for i in result]) + + @staticmethod + def are_types_equal( + attribute_name: str, expected_type: Type[Any] = JSON(), class_=Subscribe + ) -> bool: + """ + 比较类中属性的类型与expected_type是否一致 + :param class_: 类 + :param attribute_name: 属性名称 + :param expected_type: 期望的类型 + """ + column = class_.__table__.columns.get(attribute_name) + if column is None: + raise AttributeError( + f"Class: {class_.__name__} 没有属性: '{attribute_name}'" + ) + return isinstance(column.type, type(expected_type)) From b10cfe36a5c59a356f22ab54098632e81014e5f0 Mon Sep 17 00:00:00 2001 From: Attente <19653207+wikrin@users.noreply.github.com> Date: Sun, 13 Oct 2024 15:00:09 +0800 Subject: [PATCH 089/218] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 +- plugins/bangumicoll/__init__.py | 482 +++++++++++++------------------- 2 files changed, 198 insertions(+), 287 deletions(-) diff --git a/package.json b/package.json index 0c43c28..2e84c46 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,13 @@ "name": "Bangumi收藏订阅", "description": "Bangumi用户收藏添加到订阅", "labels": "订阅", - "version": "1.3.1", + "version": "1.4", "icon": "https://raw.githubusercontent.com/wikrin/MoviePilot-Plugins/main/icons/bangumi_b.png", "author": "Attente", "level": 1, "v2": true, "history": { + "v1.4": "结构优化", "v1.3.1": "修复因修改季号导致未下载剧集而完成订阅的问题", "v1.3": "添加订阅逻辑优化", "v1.2.2": "新增: 订阅添加失败总览 修复: 其他方式添加的订阅反复添加的问题", diff --git a/plugins/bangumicoll/__init__.py b/plugins/bangumicoll/__init__.py index 41009a1..21d071e 100644 --- a/plugins/bangumicoll/__init__.py +++ b/plugins/bangumicoll/__init__.py @@ -16,7 +16,6 @@ from app.log import logger from app.plugins import _PluginBase from app.schemas.types import NotificationType from app.utils.http import RequestUtils - from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger from sqlalchemy import JSON @@ -31,7 +30,7 @@ class BangumiColl(_PluginBase): # 插件图标 plugin_icon = "https://raw.githubusercontent.com/wikrin/MoviePilot-Plugins/main/icons/bangumi_b.png" # 插件版本 - plugin_version = "1.3.1" + plugin_version = "1.4" # 插件作者 plugin_author = "Attente" # 作者主页 @@ -69,45 +68,43 @@ class BangumiColl(_PluginBase): # 停止现有任务 self.stop_service() + self.load_config(config) - # 配置 + if self._onlyonce: + self.schedule_once() + + def load_config(self, config: dict): + """加载配置""" if config: - self._enabled = config.get("enabled") - self._cron = config.get("cron") - self._notify = config.get("notify") - self._onlyonce = config.get("onlyonce") - self._include = config.get("include") - self._exclude = config.get("exclude") - self._uid = config.get("uid") - self._collection_type = config.get("collection_type") or [3] - self._save_path = config.get("save_path") - self._sites = config.get("sites") + self._enabled = config.get("enabled", self._enabled) + self._cron = config.get("cron", self._cron) + self._notify = config.get("notify", self._notify) + self._onlyonce = config.get("onlyonce", self._onlyonce) + self._include = config.get("include", self._include) + self._exclude = config.get("exclude", self._exclude) + self._uid = config.get("uid", self._uid) + self._collection_type = config.get("collection_type", [3]) + self._save_path = config.get("save_path", self._save_path) + self._sites = config.get("sites", self._sites) - if self._onlyonce: - self._scheduler = BackgroundScheduler(timezone=settings.TZ) - logger.info(f"Bangumi收藏订阅启动,立即运行一次") - self._scheduler.add_job( - func=self.bangumi_coll, - trigger='date', - run_date=datetime.datetime.now(tz=pytz.timezone(settings.TZ)) - + datetime.timedelta(seconds=3), - ) + def schedule_once(self): + """调度一次性任务""" + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + logger.info("Bangumi收藏订阅,立即运行一次") + self._scheduler.add_job( + func=self.bangumi_coll, + trigger='date', + run_date=datetime.datetime.now(tz=pytz.timezone(settings.TZ)) + + datetime.timedelta(seconds=3), + ) + self._scheduler.start() - # 启动任务 - if self._scheduler.get_jobs(): - self._scheduler.print_jobs() - self._scheduler.start() - - if self._onlyonce: - # 关闭一次性开关 - self._onlyonce = False - # 保存设置 - self.__update_config() + # 关闭一次性开关 + self._onlyonce = False + self.__update_config() def __update_config(self): - """ - 更新设置 - """ + """更新设置""" self.update_config( { "enabled": self._enabled, @@ -123,12 +120,6 @@ class BangumiColl(_PluginBase): } ) - def get_api(self): - pass - - def get_command(self): - pass - def get_form(self): # 列出所有站点 sites_options = [ @@ -324,254 +315,129 @@ class BangumiColl(_PluginBase): "sites": [], } - def get_page(self): - 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 and self._cron: + """注册插件公共服务""" + if self._enabled: + trigger = CronTrigger.from_crontab(self._cron) if self._cron else "interval" + kwargs = {"hours": 6} if not self._cron else {} return [ { "id": "BangumiColl", "name": "Bangumi收藏订阅", - "trigger": CronTrigger.from_crontab(self._cron), + "trigger": trigger, "func": self.bangumi_coll, - "kwargs": {}, - } - ] - elif self._enabled: - return [ - { - "id": "BangumiColl", - "name": "Bangumi收藏订阅", - "trigger": "interval", - "func": self.bangumi_coll, - "kwargs": {"hours": 6}, + "kwargs": kwargs, } ] return [] def stop_service(self): - """ - 退出插件 - """ + """退出插件""" try: if self._scheduler: self._scheduler.remove_all_jobs() - if self._scheduler.running: - self._scheduler.shutdown() + self._scheduler.shutdown() self._scheduler = None except Exception as e: - logger.error("退出插件失败:%s" % str(e)) + logger.error(f"退出插件失败:{str(e)}") + + def get_api(self): + pass + + def get_command(self): + pass + + def get_page(self): + pass def get_state(self): return self._enabled def bangumi_coll(self): - """ - 订阅Bangumi用户收藏 - """ + """订阅Bangumi用户收藏""" if not self._uid: logger.error("请设置UID") return - # 获取收藏列表 - res = self.get_bgm_res(addr="UserCollections", id=self._uid) - res = res.json().get("data") - if not res: - logger.error(f"Bangumi用户:{self._uid} ,没有任何收藏") + try: + res = self.get_bgm_res(addr="UserCollections", id=self._uid) + items = self.parse_collection_items(res) - # 解析出必要数据 - items: Dict[int, Dict[str, Any]] = {} - logger.info(f"解析Bangumi条目信息...") - for item in res: + # 新增和移除条目 + self.manage_subscriptions(items) + + logger.info("Bangumi收藏订阅执行完成") + except Exception as e: + logger.error(f"执行失败: {str(e)}") + + def parse_collection_items(self, response) -> Dict[int, Dict[str, Any]]: + """解析获取的收藏条目""" + data = response.json().get("data") + if not data: + logger.error(f"Bangumi用户:{self._uid} ,没有任何收藏") + return {} + + items = {} + logger.info("解析Bangumi条目信息...") + for item in data: if item.get("type") not in self._collection_type: logger.debug( f"条目: {item['subject'].get('name_cn')} 类型:{item.get('type')} 不符合" ) continue - # 条目id - subject_id = item.get("subject_id") - # 主标题 - name = item['subject'].get('name') - # 中文标题 - name_cn = item['subject'].get('name_cn') - # 放送时间 - date = item['subject'].get('date') - # 集数 - eps = item['subject'].get('eps') - ## 这里在后面添加排除规则 - items.update( - { - subject_id: { - "name": name, - "name_cn": name_cn, - "date": date, - "eps": eps, - } - } - ) - ## 获取已添加的订阅 + items[item.get("subject_id")] = { + "name": item['subject'].get('name'), + "name_cn": item['subject'].get('name_cn'), + "date": item['subject'].get('date'), + "eps": item['subject'].get('eps'), + } + return items + + def manage_subscriptions(self, items: Dict[int, Dict[str, Any]]): + """管理订阅的新增和删除""" db_sub = { i.bangumiid: i.id for i in self.subscribechain.subscribeoper.list() if i.bangumiid } - ## 获取历史订阅 db_hist = self.get_subscribe_history() - # 新增条目 new_sub = items.keys() - db_sub.keys() - db_hist - logger.debug(f"待新增条目:{new_sub}") - # 移除条目 del_sub = db_sub.keys() - items.keys() + + logger.debug(f"待新增条目:{new_sub}") logger.debug(f"待移除条目:{del_sub}") - logger.info(f"解析Bangumi条目信息完成,共{len(items)}条,新增{len(new_sub)}条") - - # 执行移除操作 if del_sub and self._notify: - # 数据库id为键,bgm条目id为值 del_items = {db_sub[i]: i for i in del_sub} - logger.info(f"开始移除订阅...") + logger.info("开始移除订阅...") self.delete_subscribe(del_items) - # 执行添加操作 if new_sub: - # bgm条目id为键,bgm条目信息为值 - new_sub = {i: items[i] for i in new_sub} - logger.info(f"开始添加订阅...") - msg = self.add_subscribe(new_sub) + logger.info("开始添加订阅...") + msg = self.add_subscribe({i: items[i] for i in new_sub}) if msg: - # 订阅失败的条目打印至日志 logger.info("\n".ljust(49, ' ').join(list(msg.values()))) - # 结束 - logger.info(f"Bangumi收藏订阅执行完成") - # 添加订阅 def add_subscribe(self, items: Dict[int, Dict[str, Any]]) -> Dict: - ''' - 添加订阅 - :param items: bgm条目id为键,bgm条目信息为值 - ''' - - # 记录失败条目 + """添加订阅""" fail_items = {} - # 格式化站点 - sites = ( - self._sites - if self.are_types_equal(attribute_name='sites') - else json.dumps(self._sites) - ) - - for subject_id, item in items.items(): + for self._subid, item in items.items(): meta = MetaInfo(item.get("name_cn")) if not meta.name: - fail_items.update( - {subject_id: f"{item.get('name_cn')} 未识别到有效数据"} - ) + fail_items[self._subid] = f"{item.get('name_cn')} 未识别到有效数据" logger.warn(f"{item.get('name_cn')} 未识别到有效数据") continue - # 设置默认年份, 避免出现多个结果使用早期条目 - meta.year = item.get("date")[:4] - mediainfo: MediaInfo = self.chain.recognize_media(meta=meta) - # 识别失败则跳过 + + meta.year = item.get("date")[:4] if item.get("date") else None + mediainfo = self.chain.recognize_media(meta=meta) if not mediainfo: - fail_items.update( - {subject_id: f"{item.get('name_cn')} 媒体信息识别失败"} - ) + fail_items[self._subid] = f"{item.get('name_cn')} 媒体信息识别失败" continue - # 对比Bangumi和tmdb的信息确定季度 - for info in mediainfo.season_info: - # 对比日期, 误差默认7天 - if not self.are_dates(item.get("date"), info.get("air_date")): - continue - else: - # 更新季度信息 - mediainfo.number_of_seasons = info.get("season_number") - # 更新集数信息 - mediainfo.number_of_episodes = info.get("episode_count") - # 总集数 - total_episode = len( - mediainfo.seasons.get(mediainfo.number_of_seasons) or [] - ) - # 额外参数 - kwargs = { - "save_path": self._save_path, - "sites": sites, - "total_episode": total_episode, - } + self.update_media_info(item, mediainfo) - # 对比BGM 和 TMDB 季度和集数 - if ( - meta.begin_season - and mediainfo.number_of_seasons != meta.begin_season - or (item.get('eps') != 0 and total_episode != item.get('eps')) - or (item.get('eps') == 0 and not total_episode >= 12) - ): - ''' - 1. 标题识别到季度且与tmdb不一致 - 2. eps 字段为0且总集数小于12 - 3. eps 字段不为0且总集数与Bangumi集数不一致 - ''' - logger.info(f"{mediainfo.title_year} 与Bangumi的集数不一致") - - def get_eps(id: str, addr: str = "getEpisodes") -> tuple: - """ - 获取Bangumi条目的集数信息 - :param id: bangumi条目id - :param addr: API地址 - """ - ep: int = 1 - sort: int = 1 - total: int = 24 - res = self.get_bgm_res(addr=addr, id=id) - res = res.json() - data = res.get("data") - if data: - # 当前季的集数 - ep = data[0].get("ep") - # 系列的累计集数 - sort = data[0].get("sort") - # 当前集的总集数 - total = res.get("total") - begin_ep = sort - ep + 1 - total_ep = sort - ep + total - return begin_ep, total_ep - - begin_ep, total_ep = get_eps(id=subject_id) - prev_eps = [i for i in range(1, begin_ep)] - # 更新参数 - note = ( - prev_eps - if self.are_types_equal(attribute_name='note') - else json.dumps(prev_eps) - ) - kwargs.update( - { - "total_episode": total_ep, # 总集数 - "start_episode": begin_ep, # 开始集数 - "lack_episode": total_ep - begin_ep + 1, # 缺失集数 - "note": note, # 忽略之前季度的集数 - } - ) - logger.info( - f"{mediainfo.title_year} 更新总集数为: {total_ep},开始集数为: {begin_ep}" - ) - - # 检查是否已经订阅, 添加bangumiid sid = self.subscribeoper.list_by_tmdbid( mediainfo.tmdb_id, mediainfo.number_of_seasons ) @@ -579,55 +445,114 @@ class BangumiColl(_PluginBase): logger.info(f"{mediainfo.title_year} {meta.season} 正在订阅中") if len(sid) == 1: self.subscribeoper.update( - sid=sid[0].id, payload={"bangumiid": subject_id} + sid=sid[0].id, payload={"bangumiid": self._subid} ) logger.info( f"{mediainfo.title_year} {meta.season} Bangumi条目id更新成功" ) continue - # 添加到订阅 sid, msg = self.subscribechain.add( title=mediainfo.title, year=mediainfo.year, mtype=mediainfo.type, tmdbid=mediainfo.tmdb_id, - bangumiid=subject_id, + bangumiid=self._subid, season=mediainfo.number_of_seasons, exist_ok=True, username="Bangumi订阅", - **kwargs, + **self.prepare_kwargs(item, meta.begin_season, mediainfo), ) if not sid: - fail_items.update({subject_id: f"{item.get('name_cn')} {msg}"}) - continue + fail_items[self._subid] = f"{item.get('name_cn')} {msg}" + return fail_items + def prepare_kwargs(self, item: dict, meta_season: int, mediainfo: MediaInfo): + """准备额外参数""" + kwargs = { + "save_path": self._save_path, + "sites": ( + self._sites + if self.are_types_equal(attribute_name='sites') + else json.dumps(self._sites) + ), + } + + if self.check_series_info(meta_season, item.get("eps", 0), mediainfo): + begin_ep, total_ep = self.get_eps() + prev_eps: list = [i for i in range(1, begin_ep)] + kwargs.update( + { + "total_episode": total_ep, + "start_episode": begin_ep, + "lack_episode": total_ep - begin_ep + 1, + "note": ( + prev_eps + if self.are_types_equal("note") + else json.dumps(prev_eps) + ), + } + ) + logger.info( + f"{mediainfo.title_year} 更新总集数为: {total_ep},开始集数为: {begin_ep}" + ) + + return kwargs + + @staticmethod + def check_series_info(meta_season: int, bgm_eps: int, mediainfo: MediaInfo) -> bool: + """检查系列信息是否不一致""" + total_episode = len(mediainfo.seasons.get(mediainfo.number_of_seasons) or []) + return ( + meta_season + and mediainfo.number_of_seasons != meta_season + or (bgm_eps != 0 and total_episode != bgm_eps) + or (bgm_eps == 0 and not total_episode >= 12) + ) + + def update_media_info(self, item, mediainfo): + """更新媒体信息""" + for info in mediainfo.season_info: + if self.are_dates(item.get("date"), info.get("air_date")): + mediainfo.number_of_seasons = info.get("season_number") + mediainfo.number_of_episodes = info.get("episode_count") + break + + def get_eps(self) -> tuple: + """获取Bangumi条目的集数信息""" + try: + res = self.get_bgm_res(addr="getEpisodes", id=self._subid) + data = res.json().get("data", [{}])[0] + ep = data.get("ep", 1) + sort = data.get("sort", 1) + total = res.json().get("total", 24) + begin_ep = sort - ep + 1 + total_ep = sort - ep + total + return begin_ep, total_ep + except Exception as e: + logger.error(f"获取集数信息失败: {str(e)}") + return 1, 24 # 默认值 + # 移除订阅 def delete_subscribe(self, del_items: Dict[int, int]): - ''' - 删除订阅 - :param del_items: 数据库id为键,bgm条目id为值 - ''' - args = [i for i in del_items.keys()] - for arg in args: - subscribe_id = int(arg) - subscribe = self.subscribeoper.get(subscribe_id) - if subscribe: - self.subscribeoper.delete(subscribe_id) - # 统计订阅 - self.subscribehelper.sub_done_async( - {"tmdbid": subscribe.tmdbid, "doubanid": subscribe.doubanid} - ) - # 发送通知 - self.post_message( - mtype=NotificationType.Subscribe, - title=f"{subscribe.name}({subscribe.year}) 第{subscribe.season}季 已取消订阅", - text="原因: 未在Bangumi收藏中找到该条目\n" - + f"订阅用户: {subscribe.username}\n" - + f"创建时间: {subscribe.date}", - image=subscribe.backdrop, - ) + """删除订阅""" + for subscribe_id in del_items.keys(): + try: + subscribe = self.subscribeoper.get(subscribe_id) + if subscribe: + self.subscribeoper.delete(subscribe_id) + self.subscribehelper.sub_done_async( + {"tmdbid": subscribe.tmdbid, "doubanid": subscribe.doubanid} + ) + self.post_message( + mtype=NotificationType.Subscribe, + title=f"{subscribe.name}({subscribe.year}) 第{subscribe.season}季 已取消订阅", + text=f"原因: 未在Bangumi收藏中找到该条目\n订阅用户: {subscribe.username}\n创建时间: {subscribe.date}", + image=subscribe.backdrop, + ) + except Exception as e: + logger.error(f"删除订阅失败 {subscribe_id}: {str(e)}") @staticmethod def get_bgm_res(addr: str, id: int | str): @@ -642,45 +567,30 @@ class BangumiColl(_PluginBase): @staticmethod def are_dates(date_str1, date_str2, threshold_days: int = 7) -> bool: - """ - 对比两个日期字符串是否接近 - :param date_str: 日期字符串,格式为'YYYY-MM-DD' - :param threshold_days: 阈值天数,默认为7天 - :return: 如果两个日期之间的差异小于等于阈值天数,则返回True,否则返回False - """ - # 将日期字符串转换为datetime对象 + """对比两个日期字符串是否接近""" date1 = datetime.datetime.strptime(date_str1, '%Y-%m-%d') date2 = datetime.datetime.strptime(date_str2, '%Y-%m-%d') - - # 计算两个日期之间的差异 - delta = abs(date1 - date2) - - # 将阈值转换为timedelta对象 - threshold = datetime.timedelta(days=threshold_days) - - # 比较差异和阈值 - return delta <= threshold + return abs((date1 - date2).days) <= threshold_days @db_query def get_subscribe_history(self, db: Session = None) -> set: - ''' - 获取已完成的订阅 - ''' - result = ( - db.query(SubscribeHistory).filter(SubscribeHistory.bangumiid != None).all() - ) - return set([i.bangumiid for i in result]) + """获取已完成的订阅""" + try: + result = ( + db.query(SubscribeHistory) + .filter(SubscribeHistory.bangumiid.isnot(None)) + .all() + ) + return {i.bangumiid for i in result} + except Exception as e: + logger.error(f"获取订阅历史失败: {str(e)}") + return set() @staticmethod def are_types_equal( attribute_name: str, expected_type: Type[Any] = JSON(), class_=Subscribe ) -> bool: - """ - 比较类中属性的类型与expected_type是否一致 - :param class_: 类 - :param attribute_name: 属性名称 - :param expected_type: 期望的类型 - """ + """比较类中属性的类型与expected_type是否一致""" column = class_.__table__.columns.get(attribute_name) if column is None: raise AttributeError( From 8c497fd89a7f5ba4f0f82cb2aafed11db0990e9f Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Tue, 15 Oct 2024 16:39:59 +0800 Subject: [PATCH 090/218] fix(BrushFlow): trigger plugin event with downloader --- plugins.v2/brushflow/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/plugins.v2/brushflow/__init__.py b/plugins.v2/brushflow/__init__.py index 2f50f73..72e2724 100644 --- a/plugins.v2/brushflow/__init__.py +++ b/plugins.v2/brushflow/__init__.py @@ -2034,10 +2034,12 @@ class BrushFlow(_PluginBase): "time": time.time() } - self.eventmanager.send_event(etype=EventType.PluginAction, data={ - "action": "brushflow_download_added", + self.eventmanager.send_event(etype=EventType.PluginTriggered, data={ + "plugin_id": self.__class__.__name__, + "event_name": "brushflow_download_added", "hash": hash_string, - "data": torrent_task + "data": torrent_task, + "downloader": self.service_info.name }) torrent_tasks[hash_string] = torrent_task From 5c63526287a6050f4ffb2cc856259b239d3c965d Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Tue, 15 Oct 2024 16:41:51 +0800 Subject: [PATCH 091/218] fix(BrushFlow): v3.9.2 --- package.v2.json | 2 +- plugins.v2/brushflow/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.v2.json b/package.v2.json index 8f618a4..49dc247 100644 --- a/package.v2.json +++ b/package.v2.json @@ -15,7 +15,7 @@ "name": "站点刷流", "description": "自动托管刷流,将会提高对应站点的访问频率。", "labels": "刷流,仪表板", - "version": "3.9.1", + "version": "3.9.2", "icon": "brush.jpg", "author": "jxxghp,InfinityPacer", "level": 2, diff --git a/plugins.v2/brushflow/__init__.py b/plugins.v2/brushflow/__init__.py index 72e2724..1c602dd 100644 --- a/plugins.v2/brushflow/__init__.py +++ b/plugins.v2/brushflow/__init__.py @@ -246,7 +246,7 @@ class BrushFlow(_PluginBase): # 插件图标 plugin_icon = "brush.jpg" # 插件版本 - plugin_version = "3.9.1" + plugin_version = "3.9.2" # 插件作者 plugin_author = "jxxghp,InfinityPacer" # 作者主页 From 5dd2901c3cef9eca544be116d88820f7ecc99049 Mon Sep 17 00:00:00 2001 From: ramen <1205925392@qq.com> Date: Tue, 15 Oct 2024 23:23:40 +0800 Subject: [PATCH 092/218] =?UTF-8?q?=E5=9C=A8=E5=8E=9F=E6=9D=A5=E2=80=9C?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E8=AE=BE=E7=BD=AE=E5=8A=A8=E6=80=81IP?= =?UTF-8?q?=E5=88=B0=E4=BC=81=E4=B8=9A=E5=BE=AE=E4=BF=A1=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E5=8F=AF=E4=BF=A1IP=E2=80=9D=E7=9A=84=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E4=B8=8A=E6=B7=BB=E5=8A=A0=E4=BA=86=E4=B8=A4=E4=B8=AA=E7=AC=AC?= =?UTF-8?q?=E4=B8=89=E6=96=B9=E5=BA=94=E7=94=A8=E7=9A=84api.=E4=BE=9D?= =?UTF-8?q?=E7=84=B6=E4=BF=9D=E6=8C=81=E9=BB=98=E8=AE=A4=E4=BD=BF=E7=94=A8?= =?UTF-8?q?cookieCC=E7=99=BB=E9=99=86=20=E9=85=8D=E7=BD=AE=E4=B8=A4?= =?UTF-8?q?=E4=B8=AA=E7=AC=AC=E4=B8=89=E6=96=B9api=E4=B8=BB=E8=A6=81?= =?UTF-8?q?=E7=BC=93=E8=A7=A3=E4=BB=A5=E4=B8=8B=E9=97=AE=E9=A2=98=EF=BC=9A?= =?UTF-8?q?=201.=20=E9=9C=80=E8=A6=81=E7=BB=8F=E5=B8=B8=E5=9C=A8=E5=BC=82?= =?UTF-8?q?=E5=9C=B0=E7=99=BB=E9=99=86=E4=BD=BF=E7=94=A8=E4=BC=81=E4=B8=9A?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E7=9A=84=E7=94=A8=E6=88=B7cookie=E7=BB=8F?= =?UTF-8?q?=E5=B8=B8=E5=A4=B1=E6=95=88=EF=BC=8C=E8=A6=81=E5=BC=82=E5=9C=B0?= =?UTF-8?q?=E7=94=B5=E8=84=91=E5=AE=89=E8=A3=85=E3=80=81=E9=85=8D=E7=BD=AE?= =?UTF-8?q?cookieCC=E9=87=8D=E6=96=B0=E4=B8=8A=E4=BC=A0cookie=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98=202.=20=E5=9C=A8=E4=BB=85=E6=9C=89=E6=89=8B?= =?UTF-8?q?=E6=9C=BA=E5=BE=AE=E4=BF=A1=E7=9A=84=E6=83=85=E5=86=B5=E4=B8=8B?= =?UTF-8?q?=E6=9B=B4=E6=96=B0cookie=E7=9B=B8=E5=AF=B9=E9=BA=BB=E7=83=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/package.json b/package.json index e7c679b..7beded2 100644 --- a/package.json +++ b/package.json @@ -850,5 +850,14 @@ "author": "nnlegenda", "level": 1, "v2": true + }, + "DynamicWeChat": { + "name": "修改企业微信可信IP", + "description": "优先使用cookie,当填写两个第三方token时手机微信可以更新cookie。验证码以?结尾发给企业微信应用。如:110301?", + "labels": "消息通知", + "version": "1.1.2", + "icon": "Wecom_A.png", + "author": "RamenRa", + "level": 2 } } From a7b0c422bcbe6baf8e454c4a3986b552ca5658e3 Mon Sep 17 00:00:00 2001 From: ramen <1205925392@qq.com> Date: Wed, 16 Oct 2024 00:01:32 +0800 Subject: [PATCH 093/218] =?UTF-8?q?=E5=9C=A8=E5=8E=9F=E6=9D=A5=E2=80=9C?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E8=AE=BE=E7=BD=AE=E5=8A=A8=E6=80=81IP?= =?UTF-8?q?=E5=88=B0=E4=BC=81=E4=B8=9A=E5=BE=AE=E4=BF=A1=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E5=8F=AF=E4=BF=A1IP=E2=80=9D=E7=9A=84=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E4=B8=8A=E6=B7=BB=E5=8A=A0=E4=BA=86=E4=B8=A4=E4=B8=AA=E7=AC=AC?= =?UTF-8?q?=E4=B8=89=E6=96=B9=E5=BA=94=E7=94=A8=E7=9A=84api=E7=94=A8?= =?UTF-8?q?=E4=BA=8E=E6=89=AB=E7=A0=81=E7=99=BB=E9=99=86=E6=9B=B4=E6=96=B0?= =?UTF-8?q?cookie.=E9=BB=98=E8=AE=A4=E4=BD=BF=E7=94=A8cookieCC=E7=99=BB?= =?UTF-8?q?=E9=99=86=20=E9=85=8D=E7=BD=AE=E4=B8=A4=E4=B8=AA=E7=AC=AC?= =?UTF-8?q?=E4=B8=89=E6=96=B9api=E4=B8=BB=E8=A6=81=E7=BC=93=E8=A7=A3?= =?UTF-8?q?=E4=BB=A5=E4=B8=8B=E9=97=AE=E9=A2=98=EF=BC=9A=201.=20=E9=9C=80?= =?UTF-8?q?=E8=A6=81=E7=BB=8F=E5=B8=B8=E5=9C=A8=E5=BC=82=E5=9C=B0=E7=99=BB?= =?UTF-8?q?=E9=99=86=E4=BD=BF=E7=94=A8=E4=BC=81=E4=B8=9A=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E7=9A=84=E7=94=A8=E6=88=B7cookie=E7=BB=8F=E5=B8=B8=E5=A4=B1?= =?UTF-8?q?=E6=95=88=EF=BC=8C=E8=A6=81=E5=BC=82=E5=9C=B0=E7=94=B5=E8=84=91?= =?UTF-8?q?=E5=AE=89=E8=A3=85=E3=80=81=E9=85=8D=E7=BD=AEcookieCC=E9=87=8D?= =?UTF-8?q?=E6=96=B0=E4=B8=8A=E4=BC=A0cookie=E7=9A=84=E9=97=AE=E9=A2=98=20?= =?UTF-8?q?2.=20=E5=9C=A8=E4=BB=85=E6=9C=89=E6=89=8B=E6=9C=BA=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E7=9A=84=E6=83=85=E5=86=B5=E4=B8=8B=E6=9B=B4=E6=96=B0?= =?UTF-8?q?cookie=E7=9B=B8=E5=AF=B9=E9=BA=BB=E7=83=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/dynamicwechat/__init__.py | 968 ++++++++++++++++++++++++ plugins/dynamicwechat/src/UpdateHelp.py | 81 ++ plugins/dynamicwechat/src/debug.py | 142 ++++ plugins/dynamicwechat/update_help.py | 109 +++ 4 files changed, 1300 insertions(+) create mode 100644 plugins/dynamicwechat/__init__.py create mode 100644 plugins/dynamicwechat/src/UpdateHelp.py create mode 100644 plugins/dynamicwechat/src/debug.py create mode 100644 plugins/dynamicwechat/update_help.py diff --git a/plugins/dynamicwechat/__init__.py b/plugins/dynamicwechat/__init__.py new file mode 100644 index 0000000..90151f6 --- /dev/null +++ b/plugins/dynamicwechat/__init__.py @@ -0,0 +1,968 @@ +from app.core.event import eventmanager, Event +import re +import time +import requests +import io +from playwright.sync_api import sync_playwright +from datetime import datetime, timedelta +import pytz +from typing import Optional +from app.schemas.types import EventType +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from app.log import logger +from app.plugins import _PluginBase +from app.core.config import settings +from app.helper.cookiecloud import CookieCloudHelper +from typing import Tuple, List, Dict, Any +from app.plugins.dynamicwechat.update_help import PyCookieCloud + + +# import UpdateHelp + + +class DynamicWeChat(_PluginBase): + # 插件名称 + plugin_name = "修改企业微信可信IP" + # 插件描述 + plugin_desc = "优先使用cookie,当填写两个第三方token时手机微信可以更新cookie。验证码以?结尾发给企业微信应用。如:110301?" + # 插件图标 + plugin_icon = "Wecom_A.png" + # 插件版本 + plugin_version = "1.1.2" + # 插件作者 + plugin_author = "RamenRa" + # 作者主页 + author_url = "https://github.com/RamenRa/MoviePilot-Plugins" + # 插件配置项ID前缀 + plugin_config_prefix = "dynamicwechat_" + # 加载顺序 + plugin_order = 47 + # 可使用的用户级别 + auth_level = 2 + + # 私有属性 + _enabled = False # 开关 + _cron = None + _onlyonce = False + # IP更改成功状态,防止检测IP改动但cookie失效的时候_current_ip_address已经更新成新IP导致后面刷新cookie也没有更改企微IP + _ip_changed = False + # 强制更改IP + _forced_update = False + _cc_server = None + _push_qr_now = False + + #匹配ip地址的正则 + _ip_pattern = r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b' + # 获取ip地址的网址列表 + _ip_urls = ["https://myip.ipip.net", "https://ddns.oray.com/checkip", "https://ip.3322.net", "https://4.ipw.cn"] + # 当前ip地址 + _current_ip_address = '0.0.0.0' + #企业微信登录 + _wechatUrl = 'https://work.weixin.qq.com/wework_admin/loginpage_wx?from=myhome' + #检测间隔时间,默认10分钟 + _refresh_cron = '*/20 * * * *' + # _urls = [] + _input_id_list = '' + _helloimg_s_token = "" + _pushplus_token = "" + # _standalone_chrome_address = "http://192.168.1.0:4444/wd/hub" + _qr_code_image = None + text = "" + user_id = "" + channel = "" + _app_ids = [] + + # -------cookie add------------ + # cookie有效检测 + # _cookie_valid = False + # 使用CookieCloud开关 + _use_cookiecloud = True + # 从CookieCloud获取的cookie + _cookie_from_CC = "" + # 登录cookie + _cookie_header = "" + _server = f'http://localhost:{settings.NGINX_PORT}/cookiecloud' + # -------cookie END------------ + + _cookiecloud = CookieCloudHelper() + # 定时器 + _scheduler: Optional[BackgroundScheduler] = None + + def init_plugin(self, config: dict = None): + self._server = f'http://localhost:{settings.NGINX_PORT}/cookiecloud' + # 清空配置 + # self._wechatUrl = 'https://work.weixin.qq.com/wework_admin/loginpage_wx?from=myhome' + # self._urls = [] + self._helloimg_s_token = '' + self._pushplus_token = '' + # self._standalone_chrome_address = "http://192.168.1.0:4444/wd/hub" + self._ip_changed = True + self._forced_update = False + # self._cookie_valid = False + self._use_cookiecloud = True + self._input_id_list = '' + self._cookie_header = "" + self._cookie_from_CC = "" + self._current_ip_address = self.get_ip_from_url(self._ip_urls[0]) + # logger.info(f"当前公网 IP: {self._current_ip_address}") + # logger.info(f"server host: {self._server} _uuid: {settings.COOKIECLOUD_KEY} _password: {settings.COOKIECLOUD_PASSWORD}") + if config: + self._enabled = config.get("enabled") + self._cron = config.get("cron") + self._onlyonce = config.get("onlyonce") + self._input_id_list = config.get("input_id_list") + self._current_ip_address = config.get("current_ip_address") + self._pushplus_token = config.get("pushplus_token") + self._helloimg_s_token = config.get("helloimg_s_token") + self._cookie_from_CC = config.get("cookie_from_CC") + self._forced_update = config.get("forced_update") + self._use_cookiecloud = config.get("use_cookiecloud") + self._cookie_header = config.get("cookie_header") + # self._standalone_chrome_address = config.get("standalone_chrome_address") + self._ip_changed = config.get("ip_changed") + if self._use_cookiecloud: + self._cc_server = PyCookieCloud(url=self._server, uuid=settings.COOKIECLOUD_KEY, + password=settings.COOKIECLOUD_PASSWORD) + + # 停止现有任务 + self.stop_service() + if self._enabled or self._onlyonce: + # 定时服务 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + # 运行一次定时服务 + if self._onlyonce or self._forced_update: + logger.info("立即检测公网IP") + self._scheduler.add_job(func=self.check, trigger='date', + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="检测公网IP") # 添加任务 + # 关闭一次性开关 + self._onlyonce = False + + # 固定半小时周期请求一次地址,防止cookie失效 + try: + self._scheduler.add_job(func=self.refresh_cookie, + trigger=CronTrigger.from_crontab(self._refresh_cron), + name="延续企业微信cookie有效时间") + except Exception as err: + logger.error(f"定时任务配置错误:{err}") + self.systemmessage.put(f"执行周期配置错误:{err}") + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + if self._forced_update: + time.sleep(4) + self._forced_update = False + self.__update_config() + + @eventmanager.register(EventType.PluginAction) + def check(self, event: Event = None): + """ + 检测函数 + """ + if not self._enabled: + logger.error("插件未开启") + return + + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "dynamicwechat": + return + logger.info("收到命令,开始检测公网IP ...") + self.post_message(channel=event.event_data.get("channel"), + title="开始检测公网IP ...", + userid=event.event_data.get("user")) + + logger.info("开始检测公网IP") + if self.CheckIP(): + self.ChangeIP() + self.__update_config() + + # logger.info("检测公网IP完毕") + logger.info("----------------------本次任务结束----------------------") + if event: + self.post_message(channel=event.event_data.get("channel"), + title="检测公网IP完毕", + userid=event.event_data.get("user")) + + def CheckIP(self): + # if not self._cookie_valid: + # self.refresh_cookie() + # if not self._cookie_valid: + # logger.error("请求企微失败,cookie可能过期,跳过IP检测") + # return False + for url in self._ip_urls: + ip_address = self.get_ip_from_url(url) + if ip_address != "获取IP失败" and ip_address: + logger.info(f"IP获取成功: {url}: {ip_address}") + break + # if ip_address == "获取IP失败" or not ip_address: + # logger.error(f"请求网址失败") + + # 如果所有 URL 请求失败 + if ip_address == "获取IP失败" or not ip_address: + logger.error("获取IP失败 不操作IP") + return False + + if self._forced_update: + logger.info("强制更新IP") + self._current_ip_address = ip_address + return True + elif not self._ip_changed: # 上次修改IP失败 + logger.info("上次IP修改IP没有成功 继续尝试修改IP") + self._current_ip_address = ip_address + return True + + # 检查 IP 是否变化 + if ip_address != self._current_ip_address: + logger.info("检测到IP变化") + self._current_ip_address = ip_address + # self._ip_changed = False + return True + else: + return False + + def get_ip_from_url(self, url): + try: + # 发送 GET 请求 + response = requests.get(url) + # 检查响应状态码是否为 200 + if response.status_code == 200: + # 解析响应 JSON 数据并获取 IP 地址 + ip_address = re.search(self._ip_pattern, response.text) + if ip_address: + return ip_address.group() + else: + return "获取IP失败" + else: + return "获取IP失败" + except Exception as e: + logger.warning(f"{url}获取IP失败,Error: {e}") + # return "获取IP失败" + + def find_qrc(self, page): + # 查找 iframe 元素并切换到它 + try: + page.wait_for_selector("iframe", timeout=5000) # 等待 iframe 加载 + iframe_element = page.query_selector("iframe") + frame = iframe_element.content_frame() + + # 查找二维码图片元素 + qr_code_element = frame.query_selector("img.qrcode_login_img") + if qr_code_element: + # logger.info("找到二维码图片元素") + # 保存二维码图片 + qr_code_url = qr_code_element.get_attribute('src') + if qr_code_url.startswith("/"): + qr_code_url = "https://work.weixin.qq.com" + qr_code_url # 补全二维码 URL + + qr_code_data = requests.get(qr_code_url).content + self._qr_code_image = io.BytesIO(qr_code_data) + return True + else: + logger.warning("未找到二维码") + return False + except Exception as e: + return False + + def remote_push_qr(self): + try: + with sync_playwright() as p: + # 启动 Chromium 浏览器并设置语言为中文 + browser = p.chromium.launch(headless=True, args=['--lang=zh-CN']) + context = browser.new_context() + # ----------cookie addd----------------- + # cookie = self.get_cookie() + # if cookie: + # context.add_cookies(cookie) + # ----------cookie END----------------- + page = context.new_page() + page.goto(self._wechatUrl) + time.sleep(3) + if self.find_qrc(page): + if self._pushplus_token and self._helloimg_s_token: + img_src, refuse_time = self.upload_image(self._qr_code_image) + self.send_pushplus_message(refuse_time, f"企业微信登录二维码
") + logger.info("二维码已经发送,等待用户 60 秒内扫码登录") + logger.info("如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301?") + time.sleep(60) + login_status = self.check_login_status(page) + if login_status: + self._update_cookie(page, context) # 刷新cookie + self.click_app_management_buttons(page) + else: + logger.warning("远程推送任务 未找到二维码") + except Exception as e: + logger.error(f"远程推送任务 推送二维码失败: {e}") + + + def ChangeIP(self): + logger.info("开始请求企业微信管理更改可信IP") + try: + with sync_playwright() as p: + # 启动 Chromium 浏览器并设置语言为中文 + browser = p.chromium.launch(headless=True, args=['--lang=zh-CN']) + context = browser.new_context() + # ----------cookie addd----------------- + cookie = self.get_cookie() + if cookie: + context.add_cookies(cookie) + # ----------cookie END----------------- + page = context.new_page() + page.goto(self._wechatUrl) + time.sleep(3) + if self.find_qrc(page): + if self._pushplus_token and self._helloimg_s_token: + img_src, refuse_time = self.upload_image(self._qr_code_image) + self.send_pushplus_message(refuse_time, f"企业微信登录二维码
") + logger.info("二维码已经发送,等待用户 60 秒内扫码登录") + logger.info("如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301?") + time.sleep(60) # 等待用户扫码 + login_status = self.check_login_status(page) + if login_status: + self._update_cookie(page, context) # 刷新cookie + self.click_app_management_buttons(page) + + else: + self._ip_changed = False + else: + logger.info("cookie失效,请使用CookieCloud重新上传。") + else: # 如果直接进入企业微信 + logger.info("尝试cookie登录") + # ----------cookie addd----------------- + login_status = self.check_login_status(page) + if login_status: + self.click_app_management_buttons(page) + else: + # ----------cookie END----------------- + self._ip_changed = False + return + browser.close() + + except Exception as e: + logger.error(f"更改可信IP失败: {e}") + finally: + pass + + def _update_cookie(self, page, context): + if self._use_cookiecloud: + logger.info("使用二维码登录成功,开始刷新cookie") + try: + # logger.info("debug 开始连接CookieCloud") + if self._cc_server.check_connection(): + logger.info("成功连接CookieCloud") + current_url = page.url + current_cookies = context.cookies(current_url) # 通过 context 获取 cookies + # logger.info("原始 cookies:", current_cookies) + formatted_cookies = {} + for cookie in current_cookies: + domain = cookie['domain'] + if domain not in formatted_cookies: + formatted_cookies[domain] = [] + formatted_cookies[domain].append(cookie) + flag = self._cc_server.update_cookie({'cookie_data': formatted_cookies}) + if flag: + logger.info("更新CookieCloud成功") + else: + logger.error("更新CookieCloud失败") + else: + logger.error("连接CookieCloud失败", self._server, settings.COOKIECLOUD_KEY, + settings.COOKIECLOUD_PASSWORD) + except Exception as e: + logger.error(f"更新cookie发生错误: {e}") + else: + logger.info("不使用CookieCloud, 不刷新cookie") + + # ----------cookie addd----------------- + def get_cookie(self): # 只有从CookieCloud获取cookie成功才返回True + try: + cookie_header = '' + if self._use_cookiecloud: + # if self._cookie_valid: # 如果无效 + # return self._cookie_from_CC + # return True + cookies, msg = self._cookiecloud.download() + if not cookies: # CookieCloud获取cookie失败 + logger.error(f"CookieCloud获取cookie失败,失败原因:{msg}") + return + # cookie_header = self._cookie_header + else: + for domain, cookie in cookies.items(): + if domain == ".work.weixin.qq.com": + cookie_header = cookie + break + if cookie_header == '': + cookie_header = self._cookie_header + else: # 不使用CookieCloud + cookie_header = self._cookie_header + # return + cookie = self.parse_cookie_header(cookie_header) + self._cookie_from_CC = cookie + return cookie + except Exception as e: + logger.error(f"从CookieCloud获取cookie错误,错误原因:{e}") + # logger.info("尝试推送登录二维码") + return + + def parse_cookie_header(self, cookie_header): + cookies = [] + for cookie in cookie_header.split(';'): + name, value = cookie.strip().split('=', 1) + cookies.append({ + 'name': name, + 'value': value, + 'domain': '.work.weixin.qq.com', + 'path': '/' + }) + return cookies + + def refresh_cookie(self): # 保活 + try: + with sync_playwright() as p: + browser = p.chromium.launch(headless=False, args=['--lang=zh-CN']) + context = browser.new_context() + cookie = self.get_cookie() + if cookie: + context.add_cookies(cookie) + # logger.info("给浏览器添加cookie成功") + # else: + # logger.info("给浏览器添加cookie失败") + page = context.new_page() + logger.info("延长cookie任务开始") + page.goto(self._wechatUrl) + time.sleep(3) + # 检查登录元素是否可见 + if self.check_login_status(page): + logger.info("延长cookie任务成功") + # self._cookie_valid = True + else: + logger.info("cookie已失效,下次IP变动推送二维码") + # self._cookie_valid = False + browser.close() + except Exception as e: + logger.error(f"cookie校验失败:{e}") + # self._cookie_valid = False + + # + def check_login_status(self, page): + # 等待页面加载 + time.sleep(3) + # 检查是否需要进行短信验证 + logger.info("检查登录状态...") + try: + # 先检查登录成功后的页面状态 + success_element = page.wait_for_selector('#check_corp_info', timeout=5000) # 检查登录成功的元素 + if success_element: + logger.info("登录成功!") + return True + except Exception as e: + # logger.error(f"检查登录状态时发生错误: {e}") + pass + + try: + # 在这里使用更安全的方式来检查元素是否存在 + captcha_panel = page.wait_for_selector('.receive_captcha_panel', timeout=5000) # 检查验证码面板 + if captcha_panel: # 出现了短信验证界面 + time.sleep(10) # 多等10秒 + if self.text[:6]: + logger.info("需要短信验证 收到的短信验证码:" + self.text[:6]) + for digit in self.text[:6]: + page.keyboard.press(digit) + time.sleep(0.3) # 每个数字之间添加少量间隔以确保输入顺利 + confirm_button = page.wait_for_selector('.confirm_btn', timeout=5000) # 获取确认按钮 + confirm_button.click() # 点击确认 + time.sleep(3) # 等待处理 + # 等待登录成功的元素出现 + success_element = page.wait_for_selector('#check_corp_info', timeout=10000) + if success_element: + logger.info("验证码登录成功!") + return True + else: + logger.error("未收到短信验证码") + return False + except Exception as e: + # try: # 没有登录成功,也没有短信验证码。 查找二维码是否还存在 + if self.find_qrc(page): + logger.error(f"用户没有扫描二维码") + return False + + def click_app_management_buttons(self, page): + bash_url = "https://work.weixin.qq.com/wework_admin/frame#apps/modApiApp/" + # 按钮的选择器和名称 + buttons = [ + # ("//span[@class='frame_nav_item_title' and text()='应用管理']", "应用管理"), + # ("//div[@class='app_index_item_title ' and contains(text(), 'MoviePilot')]", "MoviePilot"), + ( + "//div[contains(@class, 'js_show_ipConfig_dialog')]//a[contains(@class, '_mod_card_operationLink') and text()='配置']", + "配置") + ] + if self._input_id_list: + id_list = self._input_id_list.split(",") + app_urls = [f"{bash_url}{app_id.strip()}" for app_id in id_list] + for app_url in app_urls: + page.goto(app_url) # 打开应用详情页 + # logger.info(f"已打开{app_url}") + time.sleep(2) + # 依次点击每个按钮 + for xpath, name in buttons: + # 等待按钮出现并可点击 + try: + button = page.wait_for_selector(xpath, timeout=5000) # 等待按钮可点击 + button.click() + # logger.info(f"已点击 '{name}' 按钮") + page.wait_for_selector('textarea.js_ipConfig_textarea', timeout=5000) + # logger.info(f"已找到文本框") + input_area = page.locator('textarea.js_ipConfig_textarea') + confirm = page.locator('.js_ipConfig_confirmBtn') + input_area.fill(self._current_ip_address) # 填充 IP 地址 + logger.info("已输入公网IP:" + self._current_ip_address) + confirm.click() # 点击确认按钮 + time.sleep(3) # 等待处理 + self._ip_changed = True + except Exception as e: + logger.error(f"未能找打开{app_url}或点击 '{name}' 按钮异常: {e}") + self._ip_changed = False + if "disabled" in str(e): + logger.info("该应用已被禁用,可能是没有设置接收api") + return + else: + logger.error("未找到应用id,修改IP失败") + return + + def send_pushplus_message(self, title, content): + pushplus_url = f"http://www.pushplus.plus/send/{self._pushplus_token}" + pushplus_data = { + "title": title, + "content": content, + "template": "html" + } + # if wait_time > 2: + # # time.sleep(wait_time) + # logger.info(f"pushplus API 调用次数限制,本次不发送 至少间隔 {wait_time} 秒") + # else: + response = requests.post(pushplus_url, json=pushplus_data) + # return response + + def upload_image(self, file_obj, permission=1, strategy_id=1, album_id=1): + """ + 上传图片到 helloimg 图床,支持传入文件路径或 BytesIO 对象。 + + :param file_obj: 文件对象,可以是路径 (str) 或 BytesIO 对象 + :param permission: 上传图片的权限设置,默认 1 + :param strategy_id: 上传策略 ID,默认 1 + :param album_id: 相册 ID,默认 1 + :return: 上传成功返回图片链接,失败返回 None + """ + helloimg_token = "Bearer " + self._helloimg_s_token + helloimg_url = "https://www.helloimg.com/api/v1/upload" + headers = { + "Authorization": helloimg_token, + "Accept": "application/json", + } + + # 构造上传的文件,支持传入 BytesIO 或文件路径 + if isinstance(file_obj, io.BytesIO): + # 如果是 BytesIO 对象,直接使用 + files = { + "file": ('qr_code.png', file_obj, 'image/png') + } + else: + # 如果是文件路径,打开文件进行读取 + files = { + "file": open(file_obj, "rb") + } + + expired_at = (datetime.now() + timedelta(hours=24)).strftime("%Y-%m-%d %H:%M:%S") + helloimg_data = { + "token": "你的临时上传 Token", # 确保这里的 token 是有效的 + "permission": permission, + "strategy_id": strategy_id, + "album_id": album_id, + "expired_at": expired_at + } + refuse_time = (datetime.now() + timedelta(seconds=110)).strftime("%Y-%m-%d %H:%M:%S") + + # 发送上传请求 + response = requests.post(helloimg_url, headers=headers, files=files, data=helloimg_data) + + # 检查响应内容是否符合预期 + try: + response_data = response.json() + if not response_data['status']: + if response_data['message'] == "Unauthenticated.": + logger.error("Token失效,无法上传图片。请检查你的上传Token。") + logger.info(f"使用的Token: {helloimg_token}") + # self._ip_changed = False + return + else: + logger.error(f"上传到图床失败: {response_data['message']}") + self._ip_changed = False + return + + img_src = response_data['data']['links']['html'] + return img_src.split('"')[1], refuse_time # 提取 img src + except KeyError as e: + logger.error(f"上传图片时解析响应失败: {e}, 响应内容: {response_data}") + logger.info("本次操作终止") + self._ip_changed = False + return + + def __update_config(self): + """ + 更新配置 + """ + self.update_config({ + "enabled": self._enabled, + "onlyonce": self._onlyonce, + "cron": self._cron, + # "wechatUrl": self._wechatUrl, + "current_ip_address": self._current_ip_address, + "ip_changed": self._ip_changed, + "forced_update": self._forced_update, + "helloimg_s_token": self._helloimg_s_token, + "pushplus_token": self._pushplus_token, + "input_id_list": self._input_id_list, + # "standalone_chrome_address": self._standalone_chrome_address, + + "cookie_from_CC": self._cookie_from_CC, + "cookie_header": self._cookie_header, + "use_cookiecloud": self._use_cookiecloud, + }) + + def get_state(self) -> bool: + return self._enabled + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,只保留必要的配置项,并添加 token 配置。 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即检测一次', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'forced_update', + 'label': '强制更新', + } + } + ] + } + ] + }, + # 添加 "使用CookieCloud获取cookie" 开关按钮 + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'use_cookiecloud', + 'label': '使用CookieCloud', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '检测周期', + 'placeholder': '0 * * * *' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'cookie_header', + 'label': 'COOKIE', + 'rows': 1, + 'placeholder': '手动填写cookie' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'input_id_list', + 'label': '应用ID', + 'rows': 1, + 'placeholder': '输入应用ID,多个ID用英文逗号分隔。在企业微信应用页面URL末尾获取' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'pushplus_token', + 'label': 'pushplus_token', + 'rows': 1, + 'placeholder': '[可选] 请输入 pushplus_token' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'helloimg_s_token', + 'label': 'helloimg_s_token', + 'rows': 1, + 'placeholder': '[可选] 请输入 helloimg_token' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '* 强制更新和立即检测按钮属于一次性按钮。使用CookieCloud请到设置打开“本地CookieCloud”。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'text': '本插件优先使用cookie,当需要修改IP时cookie失效填写了两个token时会推送登录二维码到微信。', + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "cron": "", + "onlyonce": False, + "forceUpdate": False, + "use_cookiecloud": True, # 新增的模型字段 + # "wechatUrl": "", + "cookie_header": "", + "pushplus_token": "", + "helloimg_token": "", + "input_id_list": "", + "standalone_chrome_address": "" + } + + def get_page(self) -> List[dict]: + pass + + @eventmanager.register(EventType.PluginAction) + def push_qr(self, event: Event = None): + """ + 发送二维码 + """ + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "push_qrcode": + return + logger.info("远程命令开始推送二维码") + self.remote_push_qr() + + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + return [ + { + "cmd": "/push_qr_code", + "event": EventType.PluginAction, + "desc": "立即推送登录二维码到微信", + "category": "", + "data": { + "action": "push_qrcode" + } + } + ] + + def get_api(self) -> List[Dict[str, Any]]: + pass + + + + @eventmanager.register(EventType.UserMessage) + def talk(self, event: Event): + """ + 监听用户消息 + """ + if not self._enabled: + return + self.text = event.event_data.get("text") + # self.user_id = event.event_data.get("userid") + # self.channel = event.event_data.get("channel") + if self.text and len(self.text) == 7: + logger.info(f"收到验证码:{self.text}") + else: + logger.info(f"收到消息:{self.text}") + + 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 and self._cron: + logger.info(f"{self.plugin_name}定时服务启动,时间间隔 {self._cron} ") + return [{ + "id": self.__class__.__name__, + "name": f"{self.plugin_name}服务", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self.check, + "kwargs": {} + }] + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._event.set() + self._scheduler.shutdown() + self._event.clear() + self._scheduler = None + except Exception as e: + logger.error(str(e)) diff --git a/plugins/dynamicwechat/src/UpdateHelp.py b/plugins/dynamicwechat/src/UpdateHelp.py new file mode 100644 index 0000000..20a9146 --- /dev/null +++ b/plugins/dynamicwechat/src/UpdateHelp.py @@ -0,0 +1,81 @@ +import hashlib +from typing import Dict, Any +import json +import requests +from urllib.parse import urljoin +from Cryptodome import Random +from Cryptodome.Cipher import AES +import base64 + +BLOCK_SIZE = 16 + +def pad(data): + length = BLOCK_SIZE - (len(data) % BLOCK_SIZE) + return data + (chr(length) * length).encode() + +def bytes_to_key(data, salt, output=48): + # extended from https://gist.github.com/gsakkis/4546068 + assert len(salt) == 8, len(salt) + data += salt + key = hashlib.md5(data).digest() + final_key = key + while len(final_key) < output: + key = hashlib.md5(key + data).digest() + final_key += key + return final_key[:output] + +def encrypt(message, passphrase): + salt = Random.new().read(8) + key_iv = bytes_to_key(passphrase, salt, 32 + 16) + key = key_iv[:32] + iv = key_iv[32:] + aes = AES.new(key, AES.MODE_CBC, iv) + return base64.b64encode(b"Salted__" + salt + aes.encrypt(pad(message))) + +class PyCookieCloud: + def __init__(self, url: str, uuid: str, password: str): + self.url: str = url + self.uuid: str = uuid + self.password: str = password + + def check_connection(self) -> bool: + """ + Test the connection to the CookieCloud server. + + :return: True if the connection is successful, False otherwise. + """ + try: + resp = requests.get(self.url) + if resp.status_code == 200: + return True + else: + return False + except Exception as e: + return False + + def update_cookie(self, cookie: Dict[str, Any]) -> bool: + """ + Update cookie data to CookieCloud. + + :param cookie: cookie value to update, if this cookie does not contain 'cookie_data' key, it will be added into 'cookie_data'. + :return: if update success, return True, else return False. + """ + if 'cookie_data' not in cookie: + cookie = {'cookie_data': cookie} + raw_data = json.dumps(cookie) + encrypted_data = encrypt(raw_data.encode('utf-8'), self.get_the_key().encode('utf-8')).decode('utf-8') + cookie_cloud_request = requests.post(urljoin(self.url, '/update'), data={'uuid': self.uuid, 'encrypted': encrypted_data}) + if cookie_cloud_request.status_code == 200: + if cookie_cloud_request.json()['action'] == 'done': + return True + return False + + def get_the_key(self) -> str: + """ + Get the key used to encrypt and decrypt data. + + :return: the key. + """ + md5 = hashlib.md5() + md5.update((self.uuid + '-' + self.password).encode('utf-8')) + return md5.hexdigest()[:16] \ No newline at end of file diff --git a/plugins/dynamicwechat/src/debug.py b/plugins/dynamicwechat/src/debug.py new file mode 100644 index 0000000..e91396a --- /dev/null +++ b/plugins/dynamicwechat/src/debug.py @@ -0,0 +1,142 @@ + +from Cryptodome import Random +from Cryptodome.Cipher import AES +import base64 +import json +import hashlib +import requests +from playwright.sync_api import sync_playwright +from typing import Dict, Any + + +class PyCookieCloud: + def __init__(self, url: str, uuid: str, password: str): + self.url: str = url + self.uuid: str = uuid + self.password: str = password + self.BLOCK_SIZE = 16 + + def check_connection(self) -> bool: + """ + Test the connection to the CookieCloud server. + + :return: True if the connection is successful, False otherwise. + """ + try: + resp = requests.get(self.url) + # print(self.url) + if resp.status_code == 200: + return True + else: + return False + except Exception as e: + return False + + def update_cookie(self, cookie: Dict[str, Any]) -> bool: + """ + Update cookie data to CookieCloud. + + :param cookie: cookie value to update, if this cookie does not contain 'cookie_data' key, it will be added into 'cookie_data'. + :return: if update success, return True, else return False. + """ + # 确保 cookie 是完整的结构,并直接放入 cookie_data 中 + # cookie_data = { + # "cookie_data": cookie # 直接将 cookie 数据放入 cookie_data + # } + raw_data = json.dumps(cookie) + encrypted_data = self.encrypt(raw_data.encode('utf-8'), self.get_the_key().encode('utf-8')).decode('utf-8') + + request_data = {'uuid': self.uuid, 'encrypted': encrypted_data} + print("请求数据:", request_data) # 打印请求数据 + # headers = {'Content-Type': 'application/json'} # 设置请求头为 JSON + cookie_cloud_request = requests.post(self.url + '/update', json=request_data) + print(cookie_cloud_request) # 打印响应对象 + + if cookie_cloud_request.status_code != 200: + print("错误信息:", cookie_cloud_request.text) # 打印错误信息 + + if cookie_cloud_request.status_code == 200: + if cookie_cloud_request.json().get('action') == 'done': + return True + return False + + def get_the_key(self) -> str: + """ + Get the key used to encrypt and decrypt data. + + :return: the key. + """ + md5 = hashlib.md5() + md5.update((self.uuid + '-' + self.password).encode('utf-8')) + return md5.hexdigest()[:16] + + def bytes_to_key(self, data, salt, output=48): + # extended from https://gist.github.com/gsakkis/4546068 + assert len(salt) == 8, len(salt) + data += salt + key = hashlib.md5(data).digest() + final_key = key + while len(final_key) < output: + key = hashlib.md5(key + data).digest() + final_key += key + return final_key[:output] + + def pad(self, data): + length = self.BLOCK_SIZE - (len(data) % self.BLOCK_SIZE) + return data + (chr(length) * length).encode() + + def encrypt(self, message: bytes, passphrase: bytes) -> bytes: + # 请替换为实际的加密实现,以下是示例 + # 使用 AES 或其他算法进行加密 + # 这里只是一个占位符,实际实现请根据需要修改 + # def encrypt(message, passphrase): + salt = Random.new().read(8) + key_iv = self.bytes_to_key(passphrase, salt, 32 + 16) + key = key_iv[:32] + iv = key_iv[32:] + aes = AES.new(key, AES.MODE_CBC, iv) + return base64.b64encode(b"Salted__" + salt + aes.encrypt(self.pad(message))) + # return message # 示例:返回原始消息 + + +def main(server: str, url: str, uuid: str, password: str): + with sync_playwright() as p: + browser = p.chromium.launch(headless=False) + page = browser.new_page() + + # 打开指定的 URL + page.goto(url) + + # 等待 60 秒用户登录 + print("请在30秒内完成登录...") + page.wait_for_timeout(30000) # 等待60秒 + + # 获取 cookies + cookies = page.context.cookies() + + # 关闭浏览器 + browser.close() + + # 创建 PyCookieCloud 实例并上传 cookies + py_cookie_cloud = PyCookieCloud(url=server, uuid=uuid, password=password) + cookie_data = {cookie['name']: cookie['value'] for cookie in cookies} # 转换为字典形式 + if (py_cookie_cloud.check_connection()): + print("连接成功,请稍等片刻...") + result = py_cookie_cloud.update_cookie(cookie_data) + else: + print("连接失败,请检查网络连接") + result = False + + if result: + print("Cookies 上传成功!") + else: + print("Cookies 上传失败!") + + +if __name__ == "__main__": + # 设置参数 + server = "http://172.16.8.110:43000/cookiecloud" + target_url = "https://work.weixin.qq.com/wework_admin/loginpage_wx?from=myhome" # 请替换为实际的目标 URL + uuid = "hFQrymvqMBX11d14TTmKb6" # 替换为实际的 UUID + password = "2Bfr3LmzVy3t3bsQ5FLAbZ" # 替换为实际的密码 + main(server, target_url, uuid, password) diff --git a/plugins/dynamicwechat/update_help.py b/plugins/dynamicwechat/update_help.py new file mode 100644 index 0000000..f8a02a7 --- /dev/null +++ b/plugins/dynamicwechat/update_help.py @@ -0,0 +1,109 @@ +import base64 +import hashlib +from typing import Dict, Any +import json +import requests +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.backends import default_backend +from os import urandom + +BLOCK_SIZE = 16 + +def pad(data: bytes) -> bytes: + padder = padding.PKCS7(algorithms.AES.block_size).padder() + padded_data = padder.update(data) + padder.finalize() + return padded_data + +def unpad(data: bytes) -> bytes: + unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder() + unpadded_data = unpadder.update(data) + unpadder.finalize() + return unpadded_data + +def bytes_to_key(data: bytes, salt: bytes, output: int = 48) -> bytes: + assert len(salt) == 8, len(salt) + data += salt + key = hashlib.md5(data).digest() + final_key = key + while len(final_key) < output: + key = hashlib.md5(key + data).digest() + final_key += key + return final_key[:output] + +def encrypt(message: bytes, passphrase: bytes) -> bytes: + salt = urandom(8) + key_iv = bytes_to_key(passphrase, salt, 32 + 16) + key = key_iv[:32] + iv = key_iv[32:] + + # Create AES cipher object + cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend()) + encryptor = cipher.encryptor() + + encrypted_message = encryptor.update(pad(message)) + encryptor.finalize() + return base64.b64encode(b"Salted__" + salt + encrypted_message) + +def decrypt(encrypted: bytes, passphrase: bytes) -> bytes: + encrypted = base64.b64decode(encrypted) + assert encrypted[0:8] == b"Salted__" + salt = encrypted[8:16] + key_iv = bytes_to_key(passphrase, salt, 32 + 16) + key = key_iv[:32] + iv = key_iv[32:] + + # Create AES cipher object + cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend()) + decryptor = cipher.decryptor() + + decrypted_message = decryptor.update(encrypted[16:]) + decryptor.finalize() + return unpad(decrypted_message) + +class PyCookieCloud: + def __init__(self, url: str, uuid: str, password: str): + self.url: str = url + self.uuid: str = uuid + self.password: str = password + + def check_connection(self) -> bool: + """ + Test the connection to the CookieCloud server. + + :return: True if the connection is successful, False otherwise. + """ + try: + resp = requests.get(self.url) + if resp.status_code == 200: + return True + else: + return False + except Exception as e: + return False + + def update_cookie(self, cookie: Dict[str, Any]) -> bool: + """ + Update cookie data to CookieCloud. + + :param cookie: cookie value to update, if this cookie does not contain 'cookie_data' key, it will be added into 'cookie_data'. + :return: if update success, return True, else return False. + """ + if 'cookie_data' not in cookie: + cookie = {'cookie_data': cookie} + raw_data = json.dumps(cookie) + encrypted_data = encrypt(raw_data.encode('utf-8'), self.get_the_key().encode('utf-8')).decode('utf-8') + cookie_cloud_request = requests.post(self.url + '/update', json={'uuid': self.uuid, 'encrypted': encrypted_data}) + if cookie_cloud_request.status_code == 200: + if cookie_cloud_request.json()['action'] == 'done': + return True + return False + + def get_the_key(self) -> str: + """ + Get the key used to encrypt and decrypt data. + + :return: the key. + """ + md5 = hashlib.md5() + md5.update((self.uuid + '-' + self.password).encode('utf-8')) + return md5.hexdigest()[:16] + + From 5f0b60f331936ac844b661af21ed334b9c90f6d4 Mon Sep 17 00:00:00 2001 From: ramen <1205925392@qq.com> Date: Wed, 16 Oct 2024 00:24:42 +0800 Subject: [PATCH 094/218] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=AD=89=E5=BE=85?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E6=89=AB=E7=A0=81=E7=9A=84=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E5=88=B090=E7=A7=92=EF=BC=8C=E5=92=8C=E9=9C=80=E8=A6=81?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E7=A0=81=E6=97=B6=E5=86=8D=E7=AD=89=E5=BE=85?= =?UTF-8?q?30=E7=A7=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/dynamicwechat/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/dynamicwechat/__init__.py b/plugins/dynamicwechat/__init__.py index 90151f6..3696319 100644 --- a/plugins/dynamicwechat/__init__.py +++ b/plugins/dynamicwechat/__init__.py @@ -285,9 +285,9 @@ class DynamicWeChat(_PluginBase): if self._pushplus_token and self._helloimg_s_token: img_src, refuse_time = self.upload_image(self._qr_code_image) self.send_pushplus_message(refuse_time, f"企业微信登录二维码
") - logger.info("二维码已经发送,等待用户 60 秒内扫码登录") + logger.info("二维码已经发送,等待用户 90 秒内扫码登录") logger.info("如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301?") - time.sleep(60) + time.sleep(90) login_status = self.check_login_status(page) if login_status: self._update_cookie(page, context) # 刷新cookie @@ -317,9 +317,9 @@ class DynamicWeChat(_PluginBase): if self._pushplus_token and self._helloimg_s_token: img_src, refuse_time = self.upload_image(self._qr_code_image) self.send_pushplus_message(refuse_time, f"企业微信登录二维码
") - logger.info("二维码已经发送,等待用户 60 秒内扫码登录") + logger.info("二维码已经发送,等待用户 90 秒内扫码登录") logger.info("如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301?") - time.sleep(60) # 等待用户扫码 + time.sleep(90) # 等待用户扫码 login_status = self.check_login_status(page) if login_status: self._update_cookie(page, context) # 刷新cookie @@ -465,7 +465,7 @@ class DynamicWeChat(_PluginBase): # 在这里使用更安全的方式来检查元素是否存在 captcha_panel = page.wait_for_selector('.receive_captcha_panel', timeout=5000) # 检查验证码面板 if captcha_panel: # 出现了短信验证界面 - time.sleep(10) # 多等10秒 + time.sleep(30) # 多等30秒 if self.text[:6]: logger.info("需要短信验证 收到的短信验证码:" + self.text[:6]) for digit in self.text[:6]: From 2da21afeb9e0915d7450007db24976c9779472c3 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Wed, 16 Oct 2024 00:50:22 +0800 Subject: [PATCH 095/218] chore: update V2_Plugin_Development.md --- docs/V2_Plugin_Development.md | 262 ++++++++++++++++++++++++---------- 1 file changed, 188 insertions(+), 74 deletions(-) diff --git a/docs/V2_Plugin_Development.md b/docs/V2_Plugin_Development.md index d9dd7ce..05c11de 100644 --- a/docs/V2_Plugin_Development.md +++ b/docs/V2_Plugin_Development.md @@ -1,18 +1,19 @@ -# MoviePilot V2 插件开发指南 +# MoviePilot V2 插件开发指南(更新版) -本指南详细介绍了如何开发适用于MoviePilot V2版本的插件,并实现插件的多版本兼容性,同时包括了服务封装类的使用示例,帮助开发者快速升级插件至V2版本。 +本指南详细介绍了如何开发适用于 MoviePilot V2 版本的插件,并实现插件的多版本兼容性,同时包括了服务封装类的使用示例,帮助开发者快速升级插件至 V2 版本。 ## 1. 多版本插件开发与兼容性 -### 1.1 开发V2版本的插件 +### 1.1 开发 V2 版本的插件 -要开发适用于MoviePilot V2版本的插件,并实现多版本兼容性,请按照以下步骤操作: +要开发适用于 MoviePilot V2 版本的插件,请按照以下步骤操作: 1. **目录结构调整**: - - 将插件代码放置在`plugins.v2`文件夹中。 - - 将插件的定义放置在`package.v2.json`中,以实现该插件仅在MoviePilot V2版本中可见。 + - 将插件代码放置在 `plugins.v2` 文件夹中。 + - 将插件的定义放置在 `package.v2.json` 中,以实现该插件仅在 MoviePilot V2 版本中可见。 2. **插件定义示例**: + ```json { "CustomSites": { @@ -27,21 +28,9 @@ } ``` -3. **版本判断**: - - MoviePilot V2中 Settings 模块新增了`VERSION_FLAG`属性,V2版本值为`v2`,可通过以下代码判断当前的版本,以便在插件中兼容处理: - - ```python - from app.core.config import settings - - if hasattr(settings, 'VERSION_FLAG'): - version = settings.VERSION_FLAG # V2 - else: - version = "v1" - ``` - ### 1.2 实现插件多版本兼容 -如果V1版本插件在V2版本中实际可用,或在插件中主动兼容了V1和V2版本,则可以在`package.json`中定义 `"v2": true`属性,以便在MoviePilot V2版本插件市场中显示。 +如果 V1 版本插件在 V2 版本中实际可用,或在插件中主动兼容了 V1 和 V2 版本,则可以在 `package.json` 中定义 `"v2": true` 属性,以便在 MoviePilot V2 版本插件市场中显示。 ```json { @@ -59,6 +48,7 @@ ``` - **目录结构示例**: + ``` plugins/ ├── customsites/ @@ -74,7 +64,7 @@ - **插件代码中实现版本兼容**: - 在插件代码中,可以根据`version`变量执行不同的逻辑,以适应不同的MoviePilot版本。 + 在插件代码中,可以根据 `version` 变量执行不同的逻辑,以适应不同的 MoviePilot 版本。 ```python from app.core.config import settings @@ -130,15 +120,84 @@ class ServiceInfo: config: Optional[Any] = None ``` +#### `ServiceConfigHelper` +`ServiceConfigHelper` 是一个配置帮助类,用于获取不同类型的服务配置。 + +```python +from typing import List, Optional + +from app.db.systemconfig_oper import SystemConfigOper +from app.schemas import DownloaderConf, MediaServerConf, NotificationConf, NotificationSwitchConf + +class ServiceConfigHelper: + """ + 配置帮助类,获取不同类型的服务配置 + """ + + @staticmethod + def get_configs(config_key: SystemConfigKey, conf_type: Type) -> List: + """ + 通用获取配置的方法,根据 config_key 获取相应的配置并返回指定类型的配置列表 + + :param config_key: 系统配置的 key + :param conf_type: 用于实例化配置对象的类类型 + :return: 配置对象列表 + """ + config_data = SystemConfigOper().get(config_key) + if not config_data: + return [] + # 直接使用 conf_type 来实例化配置对象 + return [conf_type(**conf) for conf in config_data] + + @staticmethod + def get_downloader_configs() -> List[DownloaderConf]: + """ + 获取下载器的配置 + """ + return ServiceConfigHelper.get_configs(SystemConfigKey.Downloaders, DownloaderConf) + + @staticmethod + def get_mediaserver_configs() -> List[MediaServerConf]: + """ + 获取媒体服务器的配置 + """ + return ServiceConfigHelper.get_configs(SystemConfigKey.MediaServers, MediaServerConf) + + @staticmethod + def get_notification_configs() -> List[NotificationConf]: + """ + 获取消息通知渠道的配置 + """ + return ServiceConfigHelper.get_configs(SystemConfigKey.Notifications, NotificationConf) + + @staticmethod + def get_notification_switches() -> List[NotificationSwitchConf]: + """ + 获取消息通知场景的开关 + """ + return ServiceConfigHelper.get_configs(SystemConfigKey.NotificationSwitchs, NotificationSwitchConf) + + @staticmethod + def get_notification_switch(mtype: NotificationType) -> Optional[str]: + """ + 获取指定类型的消息通知场景的开关 + """ + switchs = ServiceConfigHelper.get_notification_switches() + for switch in switchs: + if switch.type == mtype.value: + return switch.action + return None +``` + #### `ServiceBaseHelper` `ServiceBaseHelper` 是一个通用的服务帮助类,提供了获取配置和服务实例的通用逻辑。 ```python from typing import Dict, List, Optional, Type, TypeVar, Generic, Iterator + from app.core.module import ModuleManager -from app.helper.serviceconfig import ServiceConfigHelper from app.schemas import ServiceInfo -from app.schemas.types import SystemConfigKey +from app.schemas.types import SystemConfigKey, ModuleType TConf = TypeVar("TConf") @@ -147,15 +206,18 @@ class ServiceBaseHelper(Generic[TConf]): 通用服务帮助类,抽象获取配置和服务实例的通用逻辑 """ - def __init__(self, config_key: SystemConfigKey, conf_type: Type[TConf], modules: List[str]): + def __init__(self, config_key: SystemConfigKey, conf_type: Type[TConf], module_type: ModuleType): self.modulemanager = ModuleManager() self.config_key = config_key self.conf_type = conf_type - self.modules = modules + self.module_type = module_type def get_configs(self, include_disabled: bool = False) -> Dict[str, TConf]: """ 获取配置列表 + + :param include_disabled: 是否包含禁用的配置,默认 False(仅返回启用的配置) + :return: 配置字典 """ configs: List[TConf] = ServiceConfigHelper.get_configs(self.config_key, self.conf_type) return { @@ -178,8 +240,8 @@ class ServiceBaseHelper(Generic[TConf]): 迭代所有模块的实例及其对应的配置,返回 ServiceInfo 实例 """ configs = self.get_configs() - for module_name in self.modules: - module = self.modulemanager.get_running_module(module_name) + modules = self.modulemanager.get_running_type_modules(self.module_type) + for module in modules: if not module: continue module_instances = module.get_instances() @@ -198,19 +260,33 @@ class ServiceBaseHelper(Generic[TConf]): ) yield service_info - def get_services(self, type_filter: Optional[str] = None) -> Dict[str, ServiceInfo]: + def get_services(self, type_filter: Optional[str] = None, name_filters: Optional[List[str]] = None) \ + -> Dict[str, ServiceInfo]: """ - 获取服务信息列表,并根据类型过滤 + 获取服务信息列表,并根据类型和名称列表进行过滤 + + :param type_filter: 需要过滤的服务类型 + :param name_filters: 需要过滤的服务名称列表 + :return: 过滤后的服务信息字典 """ + name_filters_set = set(name_filters) if name_filters else None + return { service_info.name: service_info for service_info in self.iterate_module_instances() - if service_info.config and (type_filter is None or service_info.type == type_filter) + if service_info.config and ( + type_filter is None or service_info.type == type_filter + ) and ( + name_filters_set is None or service_info.name in name_filters_set) } def get_service(self, name: str, type_filter: Optional[str] = None) -> Optional[ServiceInfo]: """ 获取指定名称的服务信息,并根据类型过滤 + + :param name: 服务名称 + :param type_filter: 需要过滤的服务类型 + :return: 对应的服务信息,若不存在或类型不匹配则返回 None """ if not name: return None @@ -231,9 +307,9 @@ class ServiceBaseHelper(Generic[TConf]): ```python from typing import Optional -from app.helper.servicebase import ServiceBaseHelper +from app.helper.service import ServiceBaseHelper from app.schemas import DownloaderConf, ServiceInfo -from app.schemas.types import SystemConfigKey +from app.schemas.types import SystemConfigKey, ModuleType class DownloaderHelper(ServiceBaseHelper[DownloaderConf]): @@ -241,82 +317,119 @@ class DownloaderHelper(ServiceBaseHelper[DownloaderConf]): 下载器帮助类 """ - def __init__(self, config: dict = None): + def __init__(self): super().__init__( config_key=SystemConfigKey.Downloaders, conf_type=DownloaderConf, - modules=["QbittorrentModule", "TransmissionModule"] + module_type=ModuleType.Downloader ) - def is_qbittorrent(self, service: Optional[ServiceInfo] = None, name: Optional[str] = None) -> bool: + def is_downloader( + self, + service_type: Optional[str] = None, + service: Optional[ServiceInfo] = None, + name: Optional[str] = None, + ) -> bool: """ - 判断指定的下载器是否为 qbittorrent 类型,需要传入 `service` 或 `name` 中的任一参数 + 通用的下载器类型判断方法 + :param service_type: 下载器的类型名称(如 'qbittorrent', 'transmission') :param service: 要判断的服务信息 :param name: 服务的名称 - :return: 如果服务类型为 qbittorrent,返回 True;否则返回 False。 + :return: 如果服务类型或实例为指定类型,返回 True;否则返回 False """ - if not service: - service = self.get_service(name=name) - return service.type == "qbittorrent" if service else False + # 如果未提供 service 则通过 name 获取服务 + service = service or self.get_service(name=name) - def is_transmission(self, service: Optional[ServiceInfo] = None, name: Optional[str] = None) -> bool: - """ - 判断指定的下载器是否为 transmission 类型,需要传入 `service` 或 `name` 中的任一参数 - - :param service: 要判断的服务信息 - :param name: 服务的名称 - :return: 如果服务类型为 transmission,返回 True;否则返回 False。 - """ - if not service: - service = self.get_service(name=name) - return service.type == "transmission" if service else False + # 判断服务类型是否为指定类型 + return bool(service and service.type == service_type) ``` #### `MediaServerHelper` 用于管理媒体服务器服务。 ```python -from app.helper.servicebase import ServiceBaseHelper -from app.schemas import MediaServerConf -from app.schemas.types import SystemConfigKey +from typing import Optional + +from app.helper.service import ServiceBaseHelper +from app.schemas import MediaServerConf, ServiceInfo +from app.schemas.types import SystemConfigKey, ModuleType + class MediaServerHelper(ServiceBaseHelper[MediaServerConf]): """ 媒体服务器帮助类 """ - def __init__(self, config: dict = None): + def __init__(self): super().__init__( config_key=SystemConfigKey.MediaServers, conf_type=MediaServerConf, - modules=["PlexModule", "EmbyModule", "JellyfinModule"] + module_type=ModuleType.MediaServer ) - - ... + + def is_media_server( + self, + service_type: Optional[str] = None, + service: Optional[ServiceInfo] = None, + name: Optional[str] = None, + ) -> bool: + """ + 通用的媒体服务器类型判断方法 + :param service_type: 媒体服务器的类型名称(如 'plex', 'emby', 'jellyfin') + :param service: 要判断的服务信息 + :param name: 服务的名称 + :return: 如果服务类型或实例为指定类型,返回 True;否则返回 False + """ + # 如果未提供 service 则通过 name 获取服务 + service = service or self.get_service(name=name) + + # 判断服务类型是否为指定类型 + return bool(service and service.type == service_type) ``` #### `NotificationHelper` 用于管理消息通知服务。 ```python -from app.helper.servicebase import ServiceBaseHelper -from app.schemas import NotificationConf -from app.schemas.types import SystemConfigKey +from typing import Optional + +from app.helper.service import ServiceBaseHelper +from app.schemas import NotificationConf, ServiceInfo +from app.schemas.types import SystemConfigKey, ModuleType + class NotificationHelper(ServiceBaseHelper[NotificationConf]): """ 消息通知帮助类 """ - def __init__(self, config: dict = None): + def __init__(self): super().__init__( config_key=SystemConfigKey.Notifications, conf_type=NotificationConf, - modules=["WechatModule", "WebPushModule", "VoceChatModule", "TelegramModule", "SynologyChatModule", "SlackModule"] + module_type=ModuleType.Notification ) - - ... + + def is_notification( + self, + service_type: Optional[str] = None, + service: Optional[ServiceInfo] = None, + name: Optional[str] = None, + ) -> bool: + """ + 通用的消息通知服务类型判断方法 + + :param service_type: 消息通知服务的类型名称(如 'wechat', 'voicechat', 'telegram', 等) + :param service: 要判断的服务信息 + :param name: 服务的名称 + :return: 如果服务类型或实例为指定类型,返回 True;否则返回 False + """ + # 如果未提供 service 则通过 name 获取服务 + service = service or self.get_service(name=name) + + # 判断服务类型是否为指定类型 + return bool(service and service.type == service_type) ``` ### 2.3 在插件中使用服务帮助类 @@ -332,7 +445,7 @@ from app.helper.downloader import DownloaderHelper class MyPlugin: def init_plugin(self, config: dict = None): - self.downloaderhelper = DownloaderHelper(config) + self.downloaderhelper = DownloaderHelper() self.downloader_options = [ {"title": config.name, "value": config.name} for config in self.downloaderhelper.get_configs().values() @@ -349,14 +462,14 @@ class MyPlugin: ```python from typing import Optional, Union - from app.helper import DownloaderHelper + from app.helper.downloader import DownloaderHelper from app.modules.qbittorrent import Qbittorrent from app.modules.transmission import Transmission from app.events import EventType, eventmanager class MyPlugin: def init_plugin(self, config: dict = None): - self.downloaderhelper = DownloaderHelper(config) + self.downloaderhelper = DownloaderHelper() self._downloader = None self.__setup_downloader(config.get("downloader_name")) @@ -389,10 +502,10 @@ class MyPlugin: 检查下载器类型是否为 qbittorrent 或 transmission """ downloader = self.__get_downloader() - if self.downloaderhelper.is_qbittorrent(service=downloader): + if self.downloaderhelper.is_downloader(service_type="qbittorrent", service=downloader): # 处理 qbittorrent 类型 return True - elif self.downloaderhelper.is_transmission(service=downloader): + elif self.downloaderhelper.is_downloader(service_type="transmission", service=downloader): # 处理 transmission 类型 return True return False @@ -404,13 +517,14 @@ class MyPlugin: ```python from typing import Optional, Union - from app.helper import DownloaderHelper + from app.helper.downloader import DownloaderHelper from app.modules.qbittorrent import Qbittorrent from app.modules.transmission import Transmission class MyPlugin: def init_plugin(self, config: dict = None): - self.downloaderhelper = DownloaderHelper(config) + self.downloaderhelper = DownloaderHelper() + self.downloader_name = config.get("downloader_name") @property def service_info(self) -> Optional[ServiceInfo]: @@ -432,15 +546,15 @@ class MyPlugin: 下载器实例 """ return self.service_info.instance if self.service_info else None - + def check_downloader_type(self) -> bool: """ 检查下载器类型是否为 qbittorrent 或 transmission """ - if self.downloaderhelper.is_qbittorrent(service=self.service_info): + if self.downloaderhelper.is_downloader(service_type="qbittorrent", service=self.service_info): # 处理 qbittorrent 类型 return True - elif self.downloaderhelper.is_transmission(service=self.service_info): + elif self.downloaderhelper.is_downloader(service_type="transmission", service=self.service_info): # 处理 transmission 类型 return True return False From cdb4ad450866e601400658edc0cb341615d4c9d0 Mon Sep 17 00:00:00 2001 From: ramen <1205925392@qq.com> Date: Fri, 18 Oct 2024 11:14:27 +0800 Subject: [PATCH 096/218] =?UTF-8?q?1.=20=E8=B0=83=E6=95=B4=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E7=A0=81=E7=AD=9B=E9=80=89=E6=9D=A1=E4=BB=B6=202.=20?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E5=86=85=E7=BD=AE=E7=9A=84=E5=8A=A0=E5=AF=86?= =?UTF-8?q?=E5=87=BD=E6=95=B0=203.=20=E4=BD=BF=E7=94=A8=E2=80=98=E8=AE=BE?= =?UTF-8?q?=E5=AE=9A=E2=80=99=E9=87=8C=E7=9A=84CookieCloud=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=204.=20=E5=85=B3=E9=97=ADcookie=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E6=A1=86=E5=92=8C=E6=8F=92=E4=BB=B6=E9=85=8D=E7=BD=AE=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=E6=96=87=E6=9C=AC=205.=20=E5=BB=B6=E9=95=BFcookie?= =?UTF-8?q?=E6=88=90=E5=8A=9F=E6=97=B6=E4=B8=8D=E8=BE=93=E5=87=BA=E6=97=A5?= =?UTF-8?q?=E5=BF=97=206.=20=E6=97=A0=E6=B3=95=E4=BD=BF=E7=94=A8=E7=9A=84?= =?UTF-8?q?=E8=BF=9C=E7=A8=8B=E5=91=BD=E4=BB=A4=EF=BC=8C=E4=BD=86=E8=BF=98?= =?UTF-8?q?=E6=98=AF=E5=9C=A8=E5=87=BD=E6=95=B0=E7=BB=93=E6=9D=9F=E9=80=80?= =?UTF-8?q?=E5=87=BA=E6=B5=8F=E8=A7=88=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/dynamicwechat/__init__.py | 162 ++++++++++++++------------- plugins/dynamicwechat/update_help.py | 55 +-------- 2 files changed, 86 insertions(+), 131 deletions(-) diff --git a/plugins/dynamicwechat/__init__.py b/plugins/dynamicwechat/__init__.py index 3696319..21dcc9a 100644 --- a/plugins/dynamicwechat/__init__.py +++ b/plugins/dynamicwechat/__init__.py @@ -2,6 +2,7 @@ from app.core.event import eventmanager, Event import re import time import requests +import random import io from playwright.sync_api import sync_playwright from datetime import datetime, timedelta @@ -29,7 +30,7 @@ class DynamicWeChat(_PluginBase): # 插件图标 plugin_icon = "Wecom_A.png" # 插件版本 - plugin_version = "1.1.2" + plugin_version = "1.1.3" # 插件作者 plugin_author = "RamenRa" # 作者主页 @@ -41,11 +42,11 @@ class DynamicWeChat(_PluginBase): # 可使用的用户级别 auth_level = 2 - # 私有属性 + # ------------------------------------------私有属性------------------------------------------ _enabled = False # 开关 _cron = None _onlyonce = False - # IP更改成功状态,防止检测IP改动但cookie失效的时候_current_ip_address已经更新成新IP导致后面刷新cookie也没有更改企微IP + # IP更改成功状态 _ip_changed = False # 强制更改IP _forced_update = False @@ -66,12 +67,10 @@ class DynamicWeChat(_PluginBase): _input_id_list = '' _helloimg_s_token = "" _pushplus_token = "" - # _standalone_chrome_address = "http://192.168.1.0:4444/wd/hub" _qr_code_image = None text = "" - user_id = "" - channel = "" - _app_ids = [] + _verification_code = '' + # _app_ids = [] # -------cookie add------------ # cookie有效检测 @@ -96,7 +95,6 @@ class DynamicWeChat(_PluginBase): # self._urls = [] self._helloimg_s_token = '' self._pushplus_token = '' - # self._standalone_chrome_address = "http://192.168.1.0:4444/wd/hub" self._ip_changed = True self._forced_update = False # self._cookie_valid = False @@ -119,11 +117,19 @@ class DynamicWeChat(_PluginBase): self._forced_update = config.get("forced_update") self._use_cookiecloud = config.get("use_cookiecloud") self._cookie_header = config.get("cookie_header") - # self._standalone_chrome_address = config.get("standalone_chrome_address") self._ip_changed = config.get("ip_changed") if self._use_cookiecloud: - self._cc_server = PyCookieCloud(url=self._server, uuid=settings.COOKIECLOUD_KEY, - password=settings.COOKIECLOUD_PASSWORD) + if settings.COOKIECLOUD_ENABLE_LOCAL: + self._cc_server = PyCookieCloud(url=self._server, uuid=settings.COOKIECLOUD_KEY, + password=settings.COOKIECLOUD_PASSWORD) + logger.info("使用内建CookieCloud服务器") + else: # 使用设置里的cookieCloud + self._cc_server = PyCookieCloud(url=settings.COOKIECLOUD_HOST, uuid=settings.COOKIECLOUD_KEY, + password=settings.COOKIECLOUD_PASSWORD) + logger.info("使用自定义CookieCloud服务器") + if not self._cc_server.check_connection(): + self._cc_server = None + logger.error("没有可用的CookieCloud服务器") # 停止现有任务 self.stop_service() @@ -188,18 +194,14 @@ class DynamicWeChat(_PluginBase): userid=event.event_data.get("user")) def CheckIP(self): - # if not self._cookie_valid: - # self.refresh_cookie() - # if not self._cookie_valid: - # logger.error("请求企微失败,cookie可能过期,跳过IP检测") - # return False - for url in self._ip_urls: + retry_urls = random.sample(self._ip_urls, len(self._ip_urls)) + ip_address = None + + for url in retry_urls: ip_address = self.get_ip_from_url(url) if ip_address != "获取IP失败" and ip_address: - logger.info(f"IP获取成功: {url}: {ip_address}") + logger.info(f"IP获取成功: {url}:{ip_address}") break - # if ip_address == "获取IP失败" or not ip_address: - # logger.error(f"请求网址失败") # 如果所有 URL 请求失败 if ip_address == "获取IP失败" or not ip_address: @@ -288,12 +290,15 @@ class DynamicWeChat(_PluginBase): logger.info("二维码已经发送,等待用户 90 秒内扫码登录") logger.info("如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301?") time.sleep(90) - login_status = self.check_login_status(page) + login_status = self.check_login_status(page, '') if login_status: self._update_cookie(page, context) # 刷新cookie self.click_app_management_buttons(page) + else: + logger.warning("远程推送任务 未配置pushplus_token 或 helloimg_s_token") else: logger.warning("远程推送任务 未找到二维码") + browser.close() except Exception as e: logger.error(f"远程推送任务 推送二维码失败: {e}") @@ -320,7 +325,7 @@ class DynamicWeChat(_PluginBase): logger.info("二维码已经发送,等待用户 90 秒内扫码登录") logger.info("如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301?") time.sleep(90) # 等待用户扫码 - login_status = self.check_login_status(page) + login_status = self.check_login_status(page, "") if login_status: self._update_cookie(page, context) # 刷新cookie self.click_app_management_buttons(page) @@ -328,11 +333,11 @@ class DynamicWeChat(_PluginBase): else: self._ip_changed = False else: - logger.info("cookie失效,请使用CookieCloud重新上传。") + logger.info("cookie失效,请重新上传或者配置pushplus_token和helloimg_s_token。") else: # 如果直接进入企业微信 logger.info("尝试cookie登录") # ----------cookie addd----------------- - login_status = self.check_login_status(page) + login_status = self.check_login_status(page, "") if login_status: self.click_app_management_buttons(page) else: @@ -347,12 +352,12 @@ class DynamicWeChat(_PluginBase): pass def _update_cookie(self, page, context): - if self._use_cookiecloud: + if self._use_cookiecloud and self._cc_server: logger.info("使用二维码登录成功,开始刷新cookie") try: # logger.info("debug 开始连接CookieCloud") if self._cc_server.check_connection(): - logger.info("成功连接CookieCloud") + # logger.info("成功连接CookieCloud") current_url = page.url current_cookies = context.cookies(current_url) # 通过 context 获取 cookies # logger.info("原始 cookies:", current_cookies) @@ -373,7 +378,7 @@ class DynamicWeChat(_PluginBase): except Exception as e: logger.error(f"更新cookie发生错误: {e}") else: - logger.info("不使用CookieCloud, 不刷新cookie") + logger.error("CookieCloud配置错误, 不刷新cookie") # ----------cookie addd----------------- def get_cookie(self): # 只有从CookieCloud获取cookie成功才返回True @@ -397,7 +402,7 @@ class DynamicWeChat(_PluginBase): cookie_header = self._cookie_header else: # 不使用CookieCloud cookie_header = self._cookie_header - # return + return cookie = self.parse_cookie_header(cookie_header) self._cookie_from_CC = cookie return cookie @@ -426,36 +431,30 @@ class DynamicWeChat(_PluginBase): cookie = self.get_cookie() if cookie: context.add_cookies(cookie) - # logger.info("给浏览器添加cookie成功") - # else: - # logger.info("给浏览器添加cookie失败") page = context.new_page() - logger.info("延长cookie任务开始") page.goto(self._wechatUrl) time.sleep(3) - # 检查登录元素是否可见 - if self.check_login_status(page): - logger.info("延长cookie任务成功") - # self._cookie_valid = True - else: + if not self.check_login_status(page, task='refresh_cookie'): + # pass + # else: logger.info("cookie已失效,下次IP变动推送二维码") - # self._cookie_valid = False browser.close() except Exception as e: logger.error(f"cookie校验失败:{e}") - # self._cookie_valid = False # - def check_login_status(self, page): + def check_login_status(self, page, task): # 等待页面加载 time.sleep(3) # 检查是否需要进行短信验证 - logger.info("检查登录状态...") + if task != 'refresh_cookie': + logger.info("检查登录状态...") try: # 先检查登录成功后的页面状态 success_element = page.wait_for_selector('#check_corp_info', timeout=5000) # 检查登录成功的元素 if success_element: - logger.info("登录成功!") + if task != 'refresh_cookie': + logger.info("登录成功!") return True except Exception as e: # logger.error(f"检查登录状态时发生错误: {e}") @@ -466,9 +465,9 @@ class DynamicWeChat(_PluginBase): captcha_panel = page.wait_for_selector('.receive_captcha_panel', timeout=5000) # 检查验证码面板 if captcha_panel: # 出现了短信验证界面 time.sleep(30) # 多等30秒 - if self.text[:6]: - logger.info("需要短信验证 收到的短信验证码:" + self.text[:6]) - for digit in self.text[:6]: + if self._verification_code: + logger.info("需要短信验证 收到的短信验证码:" + self._verification_code) + for digit in self._verification_code: page.keyboard.press(digit) time.sleep(0.3) # 每个数字之间添加少量间隔以确保输入顺利 confirm_button = page.wait_for_selector('.confirm_btn', timeout=5000) # 获取确认按钮 @@ -605,7 +604,6 @@ class DynamicWeChat(_PluginBase): return img_src.split('"')[1], refuse_time # 提取 img src except KeyError as e: logger.error(f"上传图片时解析响应失败: {e}, 响应内容: {response_data}") - logger.info("本次操作终止") self._ip_changed = False return @@ -624,7 +622,7 @@ class DynamicWeChat(_PluginBase): "helloimg_s_token": self._helloimg_s_token, "pushplus_token": self._pushplus_token, "input_id_list": self._input_id_list, - # "standalone_chrome_address": self._standalone_chrome_address, + # "standalone_chrome_address": self._diy_server, "cookie_from_CC": self._cookie_from_CC, "cookie_header": self._cookie_header, @@ -739,28 +737,28 @@ class DynamicWeChat(_PluginBase): } ] }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12 - }, - 'content': [ - { - 'component': 'VTextarea', - 'props': { - 'model': 'cookie_header', - 'label': 'COOKIE', - 'rows': 1, - 'placeholder': '手动填写cookie' - } - } - ] - } - ] - }, + # { + # 'component': 'VRow', + # 'content': [ + # { + # 'component': 'VCol', + # 'props': { + # 'cols': 12 + # }, + # 'content': [ + # { + # 'component': 'VTextarea', + # 'props': { + # 'model': 'cookie_header', + # 'label': 'COOKIE', + # 'rows': 1, + # 'placeholder': '手动填写cookie' + # } + # } + # ] + # } + # ] + # }, { 'component': 'VRow', 'content': [ @@ -838,7 +836,7 @@ class DynamicWeChat(_PluginBase): 'props': { 'type': 'info', 'variant': 'tonal', - 'text': '* 强制更新和立即检测按钮属于一次性按钮。使用CookieCloud请到设置打开“本地CookieCloud”。' + 'text': '内建CC 或 自定义或 填写两个token三选一否则无法正常使用' } } ] @@ -858,7 +856,7 @@ class DynamicWeChat(_PluginBase): 'component': 'VAlert', 'props': { 'type': 'info', - 'text': '本插件优先使用cookie,当需要修改IP时cookie失效填写了两个token时会推送登录二维码到微信。', + 'text': '优先使用cookie,当IP变动 且 cookie失效 且 填写两个token才会调用API推送登录二维码。', } } ] @@ -872,13 +870,12 @@ class DynamicWeChat(_PluginBase): "cron": "", "onlyonce": False, "forceUpdate": False, - "use_cookiecloud": True, # 新增的模型字段 + "use_cookiecloud": True, # "wechatUrl": "", "cookie_header": "", "pushplus_token": "", "helloimg_token": "", "input_id_list": "", - "standalone_chrome_address": "" } def get_page(self) -> List[dict]: @@ -926,10 +923,11 @@ class DynamicWeChat(_PluginBase): self.text = event.event_data.get("text") # self.user_id = event.event_data.get("userid") # self.channel = event.event_data.get("channel") - if self.text and len(self.text) == 7: - logger.info(f"收到验证码:{self.text}") - else: - logger.info(f"收到消息:{self.text}") + if self.text[:6].isdigit() and len(self.text) == 7: + self._verification_code = self.text[:6] + logger.info(f"收到验证码:{self._verification_code}") + # else: + # logger.info(f"收到消息:{self.text}") def get_service(self) -> List[Dict[str, Any]]: """ @@ -966,3 +964,13 @@ class DynamicWeChat(_PluginBase): self._scheduler = None except Exception as e: logger.error(str(e)) + + + + + + + + + + diff --git a/plugins/dynamicwechat/update_help.py b/plugins/dynamicwechat/update_help.py index f8a02a7..3006fd5 100644 --- a/plugins/dynamicwechat/update_help.py +++ b/plugins/dynamicwechat/update_help.py @@ -1,62 +1,9 @@ -import base64 import hashlib from typing import Dict, Any import json import requests -from cryptography.hazmat.primitives import padding -from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes -from cryptography.hazmat.backends import default_backend -from os import urandom +from app.utils.common import encrypt -BLOCK_SIZE = 16 - -def pad(data: bytes) -> bytes: - padder = padding.PKCS7(algorithms.AES.block_size).padder() - padded_data = padder.update(data) + padder.finalize() - return padded_data - -def unpad(data: bytes) -> bytes: - unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder() - unpadded_data = unpadder.update(data) + unpadder.finalize() - return unpadded_data - -def bytes_to_key(data: bytes, salt: bytes, output: int = 48) -> bytes: - assert len(salt) == 8, len(salt) - data += salt - key = hashlib.md5(data).digest() - final_key = key - while len(final_key) < output: - key = hashlib.md5(key + data).digest() - final_key += key - return final_key[:output] - -def encrypt(message: bytes, passphrase: bytes) -> bytes: - salt = urandom(8) - key_iv = bytes_to_key(passphrase, salt, 32 + 16) - key = key_iv[:32] - iv = key_iv[32:] - - # Create AES cipher object - cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend()) - encryptor = cipher.encryptor() - - encrypted_message = encryptor.update(pad(message)) + encryptor.finalize() - return base64.b64encode(b"Salted__" + salt + encrypted_message) - -def decrypt(encrypted: bytes, passphrase: bytes) -> bytes: - encrypted = base64.b64decode(encrypted) - assert encrypted[0:8] == b"Salted__" - salt = encrypted[8:16] - key_iv = bytes_to_key(passphrase, salt, 32 + 16) - key = key_iv[:32] - iv = key_iv[32:] - - # Create AES cipher object - cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend()) - decryptor = cipher.decryptor() - - decrypted_message = decryptor.update(encrypted[16:]) + decryptor.finalize() - return unpad(decrypted_message) class PyCookieCloud: def __init__(self, url: str, uuid: str, password: str): From a131e425ca49a170776a905df21ce706c870d0eb Mon Sep 17 00:00:00 2001 From: ramen <1205925392@qq.com> Date: Fri, 18 Oct 2024 11:19:40 +0800 Subject: [PATCH 097/218] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=88=B01.1.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 7beded2..66d85fd 100644 --- a/package.json +++ b/package.json @@ -855,9 +855,12 @@ "name": "修改企业微信可信IP", "description": "优先使用cookie,当填写两个第三方token时手机微信可以更新cookie。验证码以?结尾发给企业微信应用。如:110301?", "labels": "消息通知", - "version": "1.1.2", + "version": "1.1.3", "icon": "Wecom_A.png", "author": "RamenRa", - "level": 2 + "level": 2, + "history": { + "v1.1.3": "关闭cookie输入框,延长cookie任务成功时不输出日志,使用设定中的CookieCloud设置" + } } } From 195c50d8e146870b536459e30bca25f0964a0831 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 18 Oct 2024 13:28:36 +0800 Subject: [PATCH 098/218] add SyncCookieCloud --- package.json | 16 ++ plugins/dynamicwechat/__init__.py | 123 ++++---- plugins/dynamicwechat/src/UpdateHelp.py | 10 +- plugins/dynamicwechat/src/debug.py | 6 +- plugins/dynamicwechat/update_help.py | 3 +- plugins/synccookiecloud/__init__.py | 360 ++++++++++++++++++++++++ 6 files changed, 443 insertions(+), 75 deletions(-) create mode 100644 plugins/synccookiecloud/__init__.py diff --git a/package.json b/package.json index 66d85fd..9db8873 100644 --- a/package.json +++ b/package.json @@ -862,5 +862,21 @@ "history": { "v1.1.3": "关闭cookie输入框,延长cookie任务成功时不输出日志,使用设定中的CookieCloud设置" } + }, + "SyncCookieCloud": { + "name": "同步CookieCloud", + "description": "同步MoviePilot站点Cookie到本地CookieCloud。", + "labels": "站点", + "version": "1.4", + "icon": "Cookiecloud_A.png", + "author": "thsrite", + "level": 1, + "history": { + "v1.4": "修复问题", + "v1.3": "感谢MidnightShake共享代码(同步时保留MoviePilot不匹配站点的cookie)", + "v1.2": "同步到本地CookieCloud", + "v1.1": "修复CookieCloud覆盖到浏览器", + "v1.0": "同步MoviePilot站点Cookie到CookieCloud" } + } } diff --git a/plugins/dynamicwechat/__init__.py b/plugins/dynamicwechat/__init__.py index 21dcc9a..daf5f04 100644 --- a/plugins/dynamicwechat/__init__.py +++ b/plugins/dynamicwechat/__init__.py @@ -1,22 +1,24 @@ -from app.core.event import eventmanager, Event +import io +import random import re import time -import requests -import random -import io -from playwright.sync_api import sync_playwright from datetime import datetime, timedelta -import pytz from typing import Optional -from app.schemas.types import EventType +from typing import Tuple, List, Dict, Any + +import pytz +import requests from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger +from playwright.sync_api import sync_playwright + +from app.core.config import settings +from app.core.event import eventmanager, Event +from app.helper.cookiecloud import CookieCloudHelper from app.log import logger from app.plugins import _PluginBase -from app.core.config import settings -from app.helper.cookiecloud import CookieCloudHelper -from typing import Tuple, List, Dict, Any from app.plugins.dynamicwechat.update_help import PyCookieCloud +from app.schemas.types import EventType, NotificationType # import UpdateHelp @@ -53,15 +55,15 @@ class DynamicWeChat(_PluginBase): _cc_server = None _push_qr_now = False - #匹配ip地址的正则 + # 匹配ip地址的正则 _ip_pattern = r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b' # 获取ip地址的网址列表 _ip_urls = ["https://myip.ipip.net", "https://ddns.oray.com/checkip", "https://ip.3322.net", "https://4.ipw.cn"] # 当前ip地址 _current_ip_address = '0.0.0.0' - #企业微信登录 + # 企业微信登录 _wechatUrl = 'https://work.weixin.qq.com/wework_admin/loginpage_wx?from=myhome' - #检测间隔时间,默认10分钟 + # 检测间隔时间,默认10分钟 _refresh_cron = '*/20 * * * *' # _urls = [] _input_id_list = '' @@ -267,6 +269,7 @@ class DynamicWeChat(_PluginBase): logger.warning("未找到二维码") return False except Exception as e: + logger.debug(str(e)) return False def remote_push_qr(self): @@ -286,14 +289,20 @@ class DynamicWeChat(_PluginBase): if self.find_qrc(page): if self._pushplus_token and self._helloimg_s_token: img_src, refuse_time = self.upload_image(self._qr_code_image) - self.send_pushplus_message(refuse_time, f"企业微信登录二维码
") - logger.info("二维码已经发送,等待用户 90 秒内扫码登录") - logger.info("如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301?") - time.sleep(90) - login_status = self.check_login_status(page, '') - if login_status: - self._update_cookie(page, context) # 刷新cookie - self.click_app_management_buttons(page) + if img_src: + self.post_message( + mtype=NotificationType.Plugin, + title="企业微信登录二维码", + text=refuse_time, + image=img_src + ) + logger.info("二维码已经发送,等待用户 90 秒内扫码登录") + logger.info("如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301?") + time.sleep(90) + login_status = self.check_login_status(page, '') + if login_status: + self._update_cookie(page, context) # 刷新cookie + self.click_app_management_buttons(page) else: logger.warning("远程推送任务 未配置pushplus_token 或 helloimg_s_token") else: @@ -302,7 +311,6 @@ class DynamicWeChat(_PluginBase): except Exception as e: logger.error(f"远程推送任务 推送二维码失败: {e}") - def ChangeIP(self): logger.info("开始请求企业微信管理更改可信IP") try: @@ -321,15 +329,20 @@ class DynamicWeChat(_PluginBase): if self.find_qrc(page): if self._pushplus_token and self._helloimg_s_token: img_src, refuse_time = self.upload_image(self._qr_code_image) - self.send_pushplus_message(refuse_time, f"企业微信登录二维码
") - logger.info("二维码已经发送,等待用户 90 秒内扫码登录") - logger.info("如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301?") - time.sleep(90) # 等待用户扫码 - login_status = self.check_login_status(page, "") - if login_status: - self._update_cookie(page, context) # 刷新cookie - self.click_app_management_buttons(page) - + if img_src: + self.post_message( + mtype=NotificationType.Plugin, + title="企业微信登录二维码", + text=refuse_time, + image=img_src + ) + logger.info("二维码已经发送,等待用户 90 秒内扫码登录") + logger.info("如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301?") + time.sleep(90) # 等待用户扫码 + login_status = self.check_login_status(page, "") + if login_status: + self._update_cookie(page, context) # 刷新cookie + self.click_app_management_buttons(page) else: self._ip_changed = False else: @@ -401,7 +414,6 @@ class DynamicWeChat(_PluginBase): if cookie_header == '': cookie_header = self._cookie_header else: # 不使用CookieCloud - cookie_header = self._cookie_header return cookie = self.parse_cookie_header(cookie_header) self._cookie_from_CC = cookie @@ -411,7 +423,8 @@ class DynamicWeChat(_PluginBase): # logger.info("尝试推送登录二维码") return - def parse_cookie_header(self, cookie_header): + @staticmethod + def parse_cookie_header(cookie_header): cookies = [] for cookie in cookie_header.split(';'): name, value = cookie.strip().split('=', 1) @@ -435,8 +448,8 @@ class DynamicWeChat(_PluginBase): page.goto(self._wechatUrl) time.sleep(3) if not self.check_login_status(page, task='refresh_cookie'): - # pass - # else: + # pass + # else: logger.info("cookie已失效,下次IP变动推送二维码") browser.close() except Exception as e: @@ -457,7 +470,7 @@ class DynamicWeChat(_PluginBase): logger.info("登录成功!") return True except Exception as e: - # logger.error(f"检查登录状态时发生错误: {e}") + logger.debug(str(e)) pass try: @@ -482,6 +495,7 @@ class DynamicWeChat(_PluginBase): logger.error("未收到短信验证码") return False except Exception as e: + logger.debug(str(e)) # try: # 没有登录成功,也没有短信验证码。 查找二维码是否还存在 if self.find_qrc(page): logger.error(f"用户没有扫描二维码") @@ -493,15 +507,14 @@ class DynamicWeChat(_PluginBase): buttons = [ # ("//span[@class='frame_nav_item_title' and text()='应用管理']", "应用管理"), # ("//div[@class='app_index_item_title ' and contains(text(), 'MoviePilot')]", "MoviePilot"), - ( - "//div[contains(@class, 'js_show_ipConfig_dialog')]//a[contains(@class, '_mod_card_operationLink') and text()='配置']", - "配置") + ("//div[contains(@class, 'js_show_ipConfig_dialog')]//a[contains(@class, '_mod_card_operationLink') and text()='配置']", + "配置") ] if self._input_id_list: id_list = self._input_id_list.split(",") app_urls = [f"{bash_url}{app_id.strip()}" for app_id in id_list] for app_url in app_urls: - page.goto(app_url) # 打开应用详情页 + page.goto(app_url) # 打开应用详情页 # logger.info(f"已打开{app_url}") time.sleep(2) # 依次点击每个按钮 @@ -530,20 +543,6 @@ class DynamicWeChat(_PluginBase): logger.error("未找到应用id,修改IP失败") return - def send_pushplus_message(self, title, content): - pushplus_url = f"http://www.pushplus.plus/send/{self._pushplus_token}" - pushplus_data = { - "title": title, - "content": content, - "template": "html" - } - # if wait_time > 2: - # # time.sleep(wait_time) - # logger.info(f"pushplus API 调用次数限制,本次不发送 至少间隔 {wait_time} 秒") - # else: - response = requests.post(pushplus_url, json=pushplus_data) - # return response - def upload_image(self, file_obj, permission=1, strategy_id=1, album_id=1): """ 上传图片到 helloimg 图床,支持传入文件路径或 BytesIO 对象。 @@ -587,6 +586,7 @@ class DynamicWeChat(_PluginBase): response = requests.post(helloimg_url, headers=headers, files=files, data=helloimg_data) # 检查响应内容是否符合预期 + response_data = None try: response_data = response.json() if not response_data['status']: @@ -893,7 +893,6 @@ class DynamicWeChat(_PluginBase): logger.info("远程命令开始推送二维码") self.remote_push_qr() - @staticmethod def get_command() -> List[Dict[str, Any]]: return [ @@ -911,8 +910,6 @@ class DynamicWeChat(_PluginBase): def get_api(self) -> List[Dict[str, Any]]: pass - - @eventmanager.register(EventType.UserMessage) def talk(self, event: Event): """ @@ -958,19 +955,7 @@ class DynamicWeChat(_PluginBase): if self._scheduler: self._scheduler.remove_all_jobs() if self._scheduler.running: - self._event.set() self._scheduler.shutdown() - self._event.clear() self._scheduler = None except Exception as e: logger.error(str(e)) - - - - - - - - - - diff --git a/plugins/dynamicwechat/src/UpdateHelp.py b/plugins/dynamicwechat/src/UpdateHelp.py index 20a9146..ec747b5 100644 --- a/plugins/dynamicwechat/src/UpdateHelp.py +++ b/plugins/dynamicwechat/src/UpdateHelp.py @@ -9,10 +9,12 @@ import base64 BLOCK_SIZE = 16 + def pad(data): length = BLOCK_SIZE - (len(data) % BLOCK_SIZE) return data + (chr(length) * length).encode() + def bytes_to_key(data, salt, output=48): # extended from https://gist.github.com/gsakkis/4546068 assert len(salt) == 8, len(salt) @@ -24,6 +26,7 @@ def bytes_to_key(data, salt, output=48): final_key += key return final_key[:output] + def encrypt(message, passphrase): salt = Random.new().read(8) key_iv = bytes_to_key(passphrase, salt, 32 + 16) @@ -32,6 +35,7 @@ def encrypt(message, passphrase): aes = AES.new(key, AES.MODE_CBC, iv) return base64.b64encode(b"Salted__" + salt + aes.encrypt(pad(message))) + class PyCookieCloud: def __init__(self, url: str, uuid: str, password: str): self.url: str = url @@ -51,6 +55,7 @@ class PyCookieCloud: else: return False except Exception as e: + print(str(e)) return False def update_cookie(self, cookie: Dict[str, Any]) -> bool: @@ -64,7 +69,8 @@ class PyCookieCloud: cookie = {'cookie_data': cookie} raw_data = json.dumps(cookie) encrypted_data = encrypt(raw_data.encode('utf-8'), self.get_the_key().encode('utf-8')).decode('utf-8') - cookie_cloud_request = requests.post(urljoin(self.url, '/update'), data={'uuid': self.uuid, 'encrypted': encrypted_data}) + cookie_cloud_request = requests.post(urljoin(self.url, '/update'), + data={'uuid': self.uuid, 'encrypted': encrypted_data}) if cookie_cloud_request.status_code == 200: if cookie_cloud_request.json()['action'] == 'done': return True @@ -78,4 +84,4 @@ class PyCookieCloud: """ md5 = hashlib.md5() md5.update((self.uuid + '-' + self.password).encode('utf-8')) - return md5.hexdigest()[:16] \ No newline at end of file + return md5.hexdigest()[:16] diff --git a/plugins/dynamicwechat/src/debug.py b/plugins/dynamicwechat/src/debug.py index e91396a..c6499d7 100644 --- a/plugins/dynamicwechat/src/debug.py +++ b/plugins/dynamicwechat/src/debug.py @@ -30,6 +30,7 @@ class PyCookieCloud: else: return False except Exception as e: + print(str(e)) return False def update_cookie(self, cookie: Dict[str, Any]) -> bool: @@ -70,7 +71,8 @@ class PyCookieCloud: md5.update((self.uuid + '-' + self.password).encode('utf-8')) return md5.hexdigest()[:16] - def bytes_to_key(self, data, salt, output=48): + @staticmethod + def bytes_to_key(data, salt, output=48): # extended from https://gist.github.com/gsakkis/4546068 assert len(salt) == 8, len(salt) data += salt @@ -120,7 +122,7 @@ def main(server: str, url: str, uuid: str, password: str): # 创建 PyCookieCloud 实例并上传 cookies py_cookie_cloud = PyCookieCloud(url=server, uuid=uuid, password=password) cookie_data = {cookie['name']: cookie['value'] for cookie in cookies} # 转换为字典形式 - if (py_cookie_cloud.check_connection()): + if py_cookie_cloud.check_connection(): print("连接成功,请稍等片刻...") result = py_cookie_cloud.update_cookie(cookie_data) else: diff --git a/plugins/dynamicwechat/update_help.py b/plugins/dynamicwechat/update_help.py index 3006fd5..d1018e7 100644 --- a/plugins/dynamicwechat/update_help.py +++ b/plugins/dynamicwechat/update_help.py @@ -24,6 +24,7 @@ class PyCookieCloud: else: return False except Exception as e: + print(str(e)) return False def update_cookie(self, cookie: Dict[str, Any]) -> bool: @@ -52,5 +53,3 @@ class PyCookieCloud: md5 = hashlib.md5() md5.update((self.uuid + '-' + self.password).encode('utf-8')) return md5.hexdigest()[:16] - - diff --git a/plugins/synccookiecloud/__init__.py b/plugins/synccookiecloud/__init__.py new file mode 100644 index 0000000..70b06d0 --- /dev/null +++ b/plugins/synccookiecloud/__init__.py @@ -0,0 +1,360 @@ +import json +from datetime import datetime, timedelta +from hashlib import md5 +from urllib.parse import urlparse + +import pytz + +from app.core.config import settings +from app.db.site_oper import SiteOper +from app.plugins import _PluginBase +from typing import Any, List, Dict, Tuple, Optional +from app.log import logger +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from app.utils.common import encrypt, decrypt + + +class SyncCookieCloud(_PluginBase): + # 插件名称 + plugin_name = "同步CookieCloud" + # 插件描述 + plugin_desc = "同步MoviePilot站点Cookie到本地CookieCloud。" + # 插件图标 + plugin_icon = "Cookiecloud_A.png" + # 插件版本 + plugin_version = "1.4" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "synccookiecloud_" + # 加载顺序 + plugin_order = 28 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled: bool = False + _onlyonce: bool = False + _cron: str = "" + siteoper = None + _scheduler: Optional[BackgroundScheduler] = None + + def init_plugin(self, config: dict = None): + self.siteoper = SiteOper() + + # 停止现有任务 + self.stop_service() + + if config: + self._enabled = config.get("enabled") + self._onlyonce = config.get("onlyonce") + self._cron = config.get("cron") + + if self._enabled or self._onlyonce: + # 定时服务 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + # 立即运行一次 + if self._onlyonce: + logger.info(f"同步CookieCloud服务启动,立即运行一次") + self._scheduler.add_job(self.__sync_to_cookiecloud, 'date', + run_date=datetime.now( + tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="同步CookieCloud") + # 关闭一次性开关 + self._onlyonce = False + + # 保存配置 + self.__update_config() + + # 周期运行 + if self._cron: + try: + self._scheduler.add_job(func=self.__sync_to_cookiecloud, + trigger=CronTrigger.from_crontab(self._cron), + name="同步CookieCloud") + except Exception as err: + logger.error(f"定时任务配置错误:{err}") + # 推送实时消息 + self.systemmessage.put(f"执行周期配置错误:{err}") + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def __sync_to_cookiecloud(self): + """ + 同步站点cookie到cookiecloud + """ + # 获取所有站点 + sites = self.siteoper.list_order_by_pri() + if not sites: + return + + if not settings.COOKIECLOUD_ENABLE_LOCAL: + logger.error('本地CookieCloud服务器未启用') + return + + cookies = {} + for site in sites: + domain = urlparse(site.url).netloc + cookie = site.cookie + + if not cookie: + logger.error(f"站点 {domain} 无cookie,跳过处理...") + continue + + # 解析cookie + site_cookies = [] + for ck in cookie.split(";"): + kv = ck.split("=") + if len(kv) < 2: + continue + site_cookies.append({ + "domain": domain, + "name": ck.split("=")[0], + "value": ck.split("=")[1] + }) + # 存储cookies + cookies[domain] = site_cookies + if cookies: + decrypted_cookies_data, errmsg = self.__decrypted() + if decrypted_cookies_data: + update_data = self.__build_data(cookies, decrypted_cookies_data) + crypt_key = self._get_crypt_key() + try: + cookies = {'cookie_data': update_data} + encrypted_data = encrypt(json.dumps(cookies).encode('utf-8'), crypt_key).decode('utf-8') + except Exception as e: + logger.error(f"CookieCloud加密失败,{e}") + return + ck = {'encrypted': encrypted_data} + cookie_path = settings.COOKIE_PATH / f"{settings.COOKIECLOUD_KEY}.json" + cookie_path.write_bytes(json.dumps(ck).encode('utf-8')) + logger.info(f"同步站点cookie到CookieCloud成功") + else: + logger.error(f"同步站点cookie到CookieCloud失败,{errmsg}") + + def __decrypted(self): + """ + 获取并解密本地CookieCloud数据 + """ + encrypt_data = self.__load_local_encrypt_data() + if not encrypt_data: + return {}, "未获取到本地CookieCloud数据" + encrypted = encrypt_data.get("encrypted") + if not encrypted: + return {}, "未获取到cookie密文" + else: + crypt_key = self._get_crypt_key() + try: + decrypted_data = decrypt(encrypted, crypt_key).decode('utf-8') + result = json.loads(decrypted_data) + except Exception as e: + return {}, "cookie解密失败:" + str(e) + + if not result: + return {}, "cookie解密为空" + + if result.get("cookie_data"): + contents = result.get("cookie_data") + else: + contents = result + return contents + + @staticmethod + def __load_local_encrypt_data() -> Dict[str, Any]: + """ + 加载本地CookieCloud加密数据 + """ + file_path = settings.COOKIE_PATH / f"{settings.COOKIECLOUD_KEY}.json" + # 检查文件是否存在 + if not file_path.exists(): + return {} + + # 读取文件 + with open(file_path, encoding="utf-8", mode="r") as file: + read_content = file.read() + data = json.loads(read_content.encode("utf-8")) + return data + + @staticmethod + def __build_data(in_list: dict, out_list: dict) -> dict: + """ + 构建站点数据 + """ + # 清除空值 + out_list = {key: value for key, value in out_list.items() if value} + + temp_list = {} + for domain in in_list.keys(): + # 构建站点数据模板 + template = {} + for domain_out in out_list: + if domain.endswith(domain_out): + for d in out_list[domain_out]: + for key, value in d.items(): + if key not in template: + template[key] = value + + # 构建站点新数据 + temp_list[domain] = [] + for d1 in in_list[domain]: + temp_dict = {k: template.get(k, "") for k in template.keys()} + temp_dict.update(d1) + temp_list[domain].append(temp_dict) + + # 覆盖修改源站点数据 + for temp_domain in temp_list.keys(): + found_match = False + for idx, domain2 in enumerate(out_list): + if temp_domain.endswith(domain2): + out_list[temp_domain] = out_list.pop(domain2) + out_list[temp_domain] = temp_list[temp_domain] + found_match = True + break + if not found_match: + out_list[temp_domain] = temp_list[temp_domain] + return out_list + + @staticmethod + def _get_crypt_key() -> bytes: + """ + 使用UUID和密码生成CookieCloud的加解密密钥 + """ + md5_generator = md5() + md5_generator.update( + (str(settings.COOKIECLOUD_KEY).strip() + '-' + str(settings.COOKIECLOUD_PASSWORD).strip()).encode('utf-8')) + return (md5_generator.hexdigest()[:16]).encode('utf-8') + + def __update_config(self): + self.update_config({ + "enabled": self._enabled, + "onlyonce": self._onlyonce, + "cron": self._cron + }) + + def get_state(self) -> bool: + return self._enabled + + @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、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '执行周期', + 'placeholder': '5位cron表达式,留空自动' + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '需要MoviePilot设定-站点启用本地CookieCloud服务器。' + } + } + ] + } + ] + }, + ] + } + ], { + "enabled": False, + "onlyonce": False, + "cron": "5 1 * * *", + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) From 082ca28ed05ee712487acd6a3b9e1b61726e1b41 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 18 Oct 2024 13:41:21 +0800 Subject: [PATCH 099/218] fix SyncCookieCloud --- package.json | 48 +++++++-------- plugins/synccookiecloud/__init__.py | 91 +++++------------------------ 2 files changed, 39 insertions(+), 100 deletions(-) diff --git a/package.json b/package.json index 9db8873..3c3feae 100644 --- a/package.json +++ b/package.json @@ -852,31 +852,31 @@ "v2": true }, "DynamicWeChat": { - "name": "修改企业微信可信IP", - "description": "优先使用cookie,当填写两个第三方token时手机微信可以更新cookie。验证码以?结尾发给企业微信应用。如:110301?", - "labels": "消息通知", - "version": "1.1.3", - "icon": "Wecom_A.png", - "author": "RamenRa", - "level": 2, - "history": { - "v1.1.3": "关闭cookie输入框,延长cookie任务成功时不输出日志,使用设定中的CookieCloud设置" + "name": "修改企业微信可信IP", + "description": "优先使用cookie,当填写两个第三方token时手机微信可以更新cookie。验证码以?结尾发给企业微信应用。如:110301?", + "labels": "消息通知", + "version": "1.1.3", + "icon": "Wecom_A.png", + "author": "RamenRa", + "level": 2, + "history": { + "v1.1.3": "关闭cookie输入框,延长cookie任务成功时不输出日志,使用设定中的CookieCloud设置" } }, - "SyncCookieCloud": { - "name": "同步CookieCloud", - "description": "同步MoviePilot站点Cookie到本地CookieCloud。", - "labels": "站点", - "version": "1.4", - "icon": "Cookiecloud_A.png", - "author": "thsrite", - "level": 1, - "history": { - "v1.4": "修复问题", - "v1.3": "感谢MidnightShake共享代码(同步时保留MoviePilot不匹配站点的cookie)", - "v1.2": "同步到本地CookieCloud", - "v1.1": "修复CookieCloud覆盖到浏览器", - "v1.0": "同步MoviePilot站点Cookie到CookieCloud" + "SyncCookieCloud": { + "name": "同步CookieCloud", + "description": "同步MoviePilot站点Cookie到本地CookieCloud。", + "labels": "站点", + "version": "1.4", + "icon": "Cookiecloud_A.png", + "author": "thsrite", + "level": 1, + "history": { + "v2.0": "调整逻辑,修复问题", + "v1.3": "感谢MidnightShake共享代码(同步时保留MoviePilot不匹配站点的cookie)", + "v1.2": "同步到本地CookieCloud", + "v1.1": "修复CookieCloud覆盖到浏览器", + "v1.0": "同步MoviePilot站点Cookie到CookieCloud" + } } - } } diff --git a/plugins/synccookiecloud/__init__.py b/plugins/synccookiecloud/__init__.py index 70b06d0..4f29226 100644 --- a/plugins/synccookiecloud/__init__.py +++ b/plugins/synccookiecloud/__init__.py @@ -23,7 +23,7 @@ class SyncCookieCloud(_PluginBase): # 插件图标 plugin_icon = "Cookiecloud_A.png" # 插件版本 - plugin_version = "1.4" + plugin_version = "2.0" # 插件作者 plugin_author = "thsrite" # 作者主页 @@ -122,30 +122,24 @@ class SyncCookieCloud(_PluginBase): # 存储cookies cookies[domain] = site_cookies if cookies: - decrypted_cookies_data, errmsg = self.__decrypted() - if decrypted_cookies_data: - update_data = self.__build_data(cookies, decrypted_cookies_data) - crypt_key = self._get_crypt_key() - try: - cookies = {'cookie_data': update_data} - encrypted_data = encrypt(json.dumps(cookies).encode('utf-8'), crypt_key).decode('utf-8') - except Exception as e: - logger.error(f"CookieCloud加密失败,{e}") - return - ck = {'encrypted': encrypted_data} - cookie_path = settings.COOKIE_PATH / f"{settings.COOKIECLOUD_KEY}.json" - cookie_path.write_bytes(json.dumps(ck).encode('utf-8')) - logger.info(f"同步站点cookie到CookieCloud成功") - else: - logger.error(f"同步站点cookie到CookieCloud失败,{errmsg}") + crypt_key = self._get_crypt_key() + try: + cookies = {'cookie_data': cookies} + encrypted_data = encrypt(json.dumps(cookies).encode('utf-8'), crypt_key).decode('utf-8') + except Exception as e: + logger.error(f"CookieCloud加密失败,{e}") + return + ck = {'encrypted': encrypted_data} + cookie_path = settings.COOKIE_PATH / f"{settings.COOKIECLOUD_KEY}.json" + cookie_path.write_bytes(json.dumps(ck).encode('utf-8')) + logger.info(f"同步站点cookie到本地CookieCloud成功") + else: + logger.error(f"同步站点cookie到本地CookieCloud失败,未获取到站点cookie") - def __decrypted(self): + def __decrypted(self, encrypt_data: dict): """ 获取并解密本地CookieCloud数据 """ - encrypt_data = self.__load_local_encrypt_data() - if not encrypt_data: - return {}, "未获取到本地CookieCloud数据" encrypted = encrypt_data.get("encrypted") if not encrypted: return {}, "未获取到cookie密文" @@ -166,61 +160,6 @@ class SyncCookieCloud(_PluginBase): contents = result return contents - @staticmethod - def __load_local_encrypt_data() -> Dict[str, Any]: - """ - 加载本地CookieCloud加密数据 - """ - file_path = settings.COOKIE_PATH / f"{settings.COOKIECLOUD_KEY}.json" - # 检查文件是否存在 - if not file_path.exists(): - return {} - - # 读取文件 - with open(file_path, encoding="utf-8", mode="r") as file: - read_content = file.read() - data = json.loads(read_content.encode("utf-8")) - return data - - @staticmethod - def __build_data(in_list: dict, out_list: dict) -> dict: - """ - 构建站点数据 - """ - # 清除空值 - out_list = {key: value for key, value in out_list.items() if value} - - temp_list = {} - for domain in in_list.keys(): - # 构建站点数据模板 - template = {} - for domain_out in out_list: - if domain.endswith(domain_out): - for d in out_list[domain_out]: - for key, value in d.items(): - if key not in template: - template[key] = value - - # 构建站点新数据 - temp_list[domain] = [] - for d1 in in_list[domain]: - temp_dict = {k: template.get(k, "") for k in template.keys()} - temp_dict.update(d1) - temp_list[domain].append(temp_dict) - - # 覆盖修改源站点数据 - for temp_domain in temp_list.keys(): - found_match = False - for idx, domain2 in enumerate(out_list): - if temp_domain.endswith(domain2): - out_list[temp_domain] = out_list.pop(domain2) - out_list[temp_domain] = temp_list[temp_domain] - found_match = True - break - if not found_match: - out_list[temp_domain] = temp_list[temp_domain] - return out_list - @staticmethod def _get_crypt_key() -> bytes: """ From 70f9e94545a97228890acf6541e67495c923d6cb Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 18 Oct 2024 13:44:37 +0800 Subject: [PATCH 100/218] fix SyncCookieCloud --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3c3feae..4b461e6 100644 --- a/package.json +++ b/package.json @@ -867,7 +867,7 @@ "name": "同步CookieCloud", "description": "同步MoviePilot站点Cookie到本地CookieCloud。", "labels": "站点", - "version": "1.4", + "version": "2.0", "icon": "Cookiecloud_A.png", "author": "thsrite", "level": 1, From d1e2c96ebc9ae3881df34c08edd04697eeec4884 Mon Sep 17 00:00:00 2001 From: ramen <1205925392@qq.com> Date: Sat, 19 Oct 2024 10:11:08 +0800 Subject: [PATCH 101/218] =?UTF-8?q?=E6=94=BE=E5=BC=83self.post=5Fmessage()?= =?UTF-8?q?=E7=9A=84=E6=B6=88=E6=81=AF=E6=8E=A8=E9=80=81=EF=BC=8C=E8=BF=98?= =?UTF-8?q?=E5=8E=9F=E6=88=90send=5Fpushplus=5Fmessage(),=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E5=88=B01.1.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 +- plugins/dynamicwechat/__init__.py | 89 ++++++++++++++++++------------- 2 files changed, 53 insertions(+), 39 deletions(-) diff --git a/package.json b/package.json index 4b461e6..e8fbce8 100644 --- a/package.json +++ b/package.json @@ -855,11 +855,12 @@ "name": "修改企业微信可信IP", "description": "优先使用cookie,当填写两个第三方token时手机微信可以更新cookie。验证码以?结尾发给企业微信应用。如:110301?", "labels": "消息通知", - "version": "1.1.3", + "version": "1.1.4", "icon": "Wecom_A.png", "author": "RamenRa", "level": 2, "history": { + "v1.1.4": "放弃self.post_message()的消息推送,还原成send_pushplus_message()", "v1.1.3": "关闭cookie输入框,延长cookie任务成功时不输出日志,使用设定中的CookieCloud设置" } }, diff --git a/plugins/dynamicwechat/__init__.py b/plugins/dynamicwechat/__init__.py index daf5f04..bf8e0de 100644 --- a/plugins/dynamicwechat/__init__.py +++ b/plugins/dynamicwechat/__init__.py @@ -21,9 +21,6 @@ from app.plugins.dynamicwechat.update_help import PyCookieCloud from app.schemas.types import EventType, NotificationType -# import UpdateHelp - - class DynamicWeChat(_PluginBase): # 插件名称 plugin_name = "修改企业微信可信IP" @@ -32,7 +29,7 @@ class DynamicWeChat(_PluginBase): # 插件图标 plugin_icon = "Wecom_A.png" # 插件版本 - plugin_version = "1.1.3" + plugin_version = "1.1.4" # 插件作者 plugin_author = "RamenRa" # 作者主页 @@ -272,6 +269,15 @@ class DynamicWeChat(_PluginBase): logger.debug(str(e)) return False + def send_pushplus_message(self, title, content): + pushplus_url = f"http://www.pushplus.plus/send/{self._pushplus_token}" + pushplus_data = { + "title": title, + "content": content, + "template": "html" + } + response = requests.post(pushplus_url, json=pushplus_data) + def remote_push_qr(self): try: with sync_playwright() as p: @@ -289,20 +295,21 @@ class DynamicWeChat(_PluginBase): if self.find_qrc(page): if self._pushplus_token and self._helloimg_s_token: img_src, refuse_time = self.upload_image(self._qr_code_image) - if img_src: - self.post_message( - mtype=NotificationType.Plugin, - title="企业微信登录二维码", - text=refuse_time, - image=img_src - ) - logger.info("二维码已经发送,等待用户 90 秒内扫码登录") - logger.info("如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301?") - time.sleep(90) - login_status = self.check_login_status(page, '') - if login_status: - self._update_cookie(page, context) # 刷新cookie - self.click_app_management_buttons(page) + self.send_pushplus_message(refuse_time, f"企业微信登录二维码
") + # if img_src: + # self.post_message( + # mtype=NotificationType.Plugin, + # title="企业微信登录二维码", + # text=refuse_time, + # image=img_src + # ) + logger.info("二维码已经发送,等待用户 90 秒内扫码登录") + logger.info("如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301?") + time.sleep(90) + login_status = self.check_login_status(page, '') + if login_status: + self._update_cookie(page, context) # 刷新cookie + self.click_app_management_buttons(page) else: logger.warning("远程推送任务 未配置pushplus_token 或 helloimg_s_token") else: @@ -329,20 +336,21 @@ class DynamicWeChat(_PluginBase): if self.find_qrc(page): if self._pushplus_token and self._helloimg_s_token: img_src, refuse_time = self.upload_image(self._qr_code_image) - if img_src: - self.post_message( - mtype=NotificationType.Plugin, - title="企业微信登录二维码", - text=refuse_time, - image=img_src - ) - logger.info("二维码已经发送,等待用户 90 秒内扫码登录") - logger.info("如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301?") - time.sleep(90) # 等待用户扫码 - login_status = self.check_login_status(page, "") - if login_status: - self._update_cookie(page, context) # 刷新cookie - self.click_app_management_buttons(page) + self.send_pushplus_message(refuse_time, f"企业微信登录二维码
") + # if img_src: + # self.post_message( + # mtype=NotificationType.Plugin, + # title="企业微信登录二维码", + # text=refuse_time, + # image=img_src + # ) + logger.info("二维码已经发送,等待用户 90 秒内扫码登录") + logger.info("如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301?") + time.sleep(90) # 等待用户扫码 + login_status = self.check_login_status(page, "") + if login_status: + self._update_cookie(page, context) # 刷新cookie + self.click_app_management_buttons(page) else: self._ip_changed = False else: @@ -448,8 +456,6 @@ class DynamicWeChat(_PluginBase): page.goto(self._wechatUrl) time.sleep(3) if not self.check_login_status(page, task='refresh_cookie'): - # pass - # else: logger.info("cookie已失效,下次IP变动推送二维码") browser.close() except Exception as e: @@ -497,7 +503,7 @@ class DynamicWeChat(_PluginBase): except Exception as e: logger.debug(str(e)) # try: # 没有登录成功,也没有短信验证码。 查找二维码是否还存在 - if self.find_qrc(page): + if self.find_qrc(page) and not task != 'refresh_cookie': # 延长任务找到的二维码不会被发送,所以不算用户没有扫码 logger.error(f"用户没有扫描二维码") return False @@ -836,7 +842,7 @@ class DynamicWeChat(_PluginBase): 'props': { 'type': 'info', 'variant': 'tonal', - 'text': '内建CC 或 自定义或 填写两个token三选一否则无法正常使用' + 'text': '使用内建CookieCloud 或 自定义 或 填写两个token 至少三选一,否则无法正常使用' } } ] @@ -918,8 +924,6 @@ class DynamicWeChat(_PluginBase): if not self._enabled: return self.text = event.event_data.get("text") - # self.user_id = event.event_data.get("userid") - # self.channel = event.event_data.get("channel") if self.text[:6].isdigit() and len(self.text) == 7: self._verification_code = self.text[:6] logger.info(f"收到验证码:{self._verification_code}") @@ -959,3 +963,12 @@ class DynamicWeChat(_PluginBase): self._scheduler = None except Exception as e: logger.error(str(e)) + + + + + + + + + From aa3a4f7ec9f54155480bb89d6096765190ac6dfe Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 19 Oct 2024 17:21:26 +0800 Subject: [PATCH 102/218] =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E8=AE=A2?= =?UTF-8?q?=E9=98=85=E5=85=BC=E5=AE=B9V2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 14 +++++++++++++- plugins.v2/rsssubscribe/__init__.py | 8 ++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/package.v2.json b/package.v2.json index 49dc247..01e3580 100644 --- a/package.v2.json +++ b/package.v2.json @@ -1,5 +1,5 @@ { - "SiteStatistic": { + "SiteStatistic": { "name": "站点数据统计", "description": "站点统计数据图表。", "labels": "站点,仪表板", @@ -97,5 +97,17 @@ "v1.7": "MoviePilot V2 版本自动转移做种插件", "v1.7.1": "修复兼容性问题" } + }, + "RssSubscribe": { + "name": "自定义订阅", + "description": "定时刷新RSS报文,识别内容后添加订阅或直接下载。", + "labels": "订阅", + "version": "2.0", + "icon": "rss.png", + "author": "jxxghp", + "level": 2, + "history": { + "v2.0": "兼容MoviePilot V2 版本" + } } } \ No newline at end of file diff --git a/plugins.v2/rsssubscribe/__init__.py b/plugins.v2/rsssubscribe/__init__.py index 2fdc278..4480b2d 100644 --- a/plugins.v2/rsssubscribe/__init__.py +++ b/plugins.v2/rsssubscribe/__init__.py @@ -33,7 +33,7 @@ class RssSubscribe(_PluginBase): # 插件图标 plugin_icon = "rss.png" # 插件版本 - plugin_version = "1.5" + plugin_version = "2.0" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -409,7 +409,7 @@ class RssSubscribe(_PluginBase): 'component': 'VSwitch', 'props': { 'model': 'filter', - 'label': '使用过滤规则', + 'label': '使用订阅优先级规则', } } ] @@ -628,7 +628,7 @@ class RssSubscribe(_PluginBase): logger.error(f"未获取到RSS数据:{url}") return # 过滤规则 - filter_rule = self.systemconfig.get(SystemConfigKey.SubscribeFilterRules) + filter_groups = self.systemconfig.get(SystemConfigKey.SubscribeFilterRuleGroups) # 解析数据 for result in results: try: @@ -680,7 +680,7 @@ class RssSubscribe(_PluginBase): # 过滤种子 if self._filter: result = self.chain.filter_torrents( - rule_string=filter_rule, + rule_groups=filter_groups, torrent_list=[torrentinfo], mediainfo=mediainfo ) From 1ac1ab7586408d7d7640c7a130e13f84cc1513d4 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 19 Oct 2024 17:27:09 +0800 Subject: [PATCH 103/218] =?UTF-8?q?FFmpeg=E7=BC=A9=E7=95=A5=E5=9B=BE=20?= =?UTF-8?q?=E5=85=BC=E5=AE=B9V2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 12 ++++++++++++ plugins.v2/ffmpegthumb/__init__.py | 5 ++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/package.v2.json b/package.v2.json index 01e3580..6346ec4 100644 --- a/package.v2.json +++ b/package.v2.json @@ -109,5 +109,17 @@ "history": { "v2.0": "兼容MoviePilot V2 版本" } + }, + "FFmpegThumb": { + "name": "FFmpeg缩略图", + "description": "TheMovieDb没有背景图片时使用FFmpeg截取视频文件缩略图", + "labels": "刮削", + "version": "2.0", + "icon": "ffmpeg.png", + "author": "jxxghp", + "level": 1, + "history": { + "v2.0": "兼容MoviePilot V2 版本" + } } } \ No newline at end of file diff --git a/plugins.v2/ffmpegthumb/__init__.py b/plugins.v2/ffmpegthumb/__init__.py index b12454e..b645f31 100644 --- a/plugins.v2/ffmpegthumb/__init__.py +++ b/plugins.v2/ffmpegthumb/__init__.py @@ -28,7 +28,7 @@ class FFmpegThumb(_PluginBase): # 插件图标 plugin_icon = "ffmpeg.png" # 插件版本 - plugin_version = "1.2" + plugin_version = "2.0" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -276,6 +276,9 @@ class FFmpegThumb(_PluginBase): transferinfo: TransferInfo = event.event_data.get("transferinfo") if not transferinfo: return + if transferinfo.target_diritem and transferinfo.target_diritem.storage != "local": + logger.warn(f"FFmpeg缩略图不支持非本地存储:{transferinfo.target_diritem.storage}") + return file_list = transferinfo.file_list_new for file in file_list: logger.info(f"FFmpeg缩略图处理文件:{file}") From cca80838063d80daf942b3941b21791d3d9f687e Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 19 Oct 2024 17:57:51 +0800 Subject: [PATCH 104/218] =?UTF-8?q?=E5=AA=92=E4=BD=93=E5=BA=93=E5=88=AE?= =?UTF-8?q?=E5=89=8A=20=E5=85=BC=E5=AE=B9V2=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 14 +++++ plugins.v2/libraryscraper/__init__.py | 87 +++++++++++++++++---------- 2 files changed, 69 insertions(+), 32 deletions(-) diff --git a/package.v2.json b/package.v2.json index 6346ec4..6bcf7af 100644 --- a/package.v2.json +++ b/package.v2.json @@ -121,5 +121,19 @@ "history": { "v2.0": "兼容MoviePilot V2 版本" } + }, + "LibraryScraper": { + "name": "媒体库刮削", + "description": "定时对媒体库进行刮削,补齐缺失元数据和图片。", + "labels": "刮削", + "version": "2.0", + "icon": "scraper.png", + "author": "jxxghp", + "level": 1, + "history": { + "v2.0": "兼容MoviePilot V2 版本", + "v1.5": "修复未获取fanart图片的问题", + "v1.4.1": "修复nfo文件读取失败时任务中断问题" + } } } \ No newline at end of file diff --git a/plugins.v2/libraryscraper/__init__.py b/plugins.v2/libraryscraper/__init__.py index be18407..ea57013 100644 --- a/plugins.v2/libraryscraper/__init__.py +++ b/plugins.v2/libraryscraper/__init__.py @@ -7,6 +7,8 @@ import pytz from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger +from app import schemas +from app.chain.media import MediaChain from app.core.config import settings from app.core.metainfo import MetaInfoPath from app.db.transferhistory_oper import TransferHistoryOper @@ -25,7 +27,7 @@ class LibraryScraper(_PluginBase): # 插件图标 plugin_icon = "scraper.png" # 插件版本 - plugin_version = "1.5" + plugin_version = "2.0" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -39,6 +41,7 @@ class LibraryScraper(_PluginBase): # 私有属性 transferhis = None + mediachain = None _scheduler = None _scraper = None # 限速开关 @@ -52,6 +55,7 @@ class LibraryScraper(_PluginBase): _event = Event() def init_plugin(self, config: dict = None): + self.mediachain = MediaChain() # 读取配置 if config: self._enabled = config.get("enabled") @@ -184,12 +188,10 @@ class LibraryScraper(_PluginBase): 'component': 'VSelect', 'props': { 'model': 'mode', - 'label': '刮削模式', + 'label': '覆盖模式', 'items': [ - {'title': '仅刮削缺失元数据和图片', 'value': ''}, + {'title': '不覆盖已有元数据', 'value': ''}, {'title': '覆盖所有元数据和图片', 'value': 'force_all'}, - {'title': '覆盖所有元数据', 'value': 'force_nfo'}, - {'title': '覆盖所有图片', 'value': 'force_image'}, ] } } @@ -303,6 +305,8 @@ class LibraryScraper(_PluginBase): exclude_paths = self._exclude_paths.split("\n") # 已选择的目录 paths = self._scraper_paths.split("\n") + # 需要适削的媒体文件夹 + scraper_paths = [] for path in paths: if not path: continue @@ -314,11 +318,12 @@ class LibraryScraper(_PluginBase): mediaType.value == str(str(path).split("#")[1])), None) path = str(path).split("#")[0] + # 判断路径是否存在 scraper_path = Path(path) if not scraper_path.exists(): logger.warning(f"媒体库刮削路径不存在:{path}") continue - logger.info(f"开始刮削媒体库:{path} {mtype} ...") + logger.info(f"开始检索目录:{path} {mtype} ...") # 遍历所有文件 files = SystemUtils.list_files(scraper_path, settings.RMT_MEDIAEXT) for file_path in files: @@ -337,48 +342,58 @@ class LibraryScraper(_PluginBase): if exclude_flag: logger.debug(f"{file_path} 在排除目录中,跳过 ...") continue - # 开始刮削文件 - self.__scrape_file(file=file_path, mtype=mtype) - logger.info(f"媒体库 {path} 刮削完成") + # 识别是电影还是电视剧 + if not mtype: + file_meta = MetaInfoPath(file_path) + mtype = file_meta.type + if mtype == MediaType.TV: + dir_path = file_path.parent.parent + if dir_path not in scraper_paths: + logger.info(f"发现电视剧目录:{dir_path}") + scraper_paths.append((dir_path, mtype)) + else: + dir_path = file_path.parent + if dir_path not in scraper_paths: + logger.info(f"发现电影目录:{dir_path}") + scraper_paths.append((dir_path, mtype)) + # 开始刮削 + if scraper_paths: + for item in scraper_paths: + logger.info(f"开始刮削目录:{item[0]} ...") + self.__scrape_dir(path=item[0], mtype=item[1]) + else: + logger.info(f"未发现需要刮削的目录") - def __scrape_file(self, file: Path, mtype: MediaType = None): + def __scrape_dir(self, path: Path, mtype: MediaType): """ 削刮一个目录,该目录必须是媒体文件目录 """ - # 识别元数据 - meta_info = MetaInfoPath(file) - # 强制指定类型 - if mtype: - meta_info.type = mtype - - # 是否刮削 - force_nfo = self._mode in ["force_all", "force_nfo"] - force_img = self._mode in ["force_all", "force_image"] - # 优先读取本地nfo文件 tmdbid = None - if meta_info.type == MediaType.MOVIE: + if mtype == MediaType.MOVIE: # 电影 - movie_nfo = file.parent / "movie.nfo" + movie_nfo = path / "movie.nfo" if movie_nfo.exists(): tmdbid = self.__get_tmdbid_from_nfo(movie_nfo) - file_nfo = file.with_suffix(".nfo") + file_nfo = path / (path.stem + ".nfo") if not tmdbid and file_nfo.exists(): tmdbid = self.__get_tmdbid_from_nfo(file_nfo) else: # 电视剧 - tv_nfo = file.parent.parent / "tvshow.nfo" + tv_nfo = path / "tvshow.nfo" if tv_nfo.exists(): tmdbid = self.__get_tmdbid_from_nfo(tv_nfo) if tmdbid: # 按TMDBID识别 logger.info(f"读取到本地nfo文件的tmdbid:{tmdbid}") - mediainfo = self.chain.recognize_media(tmdbid=tmdbid, mtype=meta_info.type) + mediainfo = self.chain.recognize_media(tmdbid=tmdbid, mtype=mtype) else: # 按名称识别 - mediainfo = self.chain.recognize_media(meta=meta_info) + meta = MetaInfoPath(path) + meta.type = mtype + mediainfo = self.chain.recognize_media(meta=meta) if not mediainfo: - logger.warn(f"未识别到媒体信息:{file}") + logger.warn(f"未识别到媒体信息:{path}") return # 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title @@ -390,11 +405,19 @@ class LibraryScraper(_PluginBase): # 获取图片 self.chain.obtain_images(mediainfo) # 刮削 - self.chain.scrape_metadata(path=file, - mediainfo=mediainfo, - transfer_type=settings.TRANSFER_TYPE, - force_nfo=force_nfo, - force_img=force_img) + self.mediachain.scrape_metadata( + fileitem=schemas.FileItem( + storage="local", + type="dir", + path=str(path).replace("\\", "/") + "/", + name=path.name, + basename=path.stem, + modify_time=path.stat().st_mtime, + ), + mediainfo=mediainfo, + overwrite=True if self._mode else False + ) + logger.info(f"{path} 刮削完成") @staticmethod def __get_tmdbid_from_nfo(file_path: Path): From d62719e8842695bccf68c1851fbbdffb5e17f1e2 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 19 Oct 2024 18:06:36 +0800 Subject: [PATCH 105/218] fix bug --- plugins.v2/libraryscraper/__init__.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/plugins.v2/libraryscraper/__init__.py b/plugins.v2/libraryscraper/__init__.py index ea57013..682e969 100644 --- a/plugins.v2/libraryscraper/__init__.py +++ b/plugins.v2/libraryscraper/__init__.py @@ -347,15 +347,15 @@ class LibraryScraper(_PluginBase): file_meta = MetaInfoPath(file_path) mtype = file_meta.type if mtype == MediaType.TV: - dir_path = file_path.parent.parent - if dir_path not in scraper_paths: - logger.info(f"发现电视剧目录:{dir_path}") - scraper_paths.append((dir_path, mtype)) + dir_item = (file_path.parent.parent, mtype) + if dir_item not in scraper_paths: + logger.info(f"发现电视剧目录:{dir_item}") + scraper_paths.append(dir_item) else: - dir_path = file_path.parent - if dir_path not in scraper_paths: - logger.info(f"发现电影目录:{dir_path}") - scraper_paths.append((dir_path, mtype)) + dir_item = (file_path.parent, mtype) + if dir_item not in scraper_paths: + logger.info(f"发现电影目录:{dir_item}") + scraper_paths.append(dir_item) # 开始刮削 if scraper_paths: for item in scraper_paths: From eb86003863bb5dbca5838b716f2a612d93863dc8 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 19 Oct 2024 19:04:26 +0800 Subject: [PATCH 106/218] =?UTF-8?q?=E6=BC=94=E8=81=8C=E4=BA=BA=E5=91=98?= =?UTF-8?q?=E5=88=AE=E5=89=8A=20=E9=80=82=E9=85=8DV2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 14 +++ plugins.v2/personmeta/__init__.py | 189 +++++++++++++++++++++--------- 2 files changed, 148 insertions(+), 55 deletions(-) diff --git a/package.v2.json b/package.v2.json index 6bcf7af..eb4a28b 100644 --- a/package.v2.json +++ b/package.v2.json @@ -135,5 +135,19 @@ "v1.5": "修复未获取fanart图片的问题", "v1.4.1": "修复nfo文件读取失败时任务中断问题" } + }, + "PersonMeta": { + "name": "演职人员刮削", + "description": "刮削演职人员图片以及中文名称。", + "labels": "媒体库,刮削", + "version": "2.0", + "icon": "actor.png", + "author": "jxxghp", + "level": 1, + "history": { + "v2.0": "兼容MoviePilot V2 版本", + "v1.4": "人物图片调整为优先从TMDB获取,避免douban图片CDN加载过慢的问题", + "v1.3": "修复v1.8.5版本后刮削报错问题" + } } } \ No newline at end of file diff --git a/plugins.v2/personmeta/__init__.py b/plugins.v2/personmeta/__init__.py index 1c5978c..ea2fca5 100644 --- a/plugins.v2/personmeta/__init__.py +++ b/plugins.v2/personmeta/__init__.py @@ -20,12 +20,10 @@ from app.chain.tmdb import TmdbChain from app.core.config import settings from app.core.event import eventmanager, Event from app.core.meta import MetaBase +from app.helper.mediaserver import MediaServerHelper from app.log import logger -from app.modules.emby import Emby -from app.modules.jellyfin import Jellyfin -from app.modules.plex import Plex from app.plugins import _PluginBase -from app.schemas import MediaInfo, MediaServerItem +from app.schemas import MediaInfo, MediaServerItem, ServiceInfo from app.schemas.types import EventType, MediaType from app.utils.common import retry from app.utils.http import RequestUtils @@ -40,7 +38,7 @@ class PersonMeta(_PluginBase): # 插件图标 plugin_icon = "actor.png" # 插件版本 - plugin_version = "1.4" + plugin_version = "2.0" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -59,16 +57,19 @@ class PersonMeta(_PluginBase): _scheduler = None tmdbchain = None mschain = None + mediaserver_helper = None _enabled = False _onlyonce = False _cron = None _delay = 0 _type = "all" _remove_nozh = False + _mediaservers = [] def init_plugin(self, config: dict = None): self.tmdbchain = TmdbChain() self.mschain = MediaServerChain() + self.mediaserver_helper = MediaServerHelper() if config: self._enabled = config.get("enabled") self._onlyonce = config.get("onlyonce") @@ -76,6 +77,7 @@ class PersonMeta(_PluginBase): self._type = config.get("type") or "all" self._delay = config.get("delay") or 0 self._remove_nozh = config.get("remove_nozh") or False + self._mediaservers = config.get("mediaservers") or [] # 停止现有任务 self.stop_service() @@ -245,6 +247,31 @@ class PersonMeta(_PluginBase): } ] }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': True, + 'chips': True, + 'clearable': True, + 'model': 'mediaservers', + 'label': '媒体服务器', + 'items': [{"title": config.name, "value": config.name} + for config in self.mediaserver_helper.get_configs().values()] + } + } + ] + } + ] + }, { 'component': 'VRow', 'content': [ @@ -280,6 +307,32 @@ class PersonMeta(_PluginBase): def get_page(self) -> List[dict]: pass + def service_infos(self, type_filter: Optional[str] = None) -> Optional[Dict[str, ServiceInfo]]: + """ + 服务信息 + """ + if not self._mediaservers: + logger.warning("尚未配置媒体服务器,请检查配置") + return None + + services = self.mediaserver_helper.get_services(type_filter=type_filter, name_filters=self._mediaservers) + if not services: + logger.warning("获取媒体服务器实例失败,请检查配置") + return None + + active_services = {} + for service_name, service_info in services.items(): + if service_info.instance.is_inactive(): + logger.warning(f"媒体服务器 {service_name} 未连接,请检查配置") + else: + active_services[service_name] = service_info + + if not active_services: + logger.warning("没有已连接的媒体服务器,请检查配置") + return None + + return active_services + @eventmanager.register(EventType.TransferComplete) def scrap_rt(self, event: Event): """ @@ -298,16 +351,16 @@ class PersonMeta(_PluginBase): # 查询媒体服务器中的条目 existsinfo = self.chain.media_exists(mediainfo=mediainfo) if not existsinfo or not existsinfo.itemid: - logger.warn(f"演职人员刮削 {mediainfo.title_year} 在媒体库中不存在") + logger.warn(f"{mediainfo.title_year} 在媒体库中不存在") return # 查询条目详情 iteminfo = self.mschain.iteminfo(server=existsinfo.server, item_id=existsinfo.itemid) if not iteminfo: - logger.warn(f"演职人员刮削 {mediainfo.title_year} 条目详情获取失败") + logger.warn(f"{mediainfo.title_year} 条目详情获取失败") return # 刮削演职人员信息 - self.__update_item(server=existsinfo.server, item=iteminfo, - mediainfo=mediainfo, season=meta.begin_season) + self.__update_item(server=existsinfo.server, server_type=existsinfo.server_type, + item=iteminfo, mediainfo=mediainfo, season=meta.begin_season) def scrap_library(self): """ @@ -339,7 +392,8 @@ class PersonMeta(_PluginBase): logger.info(f"媒体库 {library.name} 的演员信息刮削完成") logger.info(f"服务器 {server} 的演员信息刮削完成") - def __update_peoples(self, server: str, itemid: str, iteminfo: dict, douban_actors): + def __update_peoples(self, server: str, server_type: str, + itemid: str, iteminfo: dict, douban_actors): # 处理媒体项中的人物信息 """ "People": [ @@ -364,8 +418,8 @@ class PersonMeta(_PluginBase): and StringUtils.is_chinese(people.get("Role")): peoples.append(people) continue - info = self.__update_people(server=server, people=people, - douban_actors=douban_actors) + info = self.__update_people(server=server, server_type=server_type, + people=people, douban_actors=douban_actors) if info: peoples.append(info) elif not self._remove_nozh: @@ -373,9 +427,10 @@ class PersonMeta(_PluginBase): # 保存媒体项信息 if peoples: iteminfo["People"] = peoples - self.set_iteminfo(server=server, itemid=itemid, iteminfo=iteminfo) + self.set_iteminfo(server=server, server_type=server_type, + itemid=itemid, iteminfo=iteminfo) - def __update_item(self, server: str, item: MediaServerItem, + def __update_item(self, server: str, item: MediaServerItem, server_type: str = None, mediainfo: MediaInfo = None, season: int = None): """ 更新媒体服务器中的条目 @@ -413,7 +468,7 @@ class PersonMeta(_PluginBase): return # 获取媒体项 - iteminfo = self.get_iteminfo(server=server, itemid=item.item_id) + iteminfo = self.get_iteminfo(server=server, server_type=server_type, itemid=item.item_id) if not iteminfo: logger.warn(f"{item.title} 未找到媒体项") return @@ -422,14 +477,16 @@ class PersonMeta(_PluginBase): # 获取豆瓣演员信息 logger.info(f"开始获取 {item.title} 的豆瓣演员信息 ...") douban_actors = self.__get_douban_actors(mediainfo=mediainfo, season=season) - self.__update_peoples(server=server, itemid=item.item_id, iteminfo=iteminfo, douban_actors=douban_actors) + self.__update_peoples(server=server, server_type=server_type, + itemid=item.item_id, iteminfo=iteminfo, douban_actors=douban_actors) else: logger.info(f"{item.title} 的人物信息已是中文,无需更新") # 处理季和集人物 if iteminfo.get("Type") and "Series" in iteminfo["Type"]: # 获取季媒体项 - seasons = self.get_items(server=server, parentid=item.item_id, mtype="Season") + seasons = self.get_items(server=server, server_type=server_type, + parentid=item.item_id, mtype="Season") if not seasons: logger.warn(f"{item.title} 未找到季媒体项") return @@ -437,40 +494,46 @@ class PersonMeta(_PluginBase): # 获取豆瓣演员信息 season_actors = self.__get_douban_actors(mediainfo=mediainfo, season=season.get("IndexNumber")) # 如果是Jellyfin,更新季的人物,Emby/Plex季没有人物 - if server == "jellyfin": - seasoninfo = self.get_iteminfo(server=server, itemid=season.get("Id")) + if server_type == "jellyfin": + seasoninfo = self.get_iteminfo(server=server, server_type=server_type, + itemid=season.get("Id")) if not seasoninfo: logger.warn(f"{item.title} 未找到季媒体项:{season.get('Id')}") continue if __need_trans_actor(seasoninfo): # 更新季媒体项人物 - self.__update_peoples(server=server, itemid=season.get("Id"), iteminfo=seasoninfo, + self.__update_peoples(server=server, server_type=server_type, + itemid=season.get("Id"), iteminfo=seasoninfo, douban_actors=season_actors) logger.info(f"季 {seasoninfo.get('Id')} 的人物信息更新完成") else: logger.info(f"季 {seasoninfo.get('Id')} 的人物信息已是中文,无需更新") # 获取集媒体项 - episodes = self.get_items(server=server, parentid=season.get("Id"), mtype="Episode") + episodes = self.get_items(server=server, server_type=server_type, + parentid=season.get("Id"), mtype="Episode") if not episodes: logger.warn(f"{item.title} 未找到集媒体项") continue # 更新集媒体项人物 for episode in episodes["Items"]: # 获取集媒体项详情 - episodeinfo = self.get_iteminfo(server=server, itemid=episode.get("Id")) + episodeinfo = self.get_iteminfo(server=server, server_type=server_type, + itemid=episode.get("Id")) if not episodeinfo: logger.warn(f"{item.title} 未找到集媒体项:{episode.get('Id')}") continue if __need_trans_actor(episodeinfo): # 更新集媒体项人物 - self.__update_peoples(server=server, itemid=episode.get("Id"), iteminfo=episodeinfo, + self.__update_peoples(server=server, server_type=server_type, + itemid=episode.get("Id"), iteminfo=episodeinfo, douban_actors=season_actors) logger.info(f"集 {episodeinfo.get('Id')} 的人物信息更新完成") else: logger.info(f"集 {episodeinfo.get('Id')} 的人物信息已是中文,无需更新") - def __update_people(self, server: str, people: dict, douban_actors: list = None) -> Optional[dict]: + def __update_people(self, server: str, server_type: str, + people: dict, douban_actors: list = None) -> Optional[dict]: """ 更新人物信息,返回替换后的人物信息 """ @@ -497,7 +560,8 @@ class PersonMeta(_PluginBase): try: # 查询媒体库人物详情 - personinfo = self.get_iteminfo(server=server, itemid=people.get("Id")) + personinfo = self.get_iteminfo(server=server, server_type=server_type, + itemid=people.get("Id")) if not personinfo: logger.debug(f"未找到人物 {people.get('Name')} 的信息") return None @@ -611,7 +675,8 @@ class PersonMeta(_PluginBase): # 更新人物信息 if updated_name or updated_overview or update_character: logger.debug(f"更新人物 {people.get('Name')} 的信息:{personinfo}") - ret = self.set_iteminfo(server=server, itemid=people.get("Id"), iteminfo=personinfo) + ret = self.set_iteminfo(server=server, server_type=server_type, + itemid=people.get("Id"), iteminfo=personinfo) if ret: return ret_people else: @@ -642,12 +707,16 @@ class PersonMeta(_PluginBase): logger.debug(f"未找到豆瓣信息:{mediainfo.title_year}") return [] - @staticmethod - def get_iteminfo(server: str, itemid: str) -> dict: + def get_iteminfo(self, server: str, server_type: str, itemid: str) -> dict: """ 获得媒体项详情 """ + service = self.service_infos(server_type).get(server) + if not service: + logger.warn(f"未找到媒体服务器 {server} 的实例") + return {} + def __get_emby_iteminfo() -> dict: """ 获得Emby媒体项详情 @@ -655,7 +724,7 @@ class PersonMeta(_PluginBase): try: url = f'[HOST]emby/Users/[USER]/Items/{itemid}?' \ f'Fields=ChannelMappingInfo&api_key=[APIKEY]' - res = Emby().get_data(url=url) + res = service.instance.get_data(url=url) if res: return res.json() except Exception as err: @@ -668,7 +737,7 @@ class PersonMeta(_PluginBase): """ try: url = f'[HOST]Users/[USER]/Items/{itemid}?Fields=ChannelMappingInfo&api_key=[APIKEY]' - res = Jellyfin().get_data(url=url) + res = service.instance.get_data(url=url) if res: result = res.json() if result: @@ -684,7 +753,7 @@ class PersonMeta(_PluginBase): """ iteminfo = {} try: - plexitem = Plex().get_plex().library.fetchItem(ekey=itemid) + plexitem = service.instance.get_plex().library.fetchItem(ekey=itemid) if 'movie' in plexitem.METADATA_TYPE: iteminfo['Type'] = 'Movie' iteminfo['IsFolder'] = False @@ -712,19 +781,21 @@ class PersonMeta(_PluginBase): logger.error(f"获取Plex媒体项详情失败:{str(err)}") return {} - if server == "emby": + if server_type == "emby": return __get_emby_iteminfo() - elif server == "jellyfin": + elif server_type == "jellyfin": return __get_jellyfin_iteminfo() else: return __get_plex_iteminfo() - @staticmethod - def get_items(server: str, parentid: str, mtype: str = None) -> dict: + def get_items(self, server: str, server_type: str, parentid: str, mtype: str = None) -> dict: """ 获得媒体的所有子媒体项 """ - pass + service = self.service_infos(server_type).get(server) + if not service: + logger.warn(f"未找到媒体服务器 {server} 的实例") + return {} def __get_emby_items() -> dict: """ @@ -735,7 +806,7 @@ class PersonMeta(_PluginBase): url = f'[HOST]emby/Users/[USER]/Items?ParentId={parentid}&api_key=[APIKEY]' else: url = '[HOST]emby/Users/[USER]/Items?api_key=[APIKEY]' - res = Emby().get_data(url=url) + res = service.instance.get_data(url=url) if res: return res.json() except Exception as err: @@ -751,7 +822,7 @@ class PersonMeta(_PluginBase): url = f'[HOST]Users/[USER]/Items?ParentId={parentid}&api_key=[APIKEY]' else: url = '[HOST]Users/[USER]/Items?api_key=[APIKEY]' - res = Jellyfin().get_data(url=url) + res = service.instance.get_data(url=url) if res: return res.json() except Exception as err: @@ -764,7 +835,7 @@ class PersonMeta(_PluginBase): """ items = {} try: - plex = Plex().get_plex() + plex = service.instance.get_plex() items['Items'] = [] if parentid: if mtype and 'Season' in mtype: @@ -824,25 +895,29 @@ class PersonMeta(_PluginBase): logger.error(f"获取Plex媒体的所有子媒体项失败:{str(err)}") return {} - if server == "emby": + if server_type == "emby": return __get_emby_items() - elif server == "jellyfin": + elif server_type == "jellyfin": return __get_jellyfin_items() else: return __get_plex_items() - @staticmethod - def set_iteminfo(server: str, itemid: str, iteminfo: dict): + def set_iteminfo(self, server: str, server_type: str, itemid: str, iteminfo: dict): """ 更新媒体项详情 """ + service = self.service_infos(server_type).get(server) + if not service: + logger.warn(f"未找到媒体服务器 {server} 的实例") + return {} + def __set_emby_iteminfo(): """ 更新Emby媒体项详情 """ try: - res = Emby().post_data( + res = service.instance.post_data( url=f'[HOST]emby/Items/{itemid}?api_key=[APIKEY]&reqformat=json', data=json.dumps(iteminfo), headers={ @@ -863,7 +938,7 @@ class PersonMeta(_PluginBase): 更新Jellyfin媒体项详情 """ try: - res = Jellyfin().post_data( + res = service.instance.post_data( url=f'[HOST]Items/{itemid}?api_key=[APIKEY]', data=json.dumps(iteminfo), headers={ @@ -884,7 +959,7 @@ class PersonMeta(_PluginBase): 更新Plex媒体项详情 """ try: - plexitem = Plex().get_plex().library.fetchItem(ekey=itemid) + plexitem = service.instance.get_plex().library.fetchItem(ekey=itemid) if 'CommunityRating' in iteminfo: edits = { 'audienceRating.value': iteminfo['CommunityRating'], @@ -897,20 +972,24 @@ class PersonMeta(_PluginBase): logger.error(f"更新Plex媒体项详情失败:{str(err)}") return False - if server == "emby": + if server_type == "emby": return __set_emby_iteminfo() - elif server == "jellyfin": + elif server_type == "jellyfin": return __set_jellyfin_iteminfo() else: return __set_plex_iteminfo() - @staticmethod @retry(RequestException, logger=logger) - def set_item_image(server: str, itemid: str, imageurl: str): + def set_item_image(self, server: str, server_type: str, itemid: str, imageurl: str): """ 更新媒体项图片 """ + service = self.service_infos(server_type).get(server) + if not service: + logger.warn(f"未找到媒体服务器 {server} 的实例") + return {} + def __download_image(): """ 下载图片 @@ -936,7 +1015,7 @@ class PersonMeta(_PluginBase): """ try: url = f'[HOST]emby/Items/{itemid}/Images/Primary?api_key=[APIKEY]' - res = Emby().post_data( + res = service.instance.post_data( url=url, data=_base64, headers={ @@ -960,7 +1039,7 @@ class PersonMeta(_PluginBase): try: url = f'[HOST]Items/{itemid}/RemoteImages/Download?' \ f'Type=Primary&ImageUrl={imageurl}&ProviderName=TheMovieDb&api_key=[APIKEY]' - res = Jellyfin().post_data(url=url) + res = service.instance.post_data(url=url) if res and res.status_code in [200, 204]: return True else: @@ -976,19 +1055,19 @@ class PersonMeta(_PluginBase): # FIXME 改为预下载图片 """ try: - plexitem = Plex().get_plex().library.fetchItem(ekey=itemid) + plexitem = service.instance.get_plex().library.fetchItem(ekey=itemid) plexitem.uploadPoster(url=imageurl) return True except Exception as err: logger.error(f"更新Plex媒体项图片失败:{err}") return False - if server == "emby": + if server_type == "emby": # 下载图片获取base64 image_base64 = __download_image() if image_base64: return __set_emby_item_image(image_base64) - elif server == "jellyfin": + elif server_type == "jellyfin": return __set_jellyfin_item_image() else: return __set_plex_item_image() From b02c0b81c956d3d93336611ad8cd21b71cfc7296 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 19 Oct 2024 19:16:49 +0800 Subject: [PATCH 107/218] =?UTF-8?q?=E6=92=AD=E6=94=BE=E9=99=90=E9=80=9F=20?= =?UTF-8?q?=E5=85=BC=E5=AE=B9V2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 13 +++ plugins.v2/speedlimiter/__init__.py | 162 ++++++++++++++++------------ 2 files changed, 104 insertions(+), 71 deletions(-) diff --git a/package.v2.json b/package.v2.json index eb4a28b..9ab6200 100644 --- a/package.v2.json +++ b/package.v2.json @@ -149,5 +149,18 @@ "v1.4": "人物图片调整为优先从TMDB获取,避免douban图片CDN加载过慢的问题", "v1.3": "修复v1.8.5版本后刮削报错问题" } + }, + "SpeedLimiter": { + "name": "播放限速", + "description": "外网播放媒体库视频时,自动对下载器进行限速。", + "labels": "网络", + "version": "2.0", + "icon": "Librespeed_A.png", + "author": "Shurelol", + "level": 1, + "history": { + "v2.0": "兼容MoviePilot V2 版本", + "v1.2": "增加不限速路径配置,以应对网盘直链播放的情况" + } } } \ No newline at end of file diff --git a/plugins.v2/speedlimiter/__init__.py b/plugins.v2/speedlimiter/__init__.py index 517f683..5dcfc1b 100644 --- a/plugins.v2/speedlimiter/__init__.py +++ b/plugins.v2/speedlimiter/__init__.py @@ -1,16 +1,12 @@ import ipaddress -from typing import List, Tuple, Dict, Any +from typing import List, Tuple, Dict, Any, Optional -from app.core.config import settings from app.core.event import eventmanager, Event +from app.helper.downloader import DownloaderHelper +from app.helper.mediaserver import MediaServerHelper from app.log import logger -from app.modules.emby import Emby -from app.modules.jellyfin import Jellyfin -from app.modules.plex import Plex -from app.modules.qbittorrent import Qbittorrent -from app.modules.transmission import Transmission from app.plugins import _PluginBase -from app.schemas import NotificationType, WebhookEventInfo +from app.schemas import NotificationType, WebhookEventInfo, ServiceInfo from app.schemas.types import EventType from app.utils.ip import IpUtils @@ -23,7 +19,7 @@ class SpeedLimiter(_PluginBase): # 插件图标 plugin_icon = "Librespeed_A.png" # 插件版本 - plugin_version = "1.2" + plugin_version = "2.0" # 插件作者 plugin_author = "Shurelol" # 作者主页 @@ -36,9 +32,9 @@ class SpeedLimiter(_PluginBase): auth_level = 1 # 私有属性 + downloader_helper = None + mediaserver_helper = None _scheduler = None - _qb = None - _tr = None _enabled: bool = False _notify: bool = False _interval: int = 60 @@ -58,6 +54,8 @@ class SpeedLimiter(_PluginBase): _exclude_path = "" def init_plugin(self, config: dict = None): + self.downloader_helper = DownloaderHelper() + self.mediaserver_helper = MediaServerHelper() # 读取配置 if config: self._enabled = config.get("enabled") @@ -91,11 +89,6 @@ class SpeedLimiter(_PluginBase): self._unlimited_ips["ipv6"] = config.get("ipv6") or "" self._downloader = config.get("downloader") or [] - if self._downloader: - if 'qbittorrent' in self._downloader: - self._qb = Qbittorrent() - if 'transmission' in self._downloader: - self._tr = Transmission() def get_state(self) -> bool: return self._enabled @@ -177,18 +170,20 @@ class SpeedLimiter(_PluginBase): 'content': [ { 'component': 'VCol', + 'props': { + 'cols': 12 + }, 'content': [ { 'component': 'VSelect', 'props': { - 'chips': True, 'multiple': True, - 'model': 'downloader', + 'chips': True, + 'clearable': True, + 'model': 'downloaders', 'label': '下载器', - 'items': [ - {'title': 'Qbittorrent', 'value': 'qbittorrent'}, - {'title': 'Transmission', 'value': 'transmission'}, - ] + 'items': [{"title": config.name, "value": config.name} + for config in self.downloader_helper.get_configs().values()] } } ] @@ -398,12 +393,39 @@ class SpeedLimiter(_PluginBase): def get_page(self) -> List[dict]: pass + @property + def service_infos(self) -> Optional[Dict[str, ServiceInfo]]: + """ + 服务信息 + """ + if not self._downloader: + logger.warning("尚未配置下载器,请检查配置") + return None + + services = self.downloader_helper.get_services(name_filters=self._downloader) + if not services: + logger.warning("获取下载器实例失败,请检查配置") + return None + + active_services = {} + for service_name, service_info in services.items(): + if service_info.instance.is_inactive(): + logger.warning(f"下载器 {service_name} 未连接,请检查配置") + else: + active_services[service_name] = service_info + + if not active_services: + logger.warning("没有已连接的下载器,请检查配置") + return None + + return active_services + @eventmanager.register(EventType.WebhookMessage) def check_playing_sessions(self, event: Event = None): """ 检查播放会话 """ - if not self._qb and not self._tr: + if not self.service_infos: return if not self._enabled: return @@ -420,18 +442,17 @@ class SpeedLimiter(_PluginBase): return # 当前播放的总比特率 total_bit_rate = 0 - # 媒体服务器类型,多个以,分隔 - if not settings.MEDIASERVER: + media_servers = self.mediaserver_helper.get_services() + if not media_servers: return - media_servers = settings.MEDIASERVER.split(',') # 查询所有媒体服务器状态 - for media_server in media_servers: + for server, service in media_servers.items(): # 查询播放中会话 playing_sessions = [] - if media_server == "emby": + if service.type == "emby": req_url = "[HOST]emby/Sessions?api_key=[APIKEY]" try: - res = Emby().get_data(req_url) + res = service.instance.get_data(req_url) if res and res.status_code == 200: sessions = res.json() for session in sessions: @@ -453,10 +474,10 @@ class SpeedLimiter(_PluginBase): elif not IpUtils.is_private_ip(session.get("RemoteEndPoint")) \ and session.get("NowPlayingItem", {}).get("MediaType") == "Video": total_bit_rate += int(session.get("NowPlayingItem", {}).get("Bitrate") or 0) - elif media_server == "jellyfin": + elif service.type == "jellyfin": req_url = "[HOST]Sessions?api_key=[APIKEY]" try: - res = Jellyfin().get_data(req_url) + res = service.instance.get_data(req_url) if res and res.status_code == 200: sessions = res.json() for session in sessions: @@ -481,8 +502,8 @@ class SpeedLimiter(_PluginBase): media_streams = session.get("NowPlayingItem", {}).get("MediaStreams") or [] for media_stream in media_streams: total_bit_rate += int(media_stream.get("BitRate") or 0) - elif media_server == "plex": - _plex = Plex().get_plex() + elif service.type == "plex": + _plex = service.instance.get_plex() if _plex: sessions = _plex.sessions() for session in sessions: @@ -543,7 +564,7 @@ class SpeedLimiter(_PluginBase): """ 设置限速 """ - if not self._qb and not self._tr: + if not self.service_infos: return state = f"U:{upload_limit},D:{download_limit}" if self._current_state == state: @@ -555,6 +576,7 @@ class SpeedLimiter(_PluginBase): try: cnt = 0 for download in self._downloader: + service = self.service_infos.get(download) if self._auto_limit and limit_type == "播放": # 开启了播放智能限速 if len(self._downloader) == 1: @@ -578,44 +600,42 @@ class SpeedLimiter(_PluginBase): text = f"{text}\n下载:{download_limit} KB/s" else: text = f"{text}\n下载:未限速" - if str(download) == 'qbittorrent': - if self._qb: - self._qb.set_speed_limit(download_limit=download_limit, upload_limit=upload_limit) - # 发送通知 - if self._notify: - title = "【播放限速】" - if upload_limit or download_limit: - subtitle = f"Qbittorrent 开始{limit_type}限速" - self.post_message( - mtype=NotificationType.MediaServer, - title=title, - text=f"{subtitle}\n{text}" - ) - else: - self.post_message( - mtype=NotificationType.MediaServer, - title=title, - text=f"Qbittorrent 已取消限速" - ) + if service.type == 'qbittorrent': + service.instance.set_speed_limit(download_limit=download_limit, upload_limit=upload_limit) + # 发送通知 + if self._notify: + title = "【播放限速】" + if upload_limit or download_limit: + subtitle = f"Qbittorrent 开始{limit_type}限速" + self.post_message( + mtype=NotificationType.MediaServer, + title=title, + text=f"{subtitle}\n{text}" + ) + else: + self.post_message( + mtype=NotificationType.MediaServer, + title=title, + text=f"Qbittorrent 已取消限速" + ) else: - if self._tr: - self._tr.set_speed_limit(download_limit=download_limit, upload_limit=upload_limit) - # 发送通知 - if self._notify: - title = "【播放限速】" - if upload_limit or download_limit: - subtitle = f"Transmission 开始{limit_type}限速" - self.post_message( - mtype=NotificationType.MediaServer, - title=title, - text=f"{subtitle}\n{text}" - ) - else: - self.post_message( - mtype=NotificationType.MediaServer, - title=title, - text=f"Transmission 已取消限速" - ) + service.instance.set_speed_limit(download_limit=download_limit, upload_limit=upload_limit) + # 发送通知 + if self._notify: + title = "【播放限速】" + if upload_limit or download_limit: + subtitle = f"Transmission 开始{limit_type}限速" + self.post_message( + mtype=NotificationType.MediaServer, + title=title, + text=f"{subtitle}\n{text}" + ) + else: + self.post_message( + mtype=NotificationType.MediaServer, + title=title, + text=f"Transmission 已取消限速" + ) except Exception as e: logger.error(f"设置限速失败:{str(e)}") From a99bd8143125964a75dcdca5b5856893f17443c4 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 19 Oct 2024 23:23:07 +0800 Subject: [PATCH 108/218] =?UTF-8?q?refactor=EF=BC=9AMoviePilot=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E7=9B=91=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 7 +++++-- plugins/mpserverstatus/__init__.py | 18 ++++++++++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index e8fbce8..66f7bb3 100644 --- a/package.json +++ b/package.json @@ -771,11 +771,14 @@ "name": "MoviePilot服务器监控", "description": "在仪表板中实时显示MoviePilot公共服务器状态。", "labels": "仪表板", - "version": "1.0", + "version": "1.1", "icon": "Duplicati_A.png", "author": "jxxghp", "level": 1, - "v2": true + "v2": true, + "history": { + "v1.1": "增加详情界面显示" + } }, "CleanInvalidSeed": { "name": "清理QB无效做种", diff --git a/plugins/mpserverstatus/__init__.py b/plugins/mpserverstatus/__init__.py index 0768b34..d4d2070 100644 --- a/plugins/mpserverstatus/__init__.py +++ b/plugins/mpserverstatus/__init__.py @@ -15,7 +15,7 @@ class MPServerStatus(_PluginBase): # 插件图标 plugin_icon = "Duplicati_A.png" # 插件版本 - plugin_version = "1.0" + plugin_version = "1.1" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -73,7 +73,21 @@ class MPServerStatus(_PluginBase): } def get_page(self) -> List[dict]: - pass + """ + 获取插件页面 + """ + if not self._enable: + return [ + { + 'component': 'div', + 'text': '插件未启用', + 'props': { + 'class': 'text-center', + } + } + ] + _, _, elements = self.get_dashboard() + return elements def get_dashboard(self) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], List[dict]]]: """ From 64afecb7b41fdbd8e2828b54a58469e81ccbe816 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 19 Oct 2024 23:42:51 +0800 Subject: [PATCH 109/218] =?UTF-8?q?refactor=EF=BC=9A=E5=AE=9A=E6=97=B6?= =?UTF-8?q?=E6=B8=85=E7=90=86=E5=AA=92=E4=BD=93=E5=BA=93=20v2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 12 + plugins.v2/autoclean/__init__.py | 16 +- plugins.v2/bestfilmversion/__init__.py | 708 ---------- plugins.v2/cleaninvalidseed/__init__.py | 918 ------------- plugins.v2/clouddiskdel/__init__.py | 540 -------- plugins.v2/crossseed/__init__.py | 1232 ----------------- plugins.v2/diagparamadjust/__init__.py | 456 ------- plugins.v2/episodegroupmeta/__init__.py | 872 ------------ plugins.v2/mediasyncdel/__init__.py | 1589 ---------------------- plugins.v2/messageforward/__init__.py | 430 ------ plugins.v2/qbcommand/__init__.py | 1171 ---------------- plugins.v2/syncdownloadfiles/__init__.py | 579 -------- plugins.v2/trackereditor/__init__.py | 454 ------- plugins.v2/trcommand/__init__.py | 732 ---------- plugins.v2/vcbanimemonitor/__init__.py | 1124 --------------- plugins.v2/vcbanimemonitor/remeta.py | 284 ---- 16 files changed, 21 insertions(+), 11096 deletions(-) delete mode 100644 plugins.v2/bestfilmversion/__init__.py delete mode 100644 plugins.v2/cleaninvalidseed/__init__.py delete mode 100644 plugins.v2/clouddiskdel/__init__.py delete mode 100644 plugins.v2/crossseed/__init__.py delete mode 100644 plugins.v2/diagparamadjust/__init__.py delete mode 100644 plugins.v2/episodegroupmeta/__init__.py delete mode 100644 plugins.v2/mediasyncdel/__init__.py delete mode 100644 plugins.v2/messageforward/__init__.py delete mode 100644 plugins.v2/qbcommand/__init__.py delete mode 100644 plugins.v2/syncdownloadfiles/__init__.py delete mode 100644 plugins.v2/trackereditor/__init__.py delete mode 100644 plugins.v2/trcommand/__init__.py delete mode 100644 plugins.v2/vcbanimemonitor/__init__.py delete mode 100644 plugins.v2/vcbanimemonitor/remeta.py diff --git a/package.v2.json b/package.v2.json index 9ab6200..5a23f55 100644 --- a/package.v2.json +++ b/package.v2.json @@ -162,5 +162,17 @@ "v2.0": "兼容MoviePilot V2 版本", "v1.2": "增加不限速路径配置,以应对网盘直链播放的情况" } + }, + "AutoClean": { + "name": "定时清理媒体库", + "description": "定时清理用户下载的种子、源文件、媒体库文件。", + "labels": "媒体库", + "version": "2.0", + "icon": "clean.png", + "author": "thsrite", + "level": 2, + "history": { + "v2.0": "兼容MoviePilot V2 版本" + } } } \ No newline at end of file diff --git a/plugins.v2/autoclean/__init__.py b/plugins.v2/autoclean/__init__.py index d7ba6c5..7d909ac 100644 --- a/plugins.v2/autoclean/__init__.py +++ b/plugins.v2/autoclean/__init__.py @@ -1,20 +1,20 @@ import time from collections import defaultdict from datetime import datetime, timedelta -from pathlib import Path +from typing import Any, List, Dict, Tuple, Optional import pytz from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger -from app.chain.transfer import TransferChain +from app import schemas +from app.chain.storage import StorageChain from app.core.config import settings from app.core.event import eventmanager from app.db.downloadhistory_oper import DownloadHistoryOper from app.db.transferhistory_oper import TransferHistoryOper -from app.plugins import _PluginBase -from typing import Any, List, Dict, Tuple, Optional from app.log import logger +from app.plugins import _PluginBase from app.schemas import NotificationType, DownloadHistory from app.schemas.types import EventType @@ -27,7 +27,7 @@ class AutoClean(_PluginBase): # 插件图标 plugin_icon = "clean.png" # 插件版本 - plugin_version = "1.1" + plugin_version = "2.0" # 插件作者 plugin_author = "thsrite" # 作者主页 @@ -205,12 +205,14 @@ class AutoClean(_PluginBase): for history in transferhis_list: # 册除媒体库文件 if clean_type in ["dest", "all"]: - TransferChain().delete_files(Path(history.dest)) + dest_fileitem = schemas.FileItem(**history.dest_fileitem) + StorageChain().delete_file(dest_fileitem) # 删除记录 self._transferhis.delete(history.id) # 删除源文件 if clean_type in ["src", "all"]: - TransferChain().delete_files(Path(history.src)) + src_fileitem = schemas.FileItem(**history.src_fileitem) + StorageChain().delete_file(src_fileitem) # 发送事件 eventmanager.send_event( EventType.DownloadFileDeleted, diff --git a/plugins.v2/bestfilmversion/__init__.py b/plugins.v2/bestfilmversion/__init__.py deleted file mode 100644 index ce0b5f8..0000000 --- a/plugins.v2/bestfilmversion/__init__.py +++ /dev/null @@ -1,708 +0,0 @@ -from datetime import datetime, timedelta -from functools import reduce -from pathlib import Path -from threading import RLock -from typing import Optional, Any, List, Dict, Tuple -from xml.dom.minidom import parseString - -import pytz -from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.triggers.cron import CronTrigger -from requests import Response - -from app.chain.subscribe import SubscribeChain -from app.core.config import settings -from app.core.context import MediaInfo -from app.core.event import eventmanager -from app.log import logger -from app.modules.emby import Emby -from app.modules.jellyfin import Jellyfin -from app.modules.plex import Plex -from app.plugins import _PluginBase -from app.schemas import WebhookEventInfo -from app.schemas.types import MediaType, EventType -from app.utils.http import RequestUtils - -lock = RLock() - - -class BestFilmVersion(_PluginBase): - # 插件名称 - plugin_name = "收藏洗版" - # 插件描述 - plugin_desc = "Jellyfin/Emby/Plex点击收藏电影后,自动订阅洗版。" - # 插件图标 - plugin_icon = "like.jpg" - # 插件版本 - plugin_version = "2.3" - # 插件作者 - plugin_author = "wlj" - # 作者主页 - author_url = "https://github.com/developer-wlj" - # 插件配置项ID前缀 - plugin_config_prefix = "bestfilmversion_" - # 加载顺序 - plugin_order = 13 - # 可使用的用户级别 - auth_level = 2 - - # 私有变量 - _scheduler: Optional[BackgroundScheduler] = None - _cache_path: Optional[Path] = None - subscribechain = None - - # 配置属性 - _enabled: bool = False - _cron: str = "" - _notify: bool = False - _webhook_enabled: bool = False - _only_once: bool = False - - def init_plugin(self, config: dict = None): - self._cache_path = settings.TEMP_PATH / "__best_film_version_cache__" - self.subscribechain = SubscribeChain() - - # 停止现有任务 - self.stop_service() - - # 配置 - if config: - self._enabled = config.get("enabled") - self._cron = config.get("cron") - self._notify = config.get("notify") - self._webhook_enabled = config.get("webhook_enabled") - self._only_once = config.get("only_once") - - if self._only_once: - self._only_once = False - self.update_config({ - "enabled": self._enabled, - "cron": self._cron, - "notify": self._notify, - "webhook_enabled": self._webhook_enabled, - "only_once": self._only_once - }) - self._scheduler = BackgroundScheduler(timezone=settings.TZ) - self._scheduler.add_job(self.sync, 'date', - run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), - name="立即运行收藏洗版") - # 启动任务 - if self._scheduler.get_jobs(): - self._scheduler.print_jobs() - self._scheduler.start() - - def get_state(self) -> bool: - return self._enabled - - @staticmethod - def get_command() -> List[Dict[str, Any]]: - pass - - def get_api(self) -> List[Dict[str, Any]]: - """ - 获取插件API - [{ - "path": "/xx", - "endpoint": self.xxx, - "methods": ["GET", "POST"], - "summary": "API说明" - }] - """ - 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 and not self._webhook_enabled: - if self._cron: - return [{ - "id": "BestFilmVersion", - "name": "收藏洗版定时服务", - "trigger": CronTrigger.from_crontab(self._cron), - "func": self.sync, - "kwargs": {} - }] - return [ - { - "id": "BestFilmVersion", - "name": "收藏洗版定时服务", - "trigger": "interval", - "func": self.sync, - "kwargs": { - "minutes": 30 - } - } - ] - return [] - - def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: - """ - 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 - """ - return [ - { - 'component': 'VForm', - 'content': [ - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 3 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'enabled', - 'label': '启用插件', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 3 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'notify', - 'label': '发送通知', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 3 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'only_once', - 'label': '立即运行一次', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 3 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'webhook_enabled', - 'label': 'Webhook', - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'cron', - 'label': '执行周期', - 'placeholder': '5位cron表达式,留空自动' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '支持主动定时获取媒体库数据和Webhook实时触发两种方式,两者只能选其一,' - 'Webhook需要在媒体服务器设置发送Webhook报文。' - 'Plex使用主动获取时,建议执行周期设置大于1小时,' - '收藏Api调用Plex官网接口,有频率限制。' - } - } - ] - } - ] - } - ] - } - ], { - "enabled": False, - "notify": False, - "cron": "*/30 * * * *", - "webhook_enabled": False, - "only_once": False - } - - def get_page(self) -> List[dict]: - """ - 拼装插件详情页面,需要返回页面配置,同时附带数据 - """ - # 查询同步详情 - historys = self.get_data('history') - if not historys: - return [ - { - 'component': 'div', - 'text': '暂无数据', - 'props': { - 'class': 'text-center', - } - } - ] - # 数据按时间降序排序 - historys = sorted(historys, key=lambda x: x.get('time'), reverse=True) - # 拼装页面 - contents = [] - for history in historys: - title = history.get("title") - poster = history.get("poster") - mtype = history.get("type") - time_str = history.get("time") - tmdbid = history.get("tmdbid") - contents.append( - { - 'component': 'VCard', - 'content': [ - { - 'component': 'div', - 'props': { - 'class': 'd-flex justify-space-start flex-nowrap flex-row', - }, - 'content': [ - { - 'component': 'div', - 'content': [ - { - 'component': 'VImg', - 'props': { - 'src': poster, - 'height': 120, - 'width': 80, - 'aspect-ratio': '2/3', - 'class': 'object-cover shadow ring-gray-500', - 'cover': True - } - } - ] - }, - { - 'component': 'div', - 'content': [ - { - 'component': 'VCardTitle', - 'props': { - 'class': 'ps-1 pe-5 break-words whitespace-break-spaces' - }, - 'content': [ - { - 'component': 'a', - 'props': { - 'href': f"https://www.themoviedb.org/movie/{tmdbid}", - 'target': '_blank' - }, - 'text': title - } - ] - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'类型:{mtype}' - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'时间:{time_str}' - } - ] - } - ] - } - ] - } - ) - - return [ - { - 'component': 'div', - 'props': { - 'class': 'grid gap-3 grid-info-card', - }, - 'content': contents - } - ] - - def stop_service(self): - """ - 退出插件 - """ - try: - if self._scheduler: - self._scheduler.remove_all_jobs() - if self._scheduler.running: - self._scheduler.shutdown() - self._scheduler = None - except Exception as e: - logger.error("退出插件失败:%s" % str(e)) - - def sync(self): - """ - 通过流媒体管理工具收藏,自动洗版 - """ - # 获取锁 - _is_lock: bool = lock.acquire(timeout=60) - if not _is_lock: - return - try: - # 读取缓存 - caches = self._cache_path.read_text().split("\n") if self._cache_path.exists() else [] - # 读取历史记录 - history = self.get_data('history') or [] - - # 媒体服务器类型,多个以,分隔 - if not settings.MEDIASERVER: - return - media_servers = settings.MEDIASERVER.split(',') - - # 读取收藏 - all_items = {} - for media_server in media_servers: - if media_server == 'jellyfin': - all_items['jellyfin'] = self.jellyfin_get_items() - elif media_server == 'emby': - all_items['emby'] = self.emby_get_items() - else: - all_items['plex'] = self.plex_get_watchlist() - - def function(y, x): - return y if (x['Name'] in [i['Name'] for i in y]) else (lambda z, u: (z.append(u), z))(y, x)[1] - - # 处理所有结果 - for server, all_item in all_items.items(): - # all_item 根据电影名去重 - result = reduce(function, all_item, []) - for data in result: - # 检查缓存 - if data.get('Name') in caches: - continue - - # 获取详情 - if server == 'jellyfin': - item_info_resp = Jellyfin().get_iteminfo(itemid=data.get('Id')) - elif server == 'emby': - item_info_resp = Emby().get_iteminfo(itemid=data.get('Id')) - else: - item_info_resp = self.plex_get_iteminfo(itemid=data.get('Id')) - logger.debug(f'BestFilmVersion插件 item打印 {item_info_resp}') - if not item_info_resp: - continue - - # 只接受Movie类型 - if data.get('Type') != 'Movie': - continue - - # 获取tmdb_id - tmdb_id = item_info_resp.get("tmdbid") if server == 'plex' else item_info_resp.tmdbid - if not tmdb_id: - continue - # 识别媒体信息 - mediainfo: MediaInfo = self.chain.recognize_media(tmdbid=tmdb_id, mtype=MediaType.MOVIE) - if not mediainfo: - logger.warn(f'未识别到媒体信息,标题:{data.get("Name")},tmdbid:{tmdb_id}') - continue - # 添加订阅 - self.subscribechain.add(mtype=MediaType.MOVIE, - title=mediainfo.title, - year=mediainfo.year, - tmdbid=mediainfo.tmdb_id, - best_version=True, - username="收藏洗版", - exist_ok=True) - # 加入缓存 - caches.append(data.get('Name')) - # 存储历史记录 - if mediainfo.tmdb_id not in [h.get("tmdbid") for h in history]: - history.append({ - "title": mediainfo.title, - "type": mediainfo.type.value, - "year": mediainfo.year, - "poster": mediainfo.get_poster_image(), - "overview": mediainfo.overview, - "tmdbid": mediainfo.tmdb_id, - "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S") - }) - # 保存历史记录 - self.save_data('history', history) - # 保存缓存 - self._cache_path.write_text("\n".join(caches)) - finally: - lock.release() - - def jellyfin_get_items(self) -> List[dict]: - # 获取所有user - users_url = "[HOST]Users?&apikey=[APIKEY]" - users = self.get_users(Jellyfin().get_data(users_url)) - if not users: - logger.info(f"bestfilmversion/users_url: {users_url}") - return [] - all_items = [] - for user in users: - # 根据加入日期 降序排序 - url = "[HOST]Users/" + user + "/Items?SortBy=DateCreated%2CSortName" \ - "&SortOrder=Descending" \ - "&Filters=IsFavorite" \ - "&Recursive=true" \ - "&Fields=PrimaryImageAspectRatio%2CBasicSyncInfo" \ - "&CollapseBoxSetItems=false" \ - "&ExcludeLocationTypes=Virtual" \ - "&EnableTotalRecordCount=false" \ - "&Limit=20" \ - "&apikey=[APIKEY]" - resp = self.get_items(Jellyfin().get_data(url)) - if not resp: - continue - all_items.extend(resp) - return all_items - - def emby_get_items(self) -> List[dict]: - # 获取所有user - get_users_url = "[HOST]Users?&api_key=[APIKEY]" - users = self.get_users(Emby().get_data(get_users_url)) - if not users: - return [] - all_items = [] - for user in users: - # 根据加入日期 降序排序 - url = "[HOST]emby/Users/" + user + "/Items?SortBy=DateCreated%2CSortName" \ - "&SortOrder=Descending" \ - "&Filters=IsFavorite" \ - "&Recursive=true" \ - "&Fields=PrimaryImageAspectRatio%2CBasicSyncInfo" \ - "&CollapseBoxSetItems=false" \ - "&ExcludeLocationTypes=Virtual" \ - "&EnableTotalRecordCount=false" \ - "&Limit=20&api_key=[APIKEY]" - resp = self.get_items(Emby().get_data(url)) - if not resp: - continue - all_items.extend(resp) - return all_items - - @staticmethod - def get_items(resp: Response): - try: - if resp: - return resp.json().get("Items") or [] - else: - return [] - except Exception as e: - print(str(e)) - return [] - - @staticmethod - def get_users(resp: Response): - try: - if resp: - return [data['Id'] for data in resp.json()] - else: - logger.error(f"BestFilmVersion/Users 未获取到返回数据") - return [] - except Exception as e: - logger.error(f"连接BestFilmVersion/Users 出错:" + str(e)) - return [] - - @staticmethod - def plex_get_watchlist() -> List[dict]: - # 根据加入日期 降序排序 - url = f"https://metadata.provider.plex.tv/library/sections/watchlist/all?type=1&sort=addedAt%3Adesc" \ - f"&X-Plex-Container-Start=0&X-Plex-Container-Size=50" \ - f"&X-Plex-Token={settings.PLEX_TOKEN}" - res = [] - try: - resp = RequestUtils().get_res(url=url) - if resp: - dom = parseString(resp.text) - # 获取文档元素对象 - elem = dom.documentElement - # 获取 指定元素 - eles = elem.getElementsByTagName('Video') - if not eles: - return [] - for ele in eles: - data = {} - # 获取标签中内容 - ele_id = ele.attributes['ratingKey'].nodeValue - ele_title = ele.attributes['title'].nodeValue - ele_type = ele.attributes['type'].nodeValue - _type = "Movie" if ele_type == "movie" else "" - data['Id'] = ele_id - data['Name'] = ele_title - data['Type'] = _type - res.append(data) - return res - else: - logger.error(f"Plex/Watchlist 未获取到返回数据") - return [] - except Exception as e: - logger.error(f"连接Plex/Watchlist 出错:" + str(e)) - return [] - - @staticmethod - def plex_get_iteminfo(itemid) -> dict: - url = f"https://metadata.provider.plex.tv/library/metadata/{itemid}" \ - f"?X-Plex-Token={settings.PLEX_TOKEN}" - try: - resp = RequestUtils(accept_type="application/json, text/plain, */*").get_res(url=url) - if resp: - metadata = resp.json().get('MediaContainer').get('Metadata') - for item in metadata: - _guid = item.get('Guid') - if not _guid: - continue - - id_list = [h.get('id') for h in _guid if h.get('id').__contains__("tmdb")] - if not id_list: - continue - - return {'tmdbid': id_list[0].split("/")[-1]} - - return {} - else: - logger.error(f"Plex/Items 未获取到返回数据") - return {} - except Exception as e: - logger.error(f"连接Plex/Items 出错:" + str(e)) - return {} - - @eventmanager.register(EventType.WebhookMessage) - def webhook_message_action(self, event): - - if not self._enabled: - return - if not self._webhook_enabled: - return - - data: WebhookEventInfo = event.event_data - # 排除不是收藏调用 - if data.channel not in ['jellyfin', 'emby', 'plex']: - return - if data.channel in ['emby', 'plex'] and data.event != 'item.rate': - return - if data.channel == 'jellyfin' and data.save_reason != 'UpdateUserRating': - return - logger.info(f'BestFilmVersion/webhook_message_action WebhookEventInfo打印:{data}') - - # 获取锁 - _is_lock: bool = lock.acquire(timeout=60) - if not _is_lock: - return - try: - if not data.tmdb_id: - info = None - if (data.channel == 'jellyfin' - and data.save_reason == 'UpdateUserRating' - and data.item_favorite): - info = Jellyfin().get_iteminfo(itemid=data.item_id) - elif data.channel == 'emby' and data.event == 'item.rate': - info = Emby().get_iteminfo(itemid=data.item_id) - elif data.channel == 'plex' and data.event == 'item.rate': - info = Plex().get_iteminfo(itemid=data.item_id) - logger.debug(f'BestFilmVersion/webhook_message_action item打印:{info}') - if not info: - return - if info.item_type not in ['Movie', 'MOV', 'movie']: - return - # 获取tmdb_id - tmdb_id = info.tmdbid - else: - tmdb_id = data.tmdb_id - if (data.channel == 'jellyfin' - and (data.save_reason != 'UpdateUserRating' or not data.item_favorite)): - return - if data.item_type not in ['Movie', 'MOV', 'movie']: - return - # 识别媒体信息 - mediainfo = self.chain.recognize_media(tmdbid=tmdb_id, mtype=MediaType.MOVIE) - if not mediainfo: - logger.warn(f'未识别到媒体信息,标题:{data.item_name},tmdbID:{tmdb_id}') - return - # 读取缓存 - caches = self._cache_path.read_text().split("\n") if self._cache_path.exists() else [] - # 检查缓存 - if data.item_name in caches: - return - # 读取历史记录 - history = self.get_data('history') or [] - # 添加订阅 - self.subscribechain.add(mtype=MediaType.MOVIE, - title=mediainfo.title, - year=mediainfo.year, - tmdbid=mediainfo.tmdb_id, - best_version=True, - username="收藏洗版", - exist_ok=True) - # 加入缓存 - caches.append(data.item_name) - # 存储历史记录 - if mediainfo.tmdb_id not in [h.get("tmdbid") for h in history]: - history.append({ - "title": mediainfo.title, - "type": mediainfo.type.value, - "year": mediainfo.year, - "poster": mediainfo.get_poster_image(), - "overview": mediainfo.overview, - "tmdbid": mediainfo.tmdb_id, - "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S") - }) - # 保存历史记录 - self.save_data('history', history) - # 保存缓存 - self._cache_path.write_text("\n".join(caches)) - finally: - lock.release() diff --git a/plugins.v2/cleaninvalidseed/__init__.py b/plugins.v2/cleaninvalidseed/__init__.py deleted file mode 100644 index d041065..0000000 --- a/plugins.v2/cleaninvalidseed/__init__.py +++ /dev/null @@ -1,918 +0,0 @@ -import glob -import os -import shutil -import time -from datetime import datetime, timedelta -from pathlib import Path - -import pytz -from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.triggers.cron import CronTrigger -from app.modules.qbittorrent import Qbittorrent -from app.utils.string import StringUtils -from app.schemas.types import EventType -from app.core.event import eventmanager, Event - -from app.core.config import settings -from app.plugins import _PluginBase -from typing import Any, List, Dict, Tuple, Optional -from app.log import logger -from app.schemas import NotificationType - - -class CleanInvalidSeed(_PluginBase): - # 插件名称 - plugin_name = "清理QB无效做种" - # 插件描述 - plugin_desc = "清理已经被站点删除的种子及源文件,仅支持QB" - # 插件图标 - plugin_icon = "clean_a.png" - # 插件版本 - plugin_version = "2.2" - # 插件作者 - plugin_author = "DzAvril" - # 作者主页 - author_url = "https://github.com/DzAvril" - # 插件配置项ID前缀 - plugin_config_prefix = "cleaninvalidseed" - # 加载顺序 - plugin_order = 1 - # 可使用的用户级别 - auth_level = 1 - - # 私有属性 - _enabled = False - _cron = None - _notify = False - _onlyonce = False - _qb = None - _detect_invalid_files = False - _delete_invalid_files = False - _delete_invalid_torrents = False - _notify_all = False - _label_only = False - _label = "" - _download_dirs = "" - _exclude_keywords = "" - _exclude_categories = "" - _exclude_labels = "" - _more_logs = False - # 定时器 - _scheduler: Optional[BackgroundScheduler] = None - _error_msg = [ - "torrent not registered with this tracker", - "Torrent not registered with this tracker", - "torrent banned", - "err torrent banned", - ] - _custom_error_msg = "" - - def init_plugin(self, config: dict = None): - # 停止现有任务 - self.stop_service() - - if config: - self._enabled = config.get("enabled") - self._cron = config.get("cron") - self._notify = config.get("notify") - self._onlyonce = config.get("onlyonce") - self._delete_invalid_torrents = config.get("delete_invalid_torrents") - self._delete_invalid_files = config.get("delete_invalid_files") - self._detect_invalid_files = config.get("detect_invalid_files") - self._notify_all = config.get("notify_all") - self._label_only = config.get("label_only") - self._label = config.get("label") - self._download_dirs = config.get("download_dirs") - self._exclude_keywords = config.get("exclude_keywords") - self._exclude_categories = config.get("exclude_categories") - self._exclude_labels = config.get("exclude_labels") - self._custom_error_msg = config.get("custom_error_msg") - self._more_logs = config.get("more_logs") - self._qb = Qbittorrent() - - # 加载模块 - if self._onlyonce: - self._scheduler = BackgroundScheduler(timezone=settings.TZ) - logger.info(f"清理无效种子服务启动,立即运行一次") - self._scheduler.add_job( - func=self.clean_invalid_seed, - trigger="date", - run_date=datetime.now(tz=pytz.timezone(settings.TZ)) - + timedelta(seconds=3), - name="清理无效种子", - ) - # 关闭一次性开关 - self._onlyonce = False - self._update_config() - - # 启动任务 - if self._scheduler.get_jobs(): - self._scheduler.print_jobs() - self._scheduler.start() - - def get_state(self) -> bool: - return self._enabled - - def _update_config(self): - self.update_config( - { - "onlyonce": False, - "cron": self._cron, - "enabled": self._enabled, - "notify": self._notify, - "delete_invalid_torrents": self._delete_invalid_torrents, - "delete_invalid_files": self._delete_invalid_files, - "detect_invalid_files": self._detect_invalid_files, - "notify_all": self._notify_all, - "label_only": self._label_only, - "label": self._label, - "download_dirs": self._download_dirs, - "exclude_keywords": self._exclude_keywords, - "exclude_categories": self._exclude_categories, - "exclude_labels": self._exclude_labels, - "custom_error_msg": self._custom_error_msg, - "more_logs": self._more_logs, - } - ) - - @staticmethod - def get_command() -> List[Dict[str, Any]]: - """ - 定义远程控制命令 - :return: 命令关键字、事件、描述、附带数据 - """ - return [ - { - "cmd": "/detect_invalid_torrents", - "event": EventType.PluginAction, - "desc": "检测无效做种", - "category": "QB", - "data": {"action": "detect_invalid_torrents"}, - }, - { - "cmd": "/delete_invalid_torrents", - "event": EventType.PluginAction, - "desc": "清理无效做种", - "category": "QB", - "data": {"action": "delete_invalid_torrents"}, - }, - { - "cmd": "/detect_invalid_files", - "event": EventType.PluginAction, - "desc": "检测无效源文件", - "category": "QB", - "data": {"action": "detect_invalid_files"}, - }, - { - "cmd": "/delete_invalid_files", - "event": EventType.PluginAction, - "desc": "清理无效源文件", - "category": "QB", - "data": {"action": "delete_invalid_files"}, - }, - { - "cmd": "/toggle_notify_all", - "event": EventType.PluginAction, - "desc": "QB清理插件切换全量通知", - "category": "QB", - "data": {"action": "toggle_notify_all"}, - }, - ] - - @eventmanager.register(EventType.PluginAction) - def handle_commands(self, event: Event): - if event: - event_data = event.event_data - if event_data: - if not ( - event_data.get("action") == "detect_invalid_torrents" - or event_data.get("action") == "delete_invalid_torrents" - or event_data.get("action") == "detect_invalid_files" - or event_data.get("action") == "delete_invalid_files" - or event_data.get("action") == "toggle_notify_all" - ): - return - self.post_message( - channel=event.event_data.get("channel"), - title="开始执行远程命令...", - userid=event.event_data.get("user"), - ) - old_delete_invalid_torrents = self._delete_invalid_torrents - old_detect_invalid_files = self._detect_invalid_files - old_delete_invalid_files = self._delete_invalid_files - if event_data.get("action") == "detect_invalid_torrents": - logger.info("收到远程命令,开始检测无效做种") - self._delete_invalid_torrents = False - self._detect_invalid_files = False - self._delete_invalid_files = False - self.clean_invalid_seed() - elif event_data.get("action") == "delete_invalid_torrents": - logger.info("收到远程命令,开始清理无效做种") - self._delete_invalid_torrents = True - self._detect_invalid_files = False - self._delete_invalid_files = False - self.clean_invalid_seed() - elif event_data.get("action") == "detect_invalid_files": - logger.info("收到远程命令,开始检测无效源文件") - self._delete_invalid_files = False - self.detect_invalid_files() - elif event_data.get("action") == "delete_invalid_files": - logger.info("收到远程命令,开始清理无效源文件") - self._delete_invalid_files = True - self.detect_invalid_files() - elif event_data.get("action") == "toggle_notify_all": - self._notify_all = not self._notify_all - self._update_config() - if self._notify_all: - self.post_message( - channel=event.event_data.get("channel"), - title="已开启全量通知", - userid=event.event_data.get("user"), - ) - else: - self.post_message( - channel=event.event_data.get("channel"), - title="已关闭全量通知", - userid=event.event_data.get("user"), - ) - return - else: - logger.error("收到未知远程命令") - return - self._delete_invalid_torrents = old_delete_invalid_torrents - self._detect_invalid_files = old_detect_invalid_files - self._delete_invalid_files = old_delete_invalid_files - self.post_message( - channel=event.event_data.get("channel"), - title="远程命令执行完成!", - userid=event.event_data.get("user"), - ) - - 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 and self._cron: - return [ - { - "id": "CleanInvalidSeed", - "name": "清理QB无效做种", - "trigger": CronTrigger.from_crontab(self._cron), - "func": self.clean_invalid_seed, - "kwargs": {}, - } - ] - - def get_all_torrents(self): - all_torrents, error = self._qb.get_torrents() - if error: - logger.error(f"获取QB种子失败: {error}") - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【清理QB无效做种】", - text=f"获取QB种子失败,请检查QB配置", - ) - return [] - - if not all_torrents: - logger.warning("QB没有种子") - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【清理QB无效做种】", - text=f"QB中没有种子", - ) - return [] - return all_torrents - - def clean_invalid_seed(self): - logger.info("开始清理QB无效做种") - all_torrents = self.get_all_torrents() - temp_invalid_torrents = [] - # tracker未工作,但暂时不能判定为失效做种,需人工判断 - tracker_not_working_torrents = [] - working_tracker_set = set() - exclude_categories = ( - self._exclude_categories.split("\n") if self._exclude_categories else [] - ) - exclude_labels = ( - self._exclude_labels.split("\n") if self._exclude_labels else [] - ) - custom_msgs = ( - self._custom_error_msg.split("\n") if self._custom_error_msg else [] - ) - error_msgs = self._error_msg + custom_msgs - # 第一轮筛选出所有未工作的种子 - for torrent in all_torrents: - trackers = torrent.trackers - is_invalid = True - is_tracker_working = False - for tracker in trackers: - if tracker.get("tier") == -1: - continue - tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1] - # 有一个tracker工作即为有效做种 - if (tracker.get("status") == 2) or (tracker.get("status") == 3): - is_tracker_working = True - - if not ( - (tracker.get("status") == 4) and (tracker.get("msg") in error_msgs) - ): - is_invalid = False - working_tracker_set.add(tracker_domian) - - if self._more_logs: - logger.info(f"处理 [{torrent.name}] tracker [{tracker_domian}]: 分类: [{torrent.category}], 标签: [{torrent.tags}], 状态: [{tracker.get('status')}], msg: [{tracker.get('msg')}], is_invalid: [{is_invalid}], is_working: [{is_tracker_working}]") - if is_invalid: - temp_invalid_torrents.append(torrent) - elif not is_tracker_working: - # 排除已暂停的种子 - if not torrent.state_enum.is_paused: - tracker_not_working_torrents.append(torrent) - - logger.info(f"初筛共有{len(temp_invalid_torrents)}个无效做种") - # 第二轮筛选出tracker有正常工作种子而当前种子未工作的,避免因临时关站或tracker失效导致误删的问题 - # 失效做种但通过种子分类排除的种子 - invalid_torrents_exclude_categories = [] - # 失效做种但通过种子标签排除的种子 - invalid_torrents_exclude_labels = [] - # 将invalid_torrents基本信息保存起来,在种子被删除后依然可以打印这些信息 - invalid_torrent_tuple_list = [] - deleted_torrent_tuple_list = [] - for torrent in temp_invalid_torrents: - trackers = torrent.trackers - for tracker in trackers: - if tracker.get("tier") == -1: - continue - tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1] - if tracker_domian in working_tracker_set: - # tracker是正常的,说明该种子是无效的 - invalid_torrent_tuple_list.append( - ( - torrent.name, - torrent.category, - torrent.tags, - torrent.size, - tracker_domian, - tracker.msg, - ) - ) - if self._delete_invalid_torrents or self._label_only: - # 检查种子分类和标签是否排除 - is_excluded = False - if torrent.category in exclude_categories: - is_excluded = True - invalid_torrents_exclude_categories.append(torrent) - torrent_labels = [ - tag.strip() for tag in torrent.tags.split(",") - ] - for label in torrent_labels: - if label in exclude_labels: - is_excluded = True - invalid_torrents_exclude_labels.append(torrent) - if not is_excluded: - if self._label_only: - # 仅标记 - self._qb.set_torrents_tag(ids=torrent.get("hash"), tags=[self._label if self._label != "" else "无效做种"]) - else: - # 只删除种子不删除文件,以防其它站点辅种 - self._qb.delete_torrents(False, torrent.get("hash")) - # 标记已处理种子信息 - deleted_torrent_tuple_list.append( - ( - torrent.name, - torrent.category, - torrent.tags, - torrent.size, - tracker_domian, - tracker.msg, - ) - ) - break - invalid_msg = f"检测到{len(invalid_torrent_tuple_list)}个失效做种\n" - tracker_not_working_msg = f"检测到{len(tracker_not_working_torrents)}个tracker未工作做种,请检查种子状态\n" - - if self._label_only or self._delete_invalid_torrents: - if self._label_only: - deleted_msg = f"标记了{len(deleted_torrent_tuple_list)}个失效种子\n" - else: - deleted_msg = f"删除了{len(deleted_torrent_tuple_list)}个失效种子\n" - if len(exclude_categories) != 0: - exclude_categories_msg = f"分类排除{len(invalid_torrents_exclude_categories)}个失效种子未删除,请手动处理\n" - if len(exclude_labels) != 0: - exclude_labels_msg = f"标签排除{len(invalid_torrents_exclude_labels)}个失效种子未删除,请手动处理\n" - for index in range(len(invalid_torrent_tuple_list)): - torrent = invalid_torrent_tuple_list[index] - invalid_msg += f"{index + 1}. {torrent[0]},分类:{torrent[1]},标签:{torrent[2]}, 大小:{StringUtils.str_filesize(torrent[3])},Trackers: {torrent[4]}:{torrent[5]}\n" - - for index in range(len(tracker_not_working_torrents)): - torrent = tracker_not_working_torrents[index] - trackers = torrent.trackers - tracker_msg = "" - for tracker in trackers: - if tracker.get("tier") == -1: - continue - tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1] - tracker_msg += f" {tracker_domian}:{tracker.msg} " - tracker_not_working_msg += f"{index + 1}. {torrent.name},分类:{torrent.category},标签:{torrent.tags}, 大小:{StringUtils.str_filesize(torrent.size)},Trackers: {tracker_msg}\n" - - for index in range(len(invalid_torrents_exclude_categories)): - torrent = invalid_torrents_exclude_categories[index] - trackers = torrent.trackers - tracker_msg = "" - for tracker in trackers: - if tracker.get("tier") == -1: - continue - tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1] - tracker_msg += f" {tracker_domian}:{tracker.msg} " - exclude_categories_msg += f"{index + 1}. {torrent.name},分类:{torrent.category},标签:{torrent.tags}, 大小:{StringUtils.str_filesize(torrent.size)},Trackers: {tracker_msg}\n" - - for index in range(len(invalid_torrents_exclude_labels)): - torrent = invalid_torrents_exclude_labels[index] - trackers = torrent.trackers - tracker_msg = "" - for tracker in trackers: - if tracker.get("tier") == -1: - continue - tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1] - tracker_msg += f" {tracker_domian}:{tracker.msg} " - exclude_labels_msg += f"{index + 1}. {torrent.name},分类:{torrent.category},标签:{torrent.tags}, 大小:{StringUtils.str_filesize(torrent.size)},Trackers: {tracker_msg}\n" - - for index in range(len(deleted_torrent_tuple_list)): - torrent = deleted_torrent_tuple_list[index] - deleted_msg += f"{index + 1}. {torrent[0]},分类:{torrent[1]},标签:{torrent[2]}, 大小:{StringUtils.str_filesize(torrent[3])},Trackers: {torrent[4]}:{torrent[5]}\n" - - # 日志 - logger.info(invalid_msg) - logger.info(tracker_not_working_msg) - if self._delete_invalid_torrents: - logger.info(deleted_msg) - if len(exclude_categories) != 0: - logger.info(exclude_categories_msg) - if len(exclude_labels) != 0: - logger.info(exclude_labels_msg) - # 通知 - if self._notify: - invalid_msg = invalid_msg.replace("_", "\_") - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【清理无效做种】", - text=invalid_msg, - ) - if self._notify_all: - tracker_not_working_msg = tracker_not_working_msg.replace("_", "\_") - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【清理无效做种】", - text=tracker_not_working_msg, - ) - if self._label_only or self._delete_invalid_torrents: - deleted_msg = deleted_msg.replace("_", "\_") - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【清理无效做种】", - text=deleted_msg, - ) - if self._notify_all: - exclude_categories_msg = exclude_categories_msg.replace("_", "\_") - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【清理无效做种】", - text=exclude_categories_msg, - ) - exclude_labels_msg = exclude_labels_msg.replace("_", "\_") - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【清理无效做种】", - text=exclude_labels_msg, - ) - logger.info("检测无效做种任务结束") - if self._detect_invalid_files: - self.detect_invalid_files() - - def detect_invalid_files(self): - logger.info("开始检测未做种的无效源文件") - all_torrents = self.get_all_torrents() - source_path_map = {} - source_paths = [] - total_size = 0 - deleted_file_cnt = 0 - exclude_key_words = ( - self._exclude_keywords.split("\n") if self._exclude_keywords else [] - ) - if not self._download_dirs: - logger.error("未配置下载目录,无法检测未做种无效源文件") - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【检测无效源文件】", - text="未配置下载目录,无法检测未做种无效源文件", - ) - return - for path in self._download_dirs.split("\n"): - mp_path, qb_path = path.split(":") - source_path_map[mp_path] = qb_path - source_paths.append(mp_path) - # 所有做种源文件路径 - content_path_set = set() - for torrent in all_torrents: - content_path_set.add(torrent.content_path) - - message = "检测未做种无效源文件:\n" - for source_path_str in source_paths: - source_path = Path(source_path_str) - # 判断source_path是否存在 - if not source_path.exists(): - logger.error(f"{source_path} 不存在,无法检测未做种无效源文件") - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【检测无效源文件】", - text=f"{source_path} 不存在,无法检测未做种无效源文件", - ) - continue - source_files = [] - # 获取source_path下的所有文件包括文件夹 - for file in source_path.iterdir(): - source_files.append(file) - for source_file in source_files: - skip = False - for key_word in exclude_key_words: - if key_word in source_file.name: - logger.info(f"{str(source_file)}命中关键字{key_word},不做处理") - skip = True - break - if skip: - continue - # 将mp_path替换成 qb_path - qb_path = (str(source_file)).replace( - source_path_str, source_path_map[source_path_str] - ) - # todo: 优化性能 - is_exist = False - for content_path in content_path_set: - if qb_path in content_path: - is_exist = True - break - - if not is_exist: - deleted_file_cnt += 1 - message += f"{deleted_file_cnt}. {str(source_file)}\n" - total_size += self.get_size(source_file) - if self._delete_invalid_files: - if source_file.is_file(): - source_file.unlink() - elif source_file.is_dir(): - shutil.rmtree(source_file) - - message += f"检测到{deleted_file_cnt}个未做种的无效源文件,共占用{StringUtils.str_filesize(total_size)}空间。\n" - if self._delete_invalid_files: - message += f"***已删除无效源文件,释放{StringUtils.str_filesize(total_size)}空间!***\n" - logger.info(message) - if self._notify: - message = message.replace("_", "\_") - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【清理无效做种】", - text=message, - ) - logger.info("检测无效源文件任务结束") - - def get_size(self, path: Path): - total_size = 0 - if path.is_file(): - return path.stat().st_size - # rglob 方法用于递归遍历所有文件和目录 - for entry in path.rglob("*"): - if entry.is_file(): - total_size += entry.stat().st_size - return total_size - - def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: - return [ - { - "component": "VForm", - "content": [ - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "enabled", - "label": "启用插件", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "notify", - "label": "开启通知", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "onlyonce", - "label": "立即运行一次", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "delete_invalid_torrents", - "label": "删除无效种子(确认无误后再开启)", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "detect_invalid_files", - "label": "检测无效源文件", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "delete_invalid_files", - "label": "删除无效源文件(确认无误后再开启)", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "notify_all", - "label": "全量通知", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "label_only", - "label": "仅标记模式(开启后不会删种)", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "more_logs", - "label": "打印更多日志", - }, - } - ], - }, - ], - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": { "cols": 12, "md": 6 }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "cron", - "label": "执行周期", - }, - } - ], - }, - { - "component": "VCol", - "props": { "cols": 12, "md": 6 }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "label", - "label": "增加标签", - "placeholder": "仅标记模式下生效,给待处理的种子打标签", - }, - } - ], - }, - ], - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": {"cols": 12}, - "content": [ - { - "component": "VTextarea", - "props": { - "model": "download_dirs", - "label": "下载目录映射", - "rows": 2, - "placeholder": "填写要监控的源文件目录,并设置MP和QB的目录映射关系,如/mp/download:/qb/download,多个目录请换行", - }, - } - ], - } - ], - }, - { - "component": "VRow", - "props": {"style": {"margin-top": "0px"}}, - "content": [ - { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [ - { - "component": "VTextarea", - "props": { - "model": "exclude_keywords", - "label": "过滤删源文件关键字", - "rows": 2, - "placeholder": "多个关键字请换行,仅针对删除源文件", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [ - { - "component": "VTextarea", - "props": { - "model": "exclude_categories", - "label": "过滤删种分类", - "rows": 2, - "placeholder": "多个分类请换行,仅针对删除种子", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [ - { - "component": "VTextarea", - "props": { - "model": "exclude_labels", - "label": "过滤删种标签", - "rows": 2, - "placeholder": "多个标签请换行,仅针对删除种子", - }, - } - ], - }, - ], - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": {"cols": 12}, - "content": [ - { - "component": "VTextarea", - "props": { - "model": "custom_error_msg", - "label": "自定义无效做种tracker错误信息", - "rows": 5, - "placeholder": "填入想要清理的种子的tracker错误信息,如'skipping tracker announce (unreachable)',多个信息请换行", - }, - } - ], - } - ], - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": { - "cols": 12, - }, - "content": [ - { - "component": "VAlert", - "props": { - "type": "error", - "variant": "tonal", - "text": "谨慎起见删除种子/源文件功能做了开关,请确认无误后再开启删除功能", - }, - } - ], - }, - { - "component": "VCol", - "props": { - "cols": 12, - }, - "content": [ - { - "component": "VAlert", - "props": { - "type": "info", - "variant": "tonal", - "text": "下载目录映射填入源文件根目录,并设置MP和QB的目录映射关系。如某种子下载的源文件A存放路径为/qb/download/A,则目录映射填入/mp/download:/qb/download,多个目录请换行。注意映射目录不要有多余的'/'", - }, - } - ], - }, - ], - }, - ], - } - ], { - "enabled": False, - "notify": False, - "download_dirs": "", - "delete_invalid_torrents": False, - "delete_invalid_files": False, - "detect_invalid_files": False, - "notify_all": False, - "onlyonce": False, - "cron": "0 0 * * *", - "label_only": False, - "label": "", - "more_logs": False, - } - - def get_page(self) -> List[dict]: - pass - - def stop_service(self): - """ - 退出插件 - """ - try: - if self._scheduler: - self._scheduler.remove_all_jobs() - if self._scheduler.running: - self._scheduler.shutdown() - self._scheduler = None - except Exception as e: - logger.error("退出插件失败:%s" % str(e)) diff --git a/plugins.v2/clouddiskdel/__init__.py b/plugins.v2/clouddiskdel/__init__.py deleted file mode 100644 index 7769171..0000000 --- a/plugins.v2/clouddiskdel/__init__.py +++ /dev/null @@ -1,540 +0,0 @@ -import json -import os -import shutil -import time -from pathlib import Path - -from app.core.config import settings -from app.core.event import eventmanager, Event -from app.log import logger -from app.plugins import _PluginBase -from typing import Any, List, Dict, Tuple - -from app.schemas.types import EventType, MediaImageType, NotificationType, MediaType -from app.utils.system import SystemUtils - - -class CloudDiskDel(_PluginBase): - # 插件名称 - plugin_name = "云盘文件删除" - # 插件描述 - plugin_desc = "媒体库删除strm文件后同步删除云盘资源。" - # 插件图标 - plugin_icon = "clouddisk.png" - # 插件版本 - plugin_version = "1.3" - # 插件作者 - plugin_author = "thsrite" - # 作者主页 - author_url = "https://github.com/thsrite" - # 插件配置项ID前缀 - plugin_config_prefix = "clouddiskdel_" - # 加载顺序 - plugin_order = 26 - # 可使用的用户级别 - auth_level = 1 - - # 私有属性 - _enabled = False - # 任务执行间隔 - _paths = {} - _notify = False - _del_history = False - - _video_formats = ('.mp4', '.avi', '.rmvb', '.wmv', '.mov', '.mkv', '.flv', '.ts', '.webm', '.iso', '.mpg') - - def init_plugin(self, config: dict = None): - if config: - self._enabled = config.get("enabled") - self._notify = config.get("notify") - self._del_history = config.get("del_history") - for path in str(config.get("path")).split("\n"): - paths = path.split(":") - self._paths[paths[0]] = paths[1] - - # 清理插件历史 - if self._del_history: - self.del_data(key="history") - self.update_config({ - "enabled": self._enabled, - "notify": self._notify, - "path": config.get("path"), - "del_history": False - }) - - @eventmanager.register(EventType.PluginAction) - def clouddisk_del(self, event: Event): - if not self._enabled: - return - if not event: - return - - event_data = event.event_data - if not event_data or event_data.get("action") != "networkdisk_del": - return - - logger.info(f"获取到云盘删除请求 {event_data}") - - media_path = event_data.get("media_path") - if not media_path: - logger.error("未获取到删除路径") - return - - media_name = event_data.get("media_name") - tmdb_id = event_data.get("tmdb_id") - media_type = event_data.get("media_type") - season_num = event_data.get("season_num") - episode_num = event_data.get("episode_num") - - # 不是网盘监控路径,直接排除 - cloud_file_flag = False - - # 判断删除媒体路径是否与配置的媒体库路径相符,相符则继续删除,不符则跳过 - for library_path in list(self._paths.keys()): - if str(media_path).startswith(library_path): - cloud_file_flag = True - # 替换网盘路径 - media_path = str(media_path).replace(library_path, self._paths.get(library_path)) - logger.info(f"获取到moviepilot本地云盘挂载路径 {media_path}") - path = Path(media_path) - if path.is_file() or media_path.endswith(".strm"): - # 删除文件、nfo、jpg等同名文件 - pattern = path.stem.replace('[', '?').replace(']', '?') - logger.info(f"开始筛选同名文件 {pattern}") - files = path.parent.glob(f"{pattern}.*") - - remove_flag = False - for file in files: - Path(file).unlink() - logger.info(f"云盘文件 {file} 已删除") - self.__remove_json(file) - remove_flag = True - - if not remove_flag: - for ext in self._video_formats: - file = path.stem + ext - if Path(file).exists(): - Path(file).unlink() - logger.info(f"云盘文件 {file} 已删除") - self.__remove_json(file) - else: - # 非根目录,才删除目录 - shutil.rmtree(path) - # 删除目录 - logger.warn(f"云盘目录 {path} 已删除") - self.__remove_json(path) - - # 判断当前媒体父路径下是否有媒体文件,如有则无需遍历父级 - if not SystemUtils.exits_files(path.parent, settings.RMT_MEDIAEXT): - # 判断父目录是否为空, 为空则删除 - for parent_path in path.parents: - if str(parent_path.parent) != str(path.root): - # 父目录非根目录,才删除父目录 - if not SystemUtils.exits_files(parent_path, settings.RMT_MEDIAEXT): - # 当前路径下没有媒体文件则删除 - shutil.rmtree(parent_path) - logger.warn(f"云盘目录 {parent_path} 已删除") - self.__remove_json(parent_path) - break - - if cloud_file_flag: - # 发送消息 - image = 'https://emby.media/notificationicon.png' - media_type = MediaType.MOVIE if media_type in ["Movie", "MOV"] else MediaType.TV - if self._notify: - backrop_image = self.chain.obtain_specific_image( - mediaid=tmdb_id, - mtype=media_type, - image_type=MediaImageType.Backdrop, - season=season_num, - episode=episode_num - ) or image - - # 类型 - if media_type == MediaType.MOVIE: - msg = f'电影 {media_name} {tmdb_id}' - # 删除电视剧 - elif media_type == MediaType.TV and not season_num and not episode_num: - msg = f'剧集 {media_name} {tmdb_id}' - # 删除季 S02 - elif media_type == MediaType.TV and season_num and not episode_num: - msg = f'剧集 {media_name} S{season_num} {tmdb_id}' - # 删除剧集S02E02 - elif media_type == MediaType.TV and season_num and episode_num: - msg = f'剧集 {media_name} S{season_num}E{episode_num} {tmdb_id}' - else: - msg = media_name - - # 发送通知 - self.post_message( - mtype=NotificationType.MediaServer, - title="云盘同步删除任务完成", - image=backrop_image, - text=f"{msg}\n" - f"时间 {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}" - ) - - # 读取历史记录 - history = self.get_data('history') or [] - - # 获取poster - poster_image = self.chain.obtain_specific_image( - mediaid=tmdb_id, - mtype=media_type, - image_type=MediaImageType.Poster, - ) or image - history.append({ - "type": media_type.value, - "title": media_name, - "path": media_path, - "season": season_num if season_num and str(season_num).isdigit() else None, - "episode": episode_num if episode_num and str(episode_num).isdigit() else None, - "image": poster_image, - "del_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())) - }) - - # 保存历史 - self.save_data("history", history) - - def __remove_json(self, path): - """ - 删除json中的文件内容 - """ - try: - # 删除本地缓存文件 - cloud_files_json = os.path.join(settings.PLUGIN_DATA_PATH, "CloudStrm", "cloud_files.json") - if Path(cloud_files_json).exists(): - # 删除json文件中已删除部分文件 - # 尝试加载本地 - with open(cloud_files_json, 'r') as file: - content = file.read() - if content: - __cloud_files = json.loads(content) - if __cloud_files: - if not isinstance(__cloud_files, list): - __cloud_files = [__cloud_files] - if str(path) in __cloud_files: - # 删除已删除文件 - __cloud_files.remove(str(path)) - # 重新写入本地 - file = open(cloud_files_json, 'w') - file.write(json.dumps(__cloud_files)) - file.close() - else: - remove_flag = False - # 删除目录下文件 - for cloud_file in __cloud_files: - if str(cloud_file).startswith(str(path)): - __cloud_files.remove(cloud_file) - remove_flag = True - if remove_flag: - # 重新写入本地 - file = open(cloud_files_json, 'w') - file.write(json.dumps(__cloud_files)) - file.close() - except Exception as e: - print(str(e)) - - def get_state(self) -> bool: - return self._enabled - - @staticmethod - def get_command() -> List[Dict[str, Any]]: - """ - 定义远程控制命令 - :return: 命令关键字、事件、描述、附带数据 - """ - return [{ - "cmd": "/networkdisk_del", - "event": EventType.PluginAction, - "desc": "云盘文件删除", - "category": "", - "data": { - "action": "networkdisk_del" - } - }] - - def get_api(self) -> List[Dict[str, Any]]: - pass - - def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: - """ - 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 - """ - return [ - { - 'component': 'VForm', - 'content': [ - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'enabled', - 'label': '启用插件', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'notify', - 'label': '开启通知', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'del_history', - 'label': '删除历史', - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VTextarea', - 'props': { - 'model': 'path', - 'rows': '2', - 'label': '媒体库路径映射', - 'placeholder': '媒体服务器路径:moviepilot内云盘挂载路径(一行一个)' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '需要开启媒体库删除插件且正确配置排除路径。' - '主要针对于strm文件删除后同步删除云盘资源。' - '如遇删除失败,请检查文件权限问题。' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '关于路径映射:' - 'emby:/data/series/A.mp4,' - 'moviepilot内云盘挂载路径:/mnt/link/series/A.mp4。' - '路径映射填/data:/mnt/link' - } - } - ] - } - ] - }, - ] - } - ], { - "enabled": False, - "path": "", - "notify": False, - "del_history": False - } - - def get_page(self) -> List[dict]: - """ - 拼装插件详情页面,需要返回页面配置,同时附带数据 - """ - # 查询同步详情 - historys = self.get_data('history') - if not historys: - return [ - { - 'component': 'div', - 'text': '暂无数据', - 'props': { - 'class': 'text-center', - } - } - ] - # 数据按时间降序排序 - historys = sorted(historys, key=lambda x: x.get('del_time'), reverse=True) - # 拼装页面 - contents = [] - for history in historys: - htype = history.get("type") - title = history.get("title") - season = history.get("season") - episode = history.get("episode") - image = history.get("image") - del_time = history.get("del_time") - - if season: - sub_contents = [ - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'类型:{htype}' - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'标题:{title}' - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'季:{season}' - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'集:{episode}' - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'时间:{del_time}' - } - ] - else: - sub_contents = [ - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'类型:{htype}' - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'标题:{title}' - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'时间:{del_time}' - } - ] - - contents.append( - { - 'component': 'VCard', - 'content': [ - { - 'component': 'div', - 'props': { - 'class': 'd-flex justify-space-start flex-nowrap flex-row', - }, - 'content': [ - { - 'component': 'div', - 'content': [ - { - 'component': 'VImg', - 'props': { - 'src': image, - 'height': 120, - 'width': 80, - 'aspect-ratio': '2/3', - 'class': 'object-cover shadow ring-gray-500', - 'cover': True - } - } - ] - }, - { - 'component': 'div', - 'content': sub_contents - } - ] - } - ] - } - ) - - return [ - { - 'component': 'div', - 'props': { - 'class': 'grid gap-3 grid-info-card', - }, - 'content': contents - } - ] - - def stop_service(self): - """ - 退出插件 - """ - pass diff --git a/plugins.v2/crossseed/__init__.py b/plugins.v2/crossseed/__init__.py deleted file mode 100644 index 82fb138..0000000 --- a/plugins.v2/crossseed/__init__.py +++ /dev/null @@ -1,1232 +0,0 @@ -import hashlib -import os -import re -import time -from datetime import datetime, timedelta -from pathlib import Path -from threading import Event -from typing import Any, Dict, List, Optional, Tuple, Union - -import pytz -import requests -from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.triggers.cron import CronTrigger -from bencode import bdecode, bencode - -from app.core.config import settings -from app.core.event import eventmanager -from app.db.site_oper import SiteOper -from app.helper.sites import SitesHelper -from app.helper.torrent import TorrentHelper -from app.log import logger -from app.modules.qbittorrent import Qbittorrent -from app.modules.transmission import Transmission -from app.plugins import _PluginBase -from app.schemas import NotificationType -from app.schemas.types import EventType -from app.utils.string import StringUtils -from app.utils.timer import TimerUtils - - -class CSSiteConfig(object): - """ - 站点辅种配置类 - """ - - 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}nexusapi/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}" - - -class TorInfo: - - def __init__( - self, - site_name: str = None, - torrent_path: str = None, - file_path: str = None, - info_hash: str = None, - pieces_hash: str = None, - torrent_id: str = None, - ) -> None: - self.site_name = site_name - self.torrent_path = torrent_path - self.file_path = file_path - self.info_hash = info_hash - self.pieces_hash = pieces_hash - self.torrent_id = torrent_id - self.torrent_announce = None - - @staticmethod - def local(torrent_path: str, info_hash: str, pieces_hash: str): - - return TorInfo( - torrent_path=torrent_path, info_hash=info_hash, pieces_hash=pieces_hash - ) - - @staticmethod - def remote(site_name: str, pieces_hash: str, torrent_id: str): - return TorInfo( - site_name=site_name, pieces_hash=pieces_hash, torrent_id=torrent_id - ) - - @staticmethod - def from_data(data: bytes) -> Tuple[Optional[Any], Optional[str]]: - try: - torrent = bdecode(data) - info = torrent["info"] - pieces = info["pieces"] - info_hash = hashlib.sha1(bencode(info)).hexdigest() - pieces_hash = hashlib.sha1(pieces).hexdigest() - local_tor = TorInfo(info_hash=info_hash, pieces_hash=pieces_hash) - # 从种子中获取 announce, qb可能存在获取不到的情况,会存在于fastresume文件中 - if "announce" in torrent: - local_tor.torrent_announce = torrent["announce"] - return local_tor, None - except Exception as err: - return None, str(err) - - def get_name_id_tag(self): - return f"{self.site_name}:{self.torrent_id}" - - def get_name_pieces_tag(self): - return f"{self.site_name}:{self.pieces_hash}" - - -class CrossSeedHelper(object): - _version = "0.2.0" - - @staticmethod - def get_local_torrent_info(torrent_path: Path | str) -> Tuple[Optional[TorInfo], str]: - try: - if isinstance(torrent_path, Path): - torrent_data = torrent_path.read_bytes() - else: - with open(torrent_path, "rb") as f: - torrent_data = f.read() - local_tor, err = TorInfo.from_data(torrent_data) - if not local_tor: - return None, err - local_tor.torrent_path = str(torrent_path) - return local_tor, "" - except Exception as err: - return None, str(err) - - @staticmethod - def get_target_torrent( - site: CSSiteConfig, - pieces_hash_set: List[str] - ) -> Tuple[Optional[List[TorInfo]], Optional[str]]: - """ - 返回pieces_hash对应的种子信息,包括站点id,pieces_hash,种子id - """ - headers = { - "Content-Type": "application/json", - "Accept": "application/json", - "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, - proxies=settings.PROXY if site.proxy else None, - ) - 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}" - return remote_torrent_infos, None - - -class CrossSeed(_PluginBase): - # 插件名称 - plugin_name = "青蛙辅种助手" - # 插件描述 - plugin_desc = "参考ReseedPuppy和IYUU辅种插件实现自动辅种,支持站点:青蛙、AGSVPT、麒麟、UBits、聆音、憨憨等。" - # 插件图标 - plugin_icon = "qingwa.png" - # 插件版本 - plugin_version = "2.3" - # 插件作者 - plugin_author = "233@qingwa" - # 作者主页 - author_url = "https://qingwapt.com/" - # 插件配置项ID前缀 - plugin_config_prefix = "cross_seed_" - # 加载顺序 - plugin_order = 17 - # 可使用的用户级别 - auth_level = 2 - - # 私有属性 - _scheduler = None - cross_helper = None - qb = None - tr = None - sites = None - siteoper = None - torrent = None - # 开关 - _enabled = False - _cron = None - _onlyonce = False - _token = None - _downloaders = [] - _sites = [] - _torrentpath = None - _notify = False - _nolabels = None - _nopaths = None - _clearcache = False - # 退出事件 - _event = Event() - _torrent_tags = ["已整理", "辅种"] - # 待校全种子hash清单 - _recheck_torrents = {} - _is_recheck_running = False - # 辅种缓存,出错的种子不再重复辅种,可清除 - _error_caches = [] - # 辅种缓存,辅种成功的种子,可清除 - _success_caches = [] - # 辅种缓存,出错的种子不再重复辅种,且无法清除。种子被删除404等情况 - _permanent_error_caches = [] - _torrentpaths = [] - _site_cs_infos = [] - # 辅种计数 - total = 0 - realtotal = 0 - success = 0 - exist = 0 - fail = 0 - cached = 0 - - def init_plugin(self, config: dict = None): - self.sites = SitesHelper() - self.siteoper = SiteOper() - self.torrent = TorrentHelper() - # 读取配置 - if config: - self._enabled = config.get("enabled") - self._onlyonce = config.get("onlyonce") - self._cron = config.get("cron") - self._token = config.get("token") # passkey格式 青蛙:xxxxxx,站点名称:xxxxxxx - - self._downloaders = config.get("downloaders") - self._torrentpath = config.get("torrentpath") # 种子路径和下载器对应 /qb,/tr - self._torrentpaths = self._torrentpath.strip().split(",") - self._sites = config.get("sites") or [] - self._notify = config.get("notify") - self._nolabels = config.get("nolabels") - self._nopaths = config.get("nopaths") - self._clearcache = config.get("clearcache") - self._permanent_error_caches = [] if self._clearcache else config.get("permanent_error_caches") or [] - self._error_caches = [] if self._clearcache else config.get("error_caches") or [] - self._success_caches = [] if self._clearcache else config.get("success_caches") or [] - - # 过滤掉已删除的站点 - inner_site_list = self.siteoper.list_order_by_pri() - all_sites = [(site.id, site.name) for site in inner_site_list] + [ - (site.get("id"), site.get("name")) for site in self.__custom_sites() - ] - self._sites = [site_id for site_id, site_name in all_sites if site_id in self._sites] - - # 整理所有可用内部站点信息 - 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=True if site.proxy else False, - ) - 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(r"[\s::]+", site_key.strip()) - site_name = site_key_arr[0] - if len(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() - - # 停止现有任务 - self.stop_service() - - # 启动定时任务 & 立即运行一次 - if self.get_state() or self._onlyonce: - self.cross_helper = CrossSeedHelper() - self._scheduler = BackgroundScheduler(timezone=settings.TZ) - self.qb = Qbittorrent() - self.tr = Transmission() - - if self._onlyonce: - logger.info(f"辅种服务启动,立即运行一次") - self._scheduler.add_job(self.auto_seed, 'date', - run_date=datetime.now( - tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3) - ) - - # 关闭一次性开关 - self._onlyonce = False - if self._scheduler.get_jobs(): - # 追加种子校验服务 - self._scheduler.add_job(self.check_recheck, 'interval', minutes=3) - # 启动服务 - self._scheduler.print_jobs() - self._scheduler.start() - - if self._clearcache: - # 关闭清除缓存开关 - self._clearcache = False - - if self._clearcache or self._onlyonce: - # 保存配置 - self.__update_config() - - def get_state(self) -> bool: - return True if self._enabled and self._token and self._downloaders and self._torrentpath else False - - @staticmethod - def get_command() -> List[Dict[str, Any]]: - pass - - 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.get_state(): - # 如果开启了定时任务,并且参数齐全 - if self._cron: - return [{ - "id": "CrossSeed", - "name": "青蛙辅种助手", - "trigger": CronTrigger.from_crontab(self._cron), - "func": self.auto_seed, - "kwargs": {} - }] - else: - # 随机时间 - triggers = TimerUtils.random_scheduler(num_executions=1, - begin_hour=2, - end_hour=7, - max_interval=290, - min_interval=0) - ret_jobs = [] - for trigger in triggers: - ret_jobs.append({ - "id": f"CrossSeed|{trigger.hour}:{trigger.minute}", - "name": "青蛙辅种助手", - "trigger": "cron", - "func": self.auto_seed, - "kwargs": { - "hour": trigger.hour, - "minute": trigger.minute - } - }) - return ret_jobs - elif self._enabled: - logger.warn(f"青蛙辅种助手插件参数不全,定时任务未正常启动") - return [] - - def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: - """ - 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 - """ - # 站点的可选项(内置站点 + 自定义站点) - customSites = self.__custom_sites() - - # 站点的可选项 - site_options = ([{"title": site.name, "value": site.id} - for site in self.siteoper.list_order_by_pri()] - + [{"title": site.get("name"), "value": site.get("id")} - for site in customSites]) - # 测试版本,只支持青蛙 - # site_options = [s for s in site_options if s["title"]=="青蛙"] - - return [ - { - 'component': 'VForm', - 'content': [ - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'enabled', - 'label': '启用插件', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'notify', - 'label': '发送通知', - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12 - }, - 'content': [ - { - 'component': 'VSelect', - 'props': { - 'chips': True, - 'multiple': True, - 'model': 'sites', - 'label': '辅种站点', - 'items': site_options - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 12 - }, - 'content': [ - { - 'component': 'VTextarea', - 'props': { - 'model': 'token', - 'label': '站点Passkey', - 'rows': 3, - 'placeholder': '每行一个, 格式为 站点名称:Passkey ,站点名称为上面选择的名称,例如青蛙为 青蛙:xxxxxx 其中xxxxxx替换为你的Passkey' - } - } - ] - }, - - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VSelect', - 'props': { - 'chips': True, - 'multiple': True, - 'model': 'downloaders', - 'label': '辅种下载器', - 'items': [ - {'title': 'Qbittorrent', 'value': 'qbittorrent'}, - {'title': 'Transmission', 'value': 'transmission'} - ] - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'cron', - 'label': '执行周期', - 'placeholder': '0 0 0 ? *' - } - } - ] - }, - - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 12 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'torrentpath', - 'label': '种子文件目录', - 'placeholder': '多个目录逗号分隔,按下载器顺序对应填写,每个下载器只能有一个种子目录' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'nolabels', - 'label': '不辅种标签', - 'placeholder': '使用,分隔多个标签' - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12 - }, - 'content': [ - { - 'component': 'VTextarea', - 'props': { - 'model': 'nopaths', - 'label': '不辅种数据文件目录', - 'rows': 3, - 'placeholder': '每一行一个目录' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'onlyonce', - 'label': '立即运行一次', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'clearcache', - 'label': '清除缓存后运行', - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '1. 定时任务周期建议每次辅种间隔时间大于1天,不填写每天上午2点到7点随机辅种一次; ' - '2. 支持辅种站点列表:青蛙、AGSVPT、红豆饭、麒麟、UBits、聆音等,配置passkey时,站点名称需严格和上面选项一致,只有选中的站点会辅种,passkey可保存多个; ' - '3. 请勿与IYUU辅种插件同时添加相同站点,可能会有冲突,且意义不大;' - '4. 测试站点是否支持的方法:【站点域名/api/pieces-hash】接口访问返回405则大概率支持 ' - } - } - ] - } - ] - }, - { - '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' - } - } - ] - } - ] - } - ] - } - ], { - "enabled": False, - "onlyonce": False, - "notify": False, - "clearcache": False, - "cron": "", - "token": "", - "downloaders": [], - "torrentpath": "", - "sites": [], - "nopaths": "", - "nolabels": "" - } - - def get_page(self) -> List[dict]: - pass - - def __update_config(self): - self.update_config({ - "enabled": self._enabled, - "onlyonce": self._onlyonce, - "clearcache": self._clearcache, - "cron": self._cron, - "token": self._token, - "downloaders": self._downloaders, - "torrentpath": self._torrentpath, - "sites": self._sites, - "notify": self._notify, - "nolabels": self._nolabels, - "nopaths": self._nopaths, - "success_caches": self._success_caches, - "error_caches": self._error_caches, - "permanent_error_caches": self._permanent_error_caches - }) - - def __get_downloader(self, dtype: str): - """ - 根据类型返回下载器实例 - """ - if dtype == "qbittorrent": - return self.qb - elif dtype == "transmission": - return self.tr - else: - return None - - def auto_seed(self): - """ - 开始辅种 - """ - logger.info("开始辅种任务 ...") - - # 计数器初始化 - self.total = 0 - self.realtotal = 0 - self.success = 0 - self.exist = 0 - self.fail = 0 - self.cached = 0 - # 扫描下载器辅种 - for idx, downloader in enumerate(self._downloaders): - logger.info(f"开始扫描下载器 {downloader} ...") - downloader_obj = self.__get_downloader(downloader) - # 获取下载器中已完成的种子 - torrents = downloader_obj.get_completed_torrents() - if torrents: - logger.info(f"下载器 {downloader} 已完成种子数:{len(torrents)}") - else: - logger.info(f"下载器 {downloader} 没有已完成种子") - continue - hash_strs = [] - for torrent in torrents: - if self._event.is_set(): - logger.info(f"辅种服务停止") - return - # 获取种子hash - hash_str = self.__get_hash(torrent, downloader) - if hash_str in self._error_caches or hash_str in self._permanent_error_caches: - logger.info(f"种子 {hash_str} 辅种失败且已缓存,跳过 ...") - continue - save_path = self.__get_save_path(torrent, downloader) - # 获取种子文件路径 - torrent_path = Path(self._torrentpaths[idx]) / f"{hash_str}.torrent" - torrent_info = None - if not torrent_path.exists(): - if False and downloader == "qbittorrent": - # qb开启SQLite功能后将不再以hash命名的方式保存torrent文件 - # TODO 导出功能需要qb4.5.0以上版本才支持 - 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}") - continue - else: - logger.error(f"种子文件不存在:{torrent_path}") - continue - - # 读取种子文件具体信息 - if not torrent_info: - torrent_info, err = self.cross_helper.get_local_torrent_info(torrent_path) - if not torrent_info: - logger.error(f"未能读取到种子文件具体信息:{torrent_path} {err}") - continue - - # 用站点+pieces_hash记录该站点是否已经在该下载器中,需要从tracker补充站点名字 - tracker_urls = set() - try: - if downloader == "qbittorrent": - for i in torrent.trackers: - if "https" in i.get("url"): - tracker_urls.add(i.get("url")) - elif downloader == "transmission": - if torrent_info and torrent_info.torrent_announce: - if "https" in torrent_info.torrent_announce: - tracker_urls.add(torrent_info.torrent_announce) - except Exception as err: - logger.warn(f"尝试获取 {downloader} 的tracker出错 {err}") - # 根据tracker补充站点信息 - for tracker in tracker_urls: - # 优先通过passkey获取站点名 - for site_config in self._site_cs_infos: - if site_config.passkey in tracker: - torrent_info.site_name = site_config.name - break - if not torrent_info.site_name: - # 尝试通过域名获取站点信息 - tracker_domain = StringUtils.get_url_domain(tracker) - site_info = self.sites.get_indexer(tracker_domain) - if site_info: - torrent_info.site_name = site_info.get("name") - - if self._nopaths and save_path: - # 过滤不需要转移的路径 - nopath_skip = False - for nopath in self._nopaths.split('\n'): - if os.path.normpath(save_path).startswith(os.path.normpath(nopath)): - logger.info(f"种子 {hash_str} 保存路径 {save_path} 不需要辅种,跳过 ...") - nopath_skip = True - break - if nopath_skip: - continue - - # 获取种子标签 - torrent_labels = self.__get_label(torrent, downloader) - if torrent_labels and self._nolabels: - is_skip = False - for label in self._nolabels.split(','): - if label in torrent_labels: - logger.info(f"种子 {hash_str} 含有不辅种标签 {label},跳过 ...") - is_skip = True - break - if is_skip: - continue - hash_strs.append({ - "hash": hash_str, - "save_path": save_path, - "torrent_info": torrent_info - }) - if hash_strs: - self.__seed_torrents(hash_strs=hash_strs, downloader=downloader) - # 触发校验检查 - self.check_recheck() - else: - logger.info(f"没有需要辅种的种子") - # 保存缓存 - self.__update_config() - # 发送消息 - if self._notify: - if self.success or self.fail: - self.post_message( - mtype=NotificationType.SiteMessage, - title="【青蛙辅种助手辅种任务完成】", - text=f"服务器返回可辅种总数:{self.total}\n" - f"实际可辅种数:{self.realtotal}\n" - f"已存在:{self.exist}\n" - f"成功:{self.success}\n" - f"失败:{self.fail}\n" - f"{self.cached} 条失败记录已加入缓存" - ) - logger.info("辅种任务执行完成") - - def check_recheck(self): - """ - 定时检查下载器中种子是否校验完成,校验完成且完整的自动开始辅种 - """ - if not self._recheck_torrents: - return - if self._is_recheck_running: - return - self._is_recheck_running = True - for downloader in self._downloaders: - # 需要检查的种子 - recheck_torrents = self._recheck_torrents.get(downloader) or [] - if not recheck_torrents: - continue - logger.info(f"开始检查下载器 {downloader} 的校验任务 ...") - # 下载器 - downloader_obj = self.__get_downloader(downloader) - # 获取下载器中的种子状态 - torrents, _ = downloader_obj.get_torrents(ids=recheck_torrents) - if torrents: - can_seeding_torrents = [] - for torrent in torrents: - # 获取种子hash - hash_str = self.__get_hash(torrent, downloader) - if self.__can_seeding(torrent, downloader): - can_seeding_torrents.append(hash_str) - if can_seeding_torrents: - logger.info(f"共 {len(can_seeding_torrents)} 个任务校验完成,开始辅种 ...") - # 开始任务 - downloader_obj.start_torrents(ids=can_seeding_torrents) - # 去除已经处理过的种子 - self._recheck_torrents[downloader] = list( - set(recheck_torrents).difference(set(can_seeding_torrents))) - elif torrents is None: - logger.info(f"下载器 {downloader} 查询校验任务失败,将在下次继续查询 ...") - continue - else: - logger.info(f"下载器 {downloader} 中没有需要检查的校验任务,清空待处理列表 ...") - self._recheck_torrents[downloader] = [] - self._is_recheck_running = False - - def __seed_torrents(self, hash_strs: list, downloader: str): - """ - 执行所有种子的辅种 - """ - if not hash_strs: - return - logger.info(f"下载器 {downloader} 开始查询辅种,种子总数量:{len(hash_strs)} ...") - - # 每个Hash的保存目录 - save_paths = {} - pieces_hash_set = set() - site_pieces_hash_set = set() - for item in hash_strs: - tor_info: TorInfo = item.get("torrent_info") - save_paths[tor_info.pieces_hash] = item.get("save_path") - pieces_hash_set.add(tor_info.pieces_hash) - if tor_info.site_name: - site_pieces_hash_set.add(tor_info.get_name_pieces_tag()) - - logger.info(f"去重后,总共需要辅种查询的种子数:{len(pieces_hash_set)}") - pieces_hashes = list(pieces_hash_set) - - # 分站点逐个批次辅种 - # 逐个站点查询可辅种数据 - 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): - if self._event.is_set(): - logger.info(f"辅种服务停止") - return - # 切片操作 - chunk = pieces_hashes[i:i + chunk_size] - # 处理分组 - chunk_tors, err_msg = self.cross_helper.get_target_torrent(site_config, chunk) - if not chunk_tors and err_msg: - logger.info( - f"查询站点{site_config.name}可辅种的信息出错 {err_msg},进度={i + 1}/{total_size}" - ) - else: - logger.info( - f"站点{site_config.name}本批次的可辅种/查询数={len(chunk_tors)}/{len(chunk)},进度={i + 1}/{total_size}" - ) - remote_tors = remote_tors + chunk_tors - - logger.info(f"站点{site_config.name}返回可以辅种的种子总数为{len(remote_tors)}") - - # 去除已经下载过的种子 - local_cnt = 0 - not_local_tors = [] - for tor_info in remote_tors: - if ( - tor_info - and tor_info.site_name - and tor_info.pieces_hash - and tor_info.get_name_pieces_tag() in site_pieces_hash_set - ): - local_cnt = local_cnt + 1 - else: - not_local_tors.append(tor_info) - 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: - continue - if tor_info.get_name_id_tag() in self._success_caches: - logger.info(f"{tor_info.get_name_id_tag()} 已处理过辅种,跳过 ...") - continue - if tor_info.get_name_id_tag() in self._error_caches or tor_info.get_name_id_tag() in self._permanent_error_caches: - logger.info(f"种子 {tor_info.get_name_id_tag()} 辅种失败且已缓存,跳过 ...") - continue - # 添加任务 - self.__download_torrent(tor=tor_info, site_config=site_config, - downloader=downloader, - save_path=save_paths.get(tor_info.pieces_hash)) - - logger.info(f"下载器 {downloader} 辅种完成") - - def __download(self, downloader: str, content: Union[bytes, str], - save_path: str) -> Optional[str]: - """ - 添加下载任务 - """ - if downloader == "qbittorrent": - # 生成随机Tag - tag = StringUtils.generate_random_str(10) - state = self.qb.add_torrent(content=content, - download_dir=save_path, - is_paused=True, - tag=["已整理", "辅种", tag]) - if not state: - return None - else: - # 获取种子Hash - torrent_hash = self.qb.get_torrent_id_by_tag(tags=tag) - if not torrent_hash: - logger.error(f"{downloader} 下载任务添加成功,但获取任务信息失败!") - return None - return torrent_hash - elif downloader == "transmission": - # 添加任务 - torrent = self.tr.add_torrent(content=content, - download_dir=save_path, - is_paused=True, - labels=["已整理", "辅种"]) - if not torrent: - return None - else: - return torrent.hashString - - logger.error(f"不支持的下载器:{downloader}") - return None - - def __download_torrent( - self, - tor: TorInfo, - site_config: CSSiteConfig, - downloader: str, - save_path: str, - ): - """ - 下载种子 - - """ - self.total += 1 - self.realtotal += 1 - - # 下载种子 - torrent_url = site_config.get_torrent_url(tor.torrent_id) - - # 下载种子文件 - _, content, _, _, error_msg = self.torrent.download_torrent( - url=torrent_url, - 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): - # 下载失败 - self.fail += 1 - self.cached += 1 - # 加入失败缓存 - if error_msg and ('无法打开链接' in error_msg or '触发站点流控' in error_msg): - self._error_caches.append(tor.get_name_id_tag()) - else: - # 种子不存在的情况 - self._permanent_error_caches.append(tor.get_name_id_tag()) - logger.error(f"下载种子文件失败:{tor.get_name_id_tag()}") - return False - - # 添加任务前查询校验一次,避免重复添加,导致暂停的任务被重新开始 - tmp_tor_info, err_msg = TorInfo.from_data(content) - if tmp_tor_info and tmp_tor_info.info_hash: - tors, msg = self.__get_downloader(downloader).get_torrents(ids=[tmp_tor_info.info_hash]) - if tors: - self.exist += 1 - self._success_caches.append(tor.get_name_id_tag()) - logger.info(f"下载的种子{tor.get_name_id_tag()}已存在, 跳过") - return True - else: - logger.warn(f"获取下载种子的信息出错{err_msg},不能检查该种子是否已暂停") - - # 添加下载,辅种任务默认暂停 - logger.info(f"添加下载任务:{tor.get_name_id_tag()} ...") - download_id = self.__download(downloader=downloader, - content=content, - save_path=save_path) - if not download_id: - # 下载失败 - self.fail += 1 - self.cached += 1 - # 加入失败缓存 - self._error_caches.append(tor.get_name_id_tag()) - return False - else: - self.success += 1 - # 追加校验任务 - logger.info(f"添加校验检查任务:{download_id} ...") - if not self._recheck_torrents.get(downloader): - self._recheck_torrents[downloader] = [] - self._recheck_torrents[downloader].append(download_id) - # 下载成功 - logger.info(f"成功添加辅种下载,站点种子:{tor.get_name_id_tag()}") - # TR会自动校验 - if downloader == "qbittorrent": - # 开始校验种子 - self.__get_downloader(downloader).recheck_torrents(ids=[download_id]) - # 成功也加入缓存,有一些改了路径校验不通过的,手动删除后,下一次又会辅上 - self._success_caches.append(tor.get_name_id_tag()) - return True - - @staticmethod - def __get_hash(torrent: Any, dl_type: str): - """ - 获取种子hash - """ - try: - return torrent.get("hash") if dl_type == "qbittorrent" else torrent.hashString - except Exception as e: - print(str(e)) - return "" - - @staticmethod - def __get_label(torrent: Any, dl_type: str): - """ - 获取种子标签 - """ - try: - return [str(tag).strip() for tag in torrent.get("tags").split(',')] \ - if dl_type == "qbittorrent" else torrent.labels or [] - except Exception as e: - print(str(e)) - return [] - - @staticmethod - def __can_seeding(torrent: Any, dl_type: str): - """ - 判断种子是否可以做种并处于暂停状态 - """ - try: - return torrent.get("state") == "pausedUP" if dl_type == "qbittorrent" \ - else (torrent.status.stopped and torrent.percent_done == 1) - except Exception as e: - print(str(e)) - return False - - @staticmethod - def __get_save_path(torrent: Any, dl_type: str): - """ - 获取种子保存路径 - """ - try: - return torrent.get("save_path") if dl_type == "qbittorrent" else torrent.download_dir - except Exception as e: - print(str(e)) - return "" - - def stop_service(self): - """ - 退出插件 - """ - try: - if self._scheduler: - self._scheduler.remove_all_jobs() - if self._scheduler.running: - self._event.set() - self._scheduler.shutdown() - self._event.clear() - self._scheduler = None - except Exception as e: - print(str(e)) - - def __custom_sites(self) -> List[Any]: - custom_sites = [] - custom_sites_config = self.get_config("CustomSites") - if custom_sites_config and custom_sites_config.get("enabled"): - custom_sites = custom_sites_config.get("sites") - return custom_sites - - @eventmanager.register(EventType.SiteDeleted) - def site_deleted(self, event): - """ - 删除对应站点选中 - """ - site_id = event.event_data.get("site_id") - config = self.get_config() - if config: - sites = config.get("sites") - if sites: - if isinstance(sites, str): - sites = [sites] - - # 删除对应站点 - if site_id: - sites = [site for site in sites if int(site) != int(site_id)] - else: - # 清空 - sites = [] - - # 若无站点,则停止 - if len(sites) == 0: - self._enabled = False - - self._sites = sites - # 保存配置 - self.__update_config() diff --git a/plugins.v2/diagparamadjust/__init__.py b/plugins.v2/diagparamadjust/__init__.py deleted file mode 100644 index 791504f..0000000 --- a/plugins.v2/diagparamadjust/__init__.py +++ /dev/null @@ -1,456 +0,0 @@ -import json -import re -from datetime import datetime, timedelta - -from app.modules.emby import Emby -from app.core.config import settings -from app.plugins import _PluginBase -from app.log import logger -from typing import List, Tuple, Dict, Any, Optional -import pytz -from app.schemas import WebhookEventInfo -from app.schemas.types import EventType -from app.core.event import eventmanager, Event - -from apscheduler.triggers.cron import CronTrigger -from apscheduler.schedulers.background import BackgroundScheduler - - -class DiagParamAdjust(_PluginBase): - # 插件名称 - plugin_name = "诊断参数调整" - # 插件描述 - plugin_desc = "Emby专用插件|暂时性解决emby字幕偏移问题,需要emby安装Diagnostics插件。" - # 插件图标 - plugin_icon = "Gatus_A.png" - # 插件版本 - plugin_version = "1.3" - # 插件作者 - plugin_author = "jeblove" - # 作者主页 - author_url = "https://github.com/jeblove" - # 插件配置项ID前缀 - plugin_config_prefix = "dpa_" - # 加载顺序 - plugin_order = 14 - # 可使用的用户级别 - auth_level = 1 - - # 私有属性 - _enabled: bool = False - # 修正字幕偏移用途(播放时执行) - _offset_play = True - _onlyonce = False - _base_url = None - _endpoint = None - _api_key = None - _search_text = None - _replace_text = None - _cron = None - _cron_switch = False - - # 请求接口 - _url = "[HOST]emby/EncodingDiagnostics/DiagnosticOptions?api_key=[APIKEY]" - # 定时器 - _scheduler: Optional[BackgroundScheduler] = None - - # 目标消息 - _webhook_actions = { - "playback.start": "开始播放", - } - - # 分辨率标识 - _resolution = None - # 分辨率改动 - _last_resolution = None - # 目标参数 - _target_search_text = None - _target_replace_text = None - - def init_plugin(self, config: dict = None): - # 停止现有任务 - self.stop_service() - - if config: - self._enabled = config.get("enabled") - self._offset_play = config.get("offset_play") - self._onlyonce = config.get("onlyonce") - self._search_text = config.get("search") - self._replace_text = config.get("replace") - self._cron = config.get("cron") - self._cron_switch = config.get("cron_switch") - - if self._onlyonce: - self._scheduler = BackgroundScheduler(timezone=settings.TZ) - logger.info(f"诊断参数调整服务启动,立刻运行一次") - self._scheduler.add_job(func=self.run, trigger='date', - run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), - name="诊断参数调整") - - # 关闭一次性开关 - self._onlyonce = False - self.update_config({ - "enabled": self._enabled, - "offset_play": self._offset_play, - "onlyonce": False, - "search": self._search_text, - "replace": self._replace_text, - "cron": self._cron, - "cron_switch": self._cron_switch, - }) - - # 启动任务 - if self._scheduler.get_jobs(): - self._scheduler.print_jobs() - self._scheduler.start() - - def get_state(self) -> bool: - return self._enabled - - @staticmethod - def get_command() -> List[Dict[str, Any]]: - pass - - 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 and self._cron and self._cron_switch: - return [{ - "id": "DiagParamAdjust", - "name": "诊断参数调整定时服务", - "trigger": CronTrigger.from_crontab(self._cron), - "func": self.run, - "kwargs": {} - }] - - def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: - """ - 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 - """ - return [ - { - 'component': 'VForm', - 'content': [ - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'enabled', - 'label': '启用插件', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'offset_play', - 'label': '修正字幕偏移(播放时执行)', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'onlyonce', - 'label': '立即运行一次', - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'search', - 'label': '搜索文本' - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'replace', - 'label': '替换文本' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'cron', - 'label': '检测执行周期', - 'placeholder': '*/5 * * * *' - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 3 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'cron_switch', - 'label': '周期模式', - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '- 暂时性解决emby字幕偏移问题,如默认参数不合适请在基础上修改【替换文本】x、y至适合(4K视频情况下!),如[x=W/4:y=h/5]。\n - 【修正字幕偏移(播放时执行)】需要emby配置webhooks消息通知:勾选[播放-开始](具体可参考【媒体库服务器通知】插件)', - 'style': 'white-space: pre-line;' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '- 播放视频分辨率与上次视频分辨率不一致时,在通知延迟和已加载旧位置字幕影响下,需要片刻后才会加载到新位置字幕,或关闭视频再次打开(建议)。\n - 此替换文本参数应用于emby-Diagnostics-Parameter Adjustment。\n - 默认参数用于修改ffmpeg中字幕覆盖在视频上的位置。\n - 方案来源于https://opve.cn/archives/983.html', - 'style': 'white-space: pre-line;' - } - } - ] - } - ] - } - ] - } - ], { - "enabled": False, - "offset_play": True, - "onlyonce": False, - "search": "x=(W-w)/2:y=(H-h):repeatlast=0", - "replace": "x=W/4:y=h/4:repeatlast=0", - "cron": "*/5 * * * *", - "cron_switch": False, - } - - def detect(self): - """ - 检测是否存在目标参数(修正字幕偏移用途) - - :return True: 存在; False: 不存在 - """ - logger.info('字幕偏移修正,检测目标参数') - try: - res = Emby().get_data(self._url) - result = res.json() - data = result['Object']['CommandLineOptions'] - searchText = data['SearchText'] - replaceText = data['ReplaceText'] - except json.JSONDecodeError: - logger.error('服务停止,Emby请安装【Diagnostics】插件') - return None - except KeyError: - # 已装插件,未设置过该参数 - # logger.info('目标参数为空') - return False - - # 符合所有情况 - if (('repeatlast' in replaceText - and 'x=(W-w)/2:y=(H-h):repeatlast=0' in searchText - and result['Object']['TranscodingOptions']['DisableHardwareSubtitleOverlay'] is True) - or (searchText == "" and replaceText == "")) \ - and self._resolution == self._last_resolution: - # (A or B) and C - return True - - return False - - def set_options(self): - """ - 向Emby发送请求设置参数 - """ - - # 根据分辨率情况而选择是否替换 - if self._resolution == 0 and self._offset_play is True: - # 1080p,不替换(清空文本) - self._target_search_text = "" - self._target_replace_text = "" - logger.info('清空替换参数') - else: - # >1080p or 非字幕偏移用途 - self._target_search_text = self._search_text - self._target_replace_text = self._replace_text - logger.info("替换值为:{}".format(self._target_replace_text)) - - data = { - "CommandLineOptions": { - "SearchText": self._target_search_text, - "ReplaceText": self._target_replace_text - }, - "TranscodingOptions": { - "DisableHardwareSubtitleOverlay": True - } - } - data = json.dumps(data) - headers = { - 'Content-Type': 'application/octet-stream' - } - res = Emby().post_data(self._url, data, headers) - if res.status_code // 100 == 2: - logger.info('参数设置成功') - return True - else: - logger.error('参数设置失败 {}'.format(res.status_code)) - return False - - @eventmanager.register(EventType.WebhookMessage) - def get_msg(self, event: Event): - # 消息方式开关 - if not self._enabled or not self._offset_play: - return - - # 消息获取 - event_info: WebhookEventInfo = event.event_data - if not event_info: - return - - # 非目标消息 - if not self._webhook_actions.get(event_info.event): - return - - # 根据视频名获得分辨率信息 - item_path = event_info.item_path - video_resolution = re.findall(r"\d{3,4}p", item_path) - video_width = int(video_resolution[0][:-1]) - logger.info('视频分辨率:{}'.format(video_width)) - - self._last_resolution = self._resolution - # 分辨率变化情况 - if video_width > 1080: - # 2160p/4k - self._resolution = 1 - else: - self._resolution = 0 - self.run() - - def run(self): - # 字幕偏移修正,则带检测 - if self._offset_play: - state = self.detect() - if state: - logger.info('参数正常,无需修正') - return True - elif state is None: - logger.info('插件退出') - return None - - self.set_options() - - def get_page(self) -> List[dict]: - pass - - def stop_service(self): - """ - 退出插件 - """ - try: - if self._scheduler: - self._scheduler.remove_all_jobs() - if self._scheduler.running: - self._scheduler.shutdown() - self._scheduler = None - except Exception as e: - logger.error("退出插件失败:%s" % str(e)) diff --git a/plugins.v2/episodegroupmeta/__init__.py b/plugins.v2/episodegroupmeta/__init__.py deleted file mode 100644 index 7a3be26..0000000 --- a/plugins.v2/episodegroupmeta/__init__.py +++ /dev/null @@ -1,872 +0,0 @@ -import base64 -import json -import threading -import time -from pathlib import Path -from typing import Any, List, Dict, Tuple, Optional, Union - -from pydantic import BaseModel -from requests import RequestException - -from app import schemas -from app.chain.mediaserver import MediaServerChain -from app.core.config import settings -from app.core.event import eventmanager, Event -from app.core.meta import MetaBase -from app.log import logger -from app.modules.emby import Emby -from app.modules.jellyfin import Jellyfin -from app.modules.plex import Plex -from app.modules.themoviedb.tmdbv3api import TV -from app.plugins import _PluginBase -from app.schemas.types import EventType -from app.utils.common import retry -from app.utils.http import RequestUtils - - -class ExistMediaInfo(BaseModel): - # 类型 电影、电视剧 - type: Optional[schemas.MediaType] - # 季, 集 - groupep: Optional[Dict[int, list]] = {} - # 集在媒体服务器的ID - groupid: Optional[Dict[int, List[list]]] = {} - # 媒体服务器 - server: Optional[str] = None - # 媒体ID - itemid: Optional[Union[str, int]] = None - - -class EpisodeGroupMeta(_PluginBase): - # 插件名称 - plugin_name = "TMDB剧集组刮削" - # 插件描述 - plugin_desc = "从TMDB剧集组刮削季集的实际顺序。" - # 插件图标 - plugin_icon = "Element_A.png" - # 主题色 - plugin_color = "#098663" - # 插件版本 - plugin_version = "1.1" - # 插件作者 - plugin_author = "叮叮当" - # 作者主页 - author_url = "https://github.com/cikezhu" - # 插件配置项ID前缀 - plugin_config_prefix = "EpisodeGroupMeta_" - # 加载顺序 - plugin_order = 29 - # 可使用的用户级别 - auth_level = 1 - - # 退出事件 - _event = threading.Event() - - # 私有属性 - mschain = None - tv = None - emby = None - plex = None - jellyfin = None - - _enabled = False - _ignorelock = False - _delay = 0 - _allowlist = [] - - def init_plugin(self, config: dict = None): - self.mschain = MediaServerChain() - self.tv = TV() - self.emby = Emby() - self.plex = Plex() - self.jellyfin = Jellyfin() - if config: - self._enabled = config.get("enabled") - self._ignorelock = config.get("ignorelock") - self._delay = config.get("delay") or 120 - self._allowlist = [] - for s in str(config.get("allowlist", "")).split(","): - s = s.strip() - if s and s not in self._allowlist: - self._allowlist.append(s) - self.log_info(f"白名单数量: {len(self._allowlist)} > {self._allowlist}") - - def get_state(self) -> bool: - return self._enabled - - @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、数据结构 - """ - return [ - { - 'component': 'VForm', - 'content': [ - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'enabled', - 'label': '启用插件', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'ignorelock', - 'label': '媒体信息锁定时也进行刮削', - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'delay', - 'label': '入库延迟时间(秒)', - 'placeholder': '120' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12 - }, - 'content': [ - { - 'component': 'VTextarea', - 'props': { - 'model': 'allowlist', - 'label': '刮削白名单', - 'rows': 6, - 'placeholder': '使用,分隔电视剧名称' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '注意:刮削白名单(留空), 则全部刮削. 否则仅刮削白名单.' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '注意:如需刮削已经入库的项目, 可通过mp重新整理单集即可.' - } - } - ] - } - ] - } - ] - } - ], { - "enabled": False, - "ignorelock": False, - "allowlist": "", - "delay": 120 - } - - def get_page(self) -> List[dict]: - pass - - @eventmanager.register(EventType.TransferComplete) - def scrap_rt(self, event: Event): - """ - 根据事件实时刮削剧集组信息 - """ - if not self.get_state(): - return - # 事件数据 - mediainfo: schemas.MediaInfo = event.event_data.get("mediainfo") - meta: MetaBase = event.event_data.get("meta") - # self.log_error(f"{event.event_data}") - if not mediainfo or not meta: - return - # 非TV类型不处理 - if mediainfo.type != schemas.MediaType.TV: - self.log_warn(f"{mediainfo.title} 非TV类型, 无需处理") - return - # 没有tmdbID不处理 - if not mediainfo.tmdb_id: - self.log_warn(f"{mediainfo.title} 没有tmdbID, 无需处理") - return - if len(self._allowlist) != 0 \ - and mediainfo.title not in self._allowlist: - self.log_warn(f"{mediainfo.title} 不在白名单, 无需处理") - return - # 获取剧集组信息 - try: - episode_groups = self.tv.episode_groups(mediainfo.tmdb_id) - if not episode_groups: - self.log_warn(f"{mediainfo.title} 没有剧集组, 无需处理") - return - self.log_info(f"{mediainfo.title_year} 剧集组数量: {len(episode_groups)} - {episode_groups}") - # episodegroup = self.tv.group_episodes(episode_groups[0].get('id')) - except Exception as e: - self.log_error(f"{mediainfo.title} {str(e)}") - return - # 延迟 - if self._delay: - self.log_warn(f"{mediainfo.title} 将在 {self._delay} 秒后开始处理..") - time.sleep(int(self._delay)) - # 获取可用的媒体服务器 - _existsinfo = self.chain.media_exists(mediainfo=mediainfo) - existsinfo: ExistMediaInfo = self.__media_exists(server=_existsinfo.server, mediainfo=mediainfo, - existsinfo=_existsinfo) - if not existsinfo or not existsinfo.itemid: - self.log_warn(f"{mediainfo.title_year} 在媒体库中不存在") - return - # 新增需要的属性 - existsinfo.server = _existsinfo.server - existsinfo.type = _existsinfo.type - self.log_info(f"{mediainfo.title_year} 存在于媒体服务器: {_existsinfo.server}") - # 获取全部剧集组信息 - copy_keys = ['Id', 'Name', 'ChannelNumber', 'OriginalTitle', 'ForcedSortName', 'SortName', 'CommunityRating', - 'CriticRating', 'IndexNumber', 'ParentIndexNumber', 'SortParentIndexNumber', 'SortIndexNumber', - 'DisplayOrder', 'Album', 'AlbumArtists', 'ArtistItems', 'Overview', 'Status', 'Genres', 'Tags', - 'TagItems', 'Studios', 'PremiereDate', 'DateCreated', 'ProductionYear', 'Video3DFormat', - 'OfficialRating', 'CustomRating', 'People', 'LockData', 'LockedFields', 'ProviderIds', - 'PreferredMetadataLanguage', 'PreferredMetadataCountryCode', 'Taglines'] - for episode_group in episode_groups: - if not bool(existsinfo.groupep): - break - try: - id = episode_group.get('id') - name = episode_group.get('name') - if not id: - continue - # 处理 - self.log_info(f"正在匹配剧集组: {id}") - groups_meta = self.tv.group_episodes(id) - if not groups_meta: - continue - for groups in groups_meta: - if not bool(existsinfo.groupep): - break - # 剧集组中的季 - order = groups.get("order") - # 剧集组中的集列表 - episodes = groups.get("episodes") - if not order or not episodes or len(episodes) == 0: - continue - # 进行集数匹配, 确定剧集组信息 - ep = existsinfo.groupep.get(order) - if not ep or len(ep) != len(episodes): - continue - self.log_info(f"已匹配剧集组: {name}, {id}, 第 {order} 季") - # 遍历全部媒体项并更新 - for _index, _ids in enumerate(existsinfo.groupid.get(order)): - # 提取出媒体库中集id对应的集数index - ep_num = ep[_index] - for _id in _ids: - # 获取媒体服务器媒体项 - iteminfo = self.get_iteminfo(server=existsinfo.server, itemid=_id) - if not iteminfo: - self.log_info(f"未找到媒体项 - itemid: {_id}, 第 {order} 季, 第 {ep_num} 集") - continue - # 是否无视项目锁定 - if not self._ignorelock: - if iteminfo.get("LockData") or ( - "Name" in iteminfo.get("LockedFields", []) - and "Overview" in iteminfo.get("LockedFields", [])): - self.log_warn(f"已锁定媒体项 - itemid: {_id}, 第 {order} 季, 第 {ep_num} 集") - continue - # 替换项目数据 - episode = episodes[ep_num - 1] - new_dict = {} - new_dict.update({k: v for k, v in iteminfo.items() if k in copy_keys}) - new_dict["Name"] = episode["name"] - new_dict["Overview"] = episode["overview"] - new_dict["ParentIndexNumber"] = str(order) - new_dict["IndexNumber"] = str(ep_num) - new_dict["LockData"] = True - if episode.get("vote_average"): - new_dict["CommunityRating"] = episode.get("vote_average") - if not new_dict["LockedFields"]: - new_dict["LockedFields"] = [] - self.__append_to_list(new_dict["LockedFields"], "Name") - self.__append_to_list(new_dict["LockedFields"], "Overview") - # 更新数据 - self.set_iteminfo(server=existsinfo.server, itemid=_id, iteminfo=new_dict) - # still_path 图片 - if episode.get("still_path"): - self.set_item_image(server=existsinfo.server, itemid=_id, - imageurl=f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{episode['still_path']}") - self.log_info(f"已修改剧集 - itemid: {_id}, 第 {order} 季, 第 {ep_num} 集") - # 移除已经处理成功的季 - existsinfo.groupep.pop(order, 0) - existsinfo.groupid.pop(order, 0) - continue - except Exception as e: - self.log_warn(f"错误忽略: {str(e)}") - continue - - self.log_info(f"{mediainfo.title_year} 已经运行完毕了..") - - @staticmethod - def __append_to_list(list, item): - if item not in list: - list.append(item) - - def __media_exists(self, server: str, mediainfo: schemas.MediaInfo, - existsinfo: schemas.ExistMediaInfo) -> ExistMediaInfo: - """ - 根据媒体信息,返回剧集列表与剧集ID列表 - :param mediainfo: 媒体信息 - :return: 剧集列表与剧集ID列表 - """ - - def __emby_media_exists(): - # 获取系列id - item_id = None - try: - res = self.emby.get_data(("[HOST]emby/Items?" - "IncludeItemTypes=Series" - "&Fields=ProductionYear" - "&StartIndex=0" - "&Recursive=true" - "&SearchTerm=%s" - "&Limit=10" - "&IncludeSearchTypes=false" - "&api_key=[APIKEY]") % mediainfo.title) - res_items = res.json().get("Items") - if res_items: - for res_item in res_items: - if res_item.get('Name') == mediainfo.title and ( - not mediainfo.year or str(res_item.get('ProductionYear')) == str(mediainfo.year)): - item_id = res_item.get('Id') - except Exception as e: - self.log_error(f"连接Items出错:" + str(e)) - if not item_id: - return None - # 验证tmdbid是否相同 - item_info = self.emby.get_iteminfo(item_id) - if item_info: - if mediainfo.tmdb_id and item_info.tmdbid: - if str(mediainfo.tmdb_id) != str(item_info.tmdbid): - self.log_error(f"tmdbid不匹配或不存在") - return None - try: - res_json = self.emby.get_data( - "[HOST]emby/Shows/%s/Episodes?Season=&IsMissing=false&api_key=[APIKEY]" % item_id) - if res_json: - tv_item = res_json.json() - res_items = tv_item.get("Items") - group_ep = {} - group_id = {} - for res_item in res_items: - season_index = res_item.get("ParentIndexNumber") - if not season_index: - continue - episode_index = res_item.get("IndexNumber") - if not episode_index: - continue - if season_index not in group_ep: - group_ep[season_index] = [] - group_id[season_index] = [] - if episode_index not in group_ep[season_index]: - group_ep[season_index].append(episode_index) - group_id[season_index].append([]) - # 找到准确的插入索引 - _index = group_ep[season_index].index(episode_index) - if res_item.get("Id") not in group_id[season_index][_index]: - group_id[season_index][_index].append(res_item.get("Id")) - # 返回 - return ExistMediaInfo( - itemid=item_id, - groupep=group_ep, - groupid=group_id - ) - except Exception as e: - self.log_error(f"连接Shows/Id/Episodes出错:{str(e)}") - return None - - def __jellyfin_media_exists(): - # 获取系列id - item_id = None - try: - res = self.jellyfin.get_data(url=f"[HOST]Users/[USER]/Items?api_key=[APIKEY]" - f"&searchTerm={mediainfo.title}" - f"&IncludeItemTypes=Series" - f"&Limit=10&Recursive=true") - res_items = res.json().get("Items") - if res_items: - for res_item in res_items: - if res_item.get('Name') == mediainfo.title and ( - not mediainfo.year or str(res_item.get('ProductionYear')) == str(mediainfo.year)): - item_id = res_item.get('Id') - except Exception as e: - self.log_error(f"连接Items出错:" + str(e)) - if not item_id: - return None - # 验证tmdbid是否相同 - item_info = self.jellyfin.get_iteminfo(item_id) - if item_info: - if mediainfo.tmdb_id and item_info.tmdbid: - if str(mediainfo.tmdb_id) != str(item_info.tmdbid): - self.log_error(f"tmdbid不匹配或不存在") - return None - try: - res_json = self.jellyfin.get_data( - "[HOST]emby/Shows/%s/Episodes?Season=&IsMissing=false&api_key=[APIKEY]" % item_id) - if res_json: - tv_item = res_json.json() - res_items = tv_item.get("Items") - group_ep = {} - group_id = {} - for res_item in res_items: - season_index = res_item.get("ParentIndexNumber") - if not season_index: - continue - episode_index = res_item.get("IndexNumber") - if not episode_index: - continue - if season_index not in group_ep: - group_ep[season_index] = [] - group_id[season_index] = [] - if episode_index not in group_ep[season_index]: - group_ep[season_index].append(episode_index) - group_id[season_index].append([]) - # 找到准确的插入索引 - _index = group_ep[season_index].index(episode_index) - if res_item.get("Id") not in group_id[season_index][_index]: - group_id[season_index][_index].append(res_item.get("Id")) - # 返回 - return ExistMediaInfo( - itemid=item_id, - groupep=group_ep, - groupid=group_id - ) - except Exception as e: - self.log_error(f"连接Shows/Id/Episodes出错:{str(e)}") - return None - - def __plex_media_exists(): - try: - _plex = self.plex.get_plex() - if not _plex: - return None - if existsinfo.itemid: - videos = _plex.fetchItem(existsinfo.itemid) - else: - # 根据标题和年份模糊搜索,该结果不够准确 - videos = _plex.library.search(title=mediainfo.title, - year=mediainfo.year, - libtype="show") - if (not videos - and mediainfo.original_title - and str(mediainfo.original_title) != str(mediainfo.title)): - videos = _plex.library.search(title=mediainfo.original_title, - year=mediainfo.year, - libtype="show") - if not videos: - return None - if isinstance(videos, list): - videos = videos[0] - video_tmdbid = __get_ids(videos.guids).get('tmdb_id') - if mediainfo.tmdb_id and video_tmdbid: - if str(video_tmdbid) != str(mediainfo.tmdb_id): - self.log_error(f"tmdbid不匹配或不存在") - return None - episodes = videos.episodes() - group_ep = {} - group_id = {} - for episode in episodes: - season_index = episode.seasonNumber - if not season_index: - continue - episode_index = episode.index - if not episode_index: - continue - episode_id = episode.key - if not episode_id: - continue - if season_index not in group_ep: - group_ep[season_index] = [] - group_id[season_index] = [] - if episode_index not in group_ep[season_index]: - group_ep[season_index].append(episode_index) - group_id[season_index].append([]) - # 找到准确的插入索引 - _index = group_ep[season_index].index(episode_index) - if episode_id not in group_id[season_index][_index]: - group_id[season_index][_index].append(episode_id) - # 返回 - return ExistMediaInfo( - itemid=videos.key, - groupep=group_ep, - groupid=group_id - ) - except Exception as e: - self.log_error(f"连接Shows/Id/Episodes出错:{str(e)}") - return None - - def __get_ids(guids: List[Any]) -> dict: - guid_mapping = { - "imdb://": "imdb_id", - "tmdb://": "tmdb_id", - "tvdb://": "tvdb_id" - } - ids = {} - for prefix, varname in guid_mapping.items(): - ids[varname] = None - for guid in guids: - for prefix, varname in guid_mapping.items(): - if isinstance(guid, dict): - if guid['id'].startswith(prefix): - # 找到匹配的ID - ids[varname] = guid['id'][len(prefix):] - break - else: - if guid.id.startswith(prefix): - # 找到匹配的ID - ids[varname] = guid.id[len(prefix):] - break - return ids - - if server == "emby": - return __emby_media_exists() - elif server == "jellyfin": - return __jellyfin_media_exists() - else: - return __plex_media_exists() - - def get_iteminfo(self, server: str, itemid: str) -> dict: - """ - 获得媒体项详情 - """ - - def __get_emby_iteminfo() -> dict: - """ - 获得Emby媒体项详情 - """ - try: - url = f'[HOST]emby/Users/[USER]/Items/{itemid}?' \ - f'Fields=ChannelMappingInfo&api_key=[APIKEY]' - res = self.emby.get_data(url=url) - if res: - return res.json() - except Exception as err: - self.log_error(f"获取Emby媒体项详情失败:{str(err)}") - return {} - - def __get_jellyfin_iteminfo() -> dict: - """ - 获得Jellyfin媒体项详情 - """ - try: - url = f'[HOST]Users/[USER]/Items/{itemid}?Fields=ChannelMappingInfo&api_key=[APIKEY]' - res = self.jellyfin.get_data(url=url) - if res: - result = res.json() - if result: - result['FileName'] = Path(result['Path']).name - return result - except Exception as err: - self.log_error(f"获取Jellyfin媒体项详情失败:{str(err)}") - return {} - - def __get_plex_iteminfo() -> dict: - """ - 获得Plex媒体项详情 - """ - iteminfo = {} - try: - plexitem = self.plex.get_plex().library.fetchItem(ekey=itemid) - if 'movie' in plexitem.METADATA_TYPE: - iteminfo['Type'] = 'Movie' - iteminfo['IsFolder'] = False - elif 'episode' in plexitem.METADATA_TYPE: - iteminfo['Type'] = 'Series' - iteminfo['IsFolder'] = False - if 'show' in plexitem.TYPE: - iteminfo['ChildCount'] = plexitem.childCount - iteminfo['Name'] = plexitem.title - iteminfo['Id'] = plexitem.key - iteminfo['ProductionYear'] = plexitem.year - iteminfo['ProviderIds'] = {} - for guid in plexitem.guids: - idlist = str(guid.id).split(sep='://') - if len(idlist) < 2: - continue - iteminfo['ProviderIds'][idlist[0]] = idlist[1] - for location in plexitem.locations: - iteminfo['Path'] = location - iteminfo['FileName'] = Path(location).name - iteminfo['Overview'] = plexitem.summary - iteminfo['CommunityRating'] = plexitem.audienceRating - # 增加锁定属性列表 - iteminfo['LockedFields'] = [] - try: - if plexitem.title.locked: - iteminfo['LockedFields'].append('Name') - except Exception as err: - logger.warn(f"获取Plex媒体项详情失败:{str(err)}") - pass - try: - if plexitem.summary.locked: - iteminfo['LockedFields'].append('Overview') - except Exception as err: - logger.warn(f"获取Plex媒体项详情失败:{str(err)}") - pass - return iteminfo - except Exception as err: - self.log_error(f"获取Plex媒体项详情失败:{str(err)}") - return {} - - if server == "emby": - return __get_emby_iteminfo() - elif server == "jellyfin": - return __get_jellyfin_iteminfo() - else: - return __get_plex_iteminfo() - - def set_iteminfo(self, server: str, itemid: str, iteminfo: dict): - """ - 更新媒体项详情 - """ - - def __set_emby_iteminfo(): - """ - 更新Emby媒体项详情 - """ - try: - res = self.emby.post_data( - url=f'[HOST]emby/Items/{itemid}?api_key=[APIKEY]&reqformat=json', - data=json.dumps(iteminfo), - headers={ - "Content-Type": "application/json" - } - ) - if res and res.status_code in [200, 204]: - return True - else: - self.log_error(f"更新Emby媒体项详情失败,错误码:{res.status_code}") - return False - except Exception as err: - self.log_error(f"更新Emby媒体项详情失败:{str(err)}") - return False - - def __set_jellyfin_iteminfo(): - """ - 更新Jellyfin媒体项详情 - """ - try: - res = self.jellyfin.post_data( - url=f'[HOST]Items/{itemid}?api_key=[APIKEY]', - data=json.dumps(iteminfo), - headers={ - "Content-Type": "application/json" - } - ) - if res and res.status_code in [200, 204]: - return True - else: - self.log_error(f"更新Jellyfin媒体项详情失败,错误码:{res.status_code}") - return False - except Exception as err: - self.log_error(f"更新Jellyfin媒体项详情失败:{str(err)}") - return False - - def __set_plex_iteminfo(): - """ - 更新Plex媒体项详情 - """ - try: - plexitem = self.plex.get_plex().library.fetchItem(ekey=itemid) - if 'CommunityRating' in iteminfo and iteminfo['CommunityRating']: - edits = { - 'audienceRating.value': iteminfo['CommunityRating'], - 'audienceRating.locked': 1 - } - plexitem.edit(**edits) - plexitem.editTitle(iteminfo['Name']).editSummary(iteminfo['Overview']).reload() - return True - except Exception as err: - self.log_error(f"更新Plex媒体项详情失败:{str(err)}") - return False - - if server == "emby": - return __set_emby_iteminfo() - elif server == "jellyfin": - return __set_jellyfin_iteminfo() - else: - return __set_plex_iteminfo() - - @retry(RequestException, logger=logger) - def set_item_image(self, server: str, itemid: str, imageurl: str): - """ - 更新媒体项图片 - """ - - def __download_image(): - """ - 下载图片 - """ - try: - if "doubanio.com" in imageurl: - r = RequestUtils(headers={ - 'Referer': "https://movie.douban.com/" - }, ua=settings.USER_AGENT).get_res(url=imageurl, raise_exception=True) - else: - r = RequestUtils().get_res(url=imageurl, raise_exception=True) - if r: - return base64.b64encode(r.content).decode() - else: - self.log_error(f"{imageurl} 图片下载失败,请检查网络连通性") - except Exception as err: - self.log_error(f"下载图片失败:{str(err)}") - return None - - def __set_emby_item_image(_base64: str): - """ - 更新Emby媒体项图片 - """ - try: - url = f'[HOST]emby/Items/{itemid}/Images/Primary?api_key=[APIKEY]' - res = self.emby.post_data( - url=url, - data=_base64, - headers={ - "Content-Type": "image/png" - } - ) - if res and res.status_code in [200, 204]: - return True - else: - self.log_error(f"更新Emby媒体项图片失败,错误码:{res.status_code}") - return False - except Exception as result: - self.log_error(f"更新Emby媒体项图片失败:{result}") - return False - - def __set_jellyfin_item_image(): - """ - 更新Jellyfin媒体项图片 - # FIXME 改为预下载图片 - """ - try: - url = f'[HOST]Items/{itemid}/RemoteImages/Download?' \ - f'Type=Primary&ImageUrl={imageurl}&ProviderName=TheMovieDb&api_key=[APIKEY]' - res = self.jellyfin.post_data(url=url) - if res and res.status_code in [200, 204]: - return True - else: - self.log_error(f"更新Jellyfin媒体项图片失败,错误码:{res.status_code}") - return False - except Exception as err: - self.log_error(f"更新Jellyfin媒体项图片失败:{err}") - return False - - def __set_plex_item_image(): - """ - 更新Plex媒体项图片 - # FIXME 改为预下载图片 - """ - try: - plexitem = self.plex.get_plex().library.fetchItem(ekey=itemid) - plexitem.uploadPoster(url=imageurl) - return True - except Exception as err: - self.log_error(f"更新Plex媒体项图片失败:{err}") - return False - - if server == "emby": - # 下载图片获取base64 - image_base64 = __download_image() - if image_base64: - return __set_emby_item_image(image_base64) - elif server == "jellyfin": - return __set_jellyfin_item_image() - else: - return __set_plex_item_image() - return None - - def log_error(self, ss: str): - logger.error(f"<{self.plugin_name}> {str(ss)}") - - def log_warn(self, ss: str): - logger.warn(f"<{self.plugin_name}> {str(ss)}") - - def log_info(self, ss: str): - logger.info(f"<{self.plugin_name}> {str(ss)}") - - def stop_service(self): - """ - 停止服务 - """ - pass diff --git a/plugins.v2/mediasyncdel/__init__.py b/plugins.v2/mediasyncdel/__init__.py deleted file mode 100644 index 41cb858..0000000 --- a/plugins.v2/mediasyncdel/__init__.py +++ /dev/null @@ -1,1589 +0,0 @@ -import datetime -import json -import os -import re -import time -from pathlib import Path -from typing import List, Tuple, Dict, Any, Optional - -from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.triggers.cron import CronTrigger - -from app import schemas -from app.chain.transfer import TransferChain -from app.core.config import settings -from app.core.event import eventmanager, Event -from app.db.models.transferhistory import TransferHistory -from app.log import logger -from app.modules.emby import Emby -from app.modules.jellyfin import Jellyfin -from app.plugins import _PluginBase -from app.schemas.types import NotificationType, EventType, MediaType, MediaImageType - - -class MediaSyncDel(_PluginBase): - # 插件名称 - plugin_name = "媒体文件同步删除" - # 插件描述 - plugin_desc = "同步删除历史记录、源文件和下载任务。" - # 插件图标 - plugin_icon = "mediasyncdel.png" - # 插件版本 - plugin_version = "1.7" - # 插件作者 - plugin_author = "thsrite" - # 作者主页 - author_url = "https://github.com/thsrite" - # 插件配置项ID前缀 - plugin_config_prefix = "mediasyncdel_" - # 加载顺序 - plugin_order = 9 - # 可使用的用户级别 - auth_level = 1 - - # 私有属性 - _scheduler: Optional[BackgroundScheduler] = None - _enabled = False - _sync_type: str = "" - _cron: str = "" - _notify = False - _del_source = False - _del_history = False - _exclude_path = None - _library_path = None - _transferchain = None - _transferhis = None - _downloadhis = None - - def init_plugin(self, config: dict = None): - self._transferchain = TransferChain() - self._transferhis = self._transferchain.transferhis - self._downloadhis = self._transferchain.downloadhis - - # 停止现有任务 - self.stop_service() - - # 读取配置 - if config: - self._enabled = config.get("enabled") - self._sync_type = config.get("sync_type") - self._cron = config.get("cron") - self._notify = config.get("notify") - self._del_source = config.get("del_source") - self._del_history = config.get("del_history") - self._exclude_path = config.get("exclude_path") - self._library_path = config.get("library_path") - - # 清理插件历史 - if self._del_history: - self.del_data(key="history") - self.update_config({ - "enabled": self._enabled, - "sync_type": self._sync_type, - "cron": self._cron, - "notify": self._notify, - "del_source": self._del_source, - "del_history": False, - "exclude_path": self._exclude_path, - "library_path": self._library_path - }) - - @staticmethod - def get_command() -> List[Dict[str, Any]]: - """ - 定义远程控制命令 - :return: 命令关键字、事件、描述、附带数据 - """ - pass - - def get_api(self) -> List[Dict[str, Any]]: - return [ - { - "path": "/delete_history", - "endpoint": self.delete_history, - "methods": ["GET"], - "summary": "删除订阅历史记录" - } - ] - - def delete_history(self, key: str, apikey: str): - """ - 删除历史记录 - """ - if apikey != settings.API_TOKEN: - return schemas.Response(success=False, message="API密钥错误") - # 历史记录 - historys = self.get_data('history') - if not historys: - return schemas.Response(success=False, message="未找到历史记录") - # 删除指定记录 - historys = [h for h in historys if h.get("unique") != key] - self.save_data('history', historys) - return schemas.Response(success=True, message="删除成功") - - 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 and str(self._sync_type) == "log": - # 媒体库同步删除日志方式 - if self._cron: - return [{ - "id": "MediaSyncDel", - "name": "媒体库同步删除服务", - "trigger": CronTrigger.from_crontab(self._cron), - "func": self.sync_del_by_log, - "kwargs": {} - }] - else: - return [{ - "id": "MediaSyncDel", - "name": "媒体库同步删除服务", - "trigger": "interval", - "func": self.sync_del_by_log, - "kwargs": {"minutes": 30} - }] - return [] - - def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: - """ - 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 - """ - return [ - { - 'component': 'VForm', - 'content': [ - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 3 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'enabled', - 'label': '启用插件', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 3 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'notify', - 'label': '发送通知', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 3 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'del_source', - 'label': '删除源文件', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 3 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'del_history', - 'label': '删除历史', - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSelect', - 'props': { - 'model': 'sync_type', - 'label': '媒体库同步方式', - 'items': [ - {'title': 'Webhook', 'value': 'webhook'}, - {'title': '日志', 'value': 'log'}, - {'title': 'Scripter X', 'value': 'plugin'} - ] - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'cron', - 'label': '日志检查周期', - 'placeholder': '5位cron表达式,留空自动' - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'exclude_path', - 'label': '排除路径' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VTextarea', - 'props': { - 'model': 'library_path', - 'rows': '2', - 'label': '媒体库路径映射', - 'placeholder': '媒体服务器路径:MoviePilot路径(一行一个)' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '媒体库同步方式分为Webhook、日志同步和Scripter X:' - '1、Webhook需要Emby4.8.0.45及以上开启媒体删除的Webhook。' - '2、日志同步需要配置检查周期,默认30分钟执行一次。' - '3、Scripter X方式需要emby安装并配置Scripter X插件,无需配置执行周期。' - '4、启用该插件后,非媒体服务器触发的源文件删除,也会同步处理下载器中的下载任务。' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '关于路径映射(转移后文件路径):' - 'emby:/data/A.mp4,' - 'moviepilot:/mnt/link/A.mp4。' - '路径映射填/data:/mnt/link。' - '不正确配置会导致查询不到转移记录!(路径一样可不填)' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '排除路径:命中排除路径后请求云盘删除插件删除云盘资源。' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': 'Scripter X配置文档:' - 'https://github.com/thsrite/' - 'MediaSyncDel/blob/main/MoviePilot/MoviePilot.md' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '路径映射配置文档:' - 'https://github.com/thsrite/MediaSyncDel/blob/main/path.md' - } - } - ] - } - ] - } - ] - } - ], { - "enabled": False, - "notify": True, - "del_source": False, - "del_history": False, - "library_path": "", - "sync_type": "webhook", - "cron": "*/30 * * * *", - "exclude_path": "", - } - - def get_page(self) -> List[dict]: - """ - 拼装插件详情页面,需要返回页面配置,同时附带数据 - """ - # 查询同步详情 - historys = self.get_data('history') - if not historys: - return [ - { - 'component': 'div', - 'text': '暂无数据', - 'props': { - 'class': 'text-center', - } - } - ] - # 数据按时间降序排序 - historys = sorted(historys, key=lambda x: x.get('del_time'), reverse=True) - # 拼装页面 - contents = [] - for history in historys: - htype = history.get("type") - title = history.get("title") - unique = history.get("unique") - year = history.get("year") - season = history.get("season") - episode = history.get("episode") - image = history.get("image") - del_time = history.get("del_time") - - if season: - sub_contents = [ - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'类型:{htype}' - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'标题:{title}' - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'年份:{year}' - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'季:{season}' - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'集:{episode}' - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'时间:{del_time}' - } - ] - else: - sub_contents = [ - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'类型:{htype}' - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'标题:{title}' - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'年份:{year}' - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'时间:{del_time}' - } - ] - - contents.append( - { - 'component': 'VCard', - 'content': [ - { - "component": "VDialogCloseBtn", - "props": { - 'innerClass': 'absolute top-0 right-0', - }, - 'events': { - 'click': { - 'api': 'plugin/MediaSyncDel/delete_history', - 'method': 'get', - 'params': { - 'key': unique, - 'apikey': settings.API_TOKEN - } - } - }, - }, - { - 'component': 'div', - 'props': { - 'class': 'd-flex justify-space-start flex-nowrap flex-row', - }, - 'content': [ - { - 'component': 'div', - 'content': [ - { - 'component': 'VImg', - 'props': { - 'src': image, - 'height': 120, - 'width': 80, - 'aspect-ratio': '2/3', - 'class': 'object-cover shadow ring-gray-500', - 'cover': True - } - } - ] - }, - { - 'component': 'div', - 'content': sub_contents - } - ] - } - ] - } - ) - - return [ - { - 'component': 'div', - 'props': { - 'class': 'grid gap-3 grid-info-card', - }, - 'content': contents - } - ] - - @eventmanager.register(EventType.WebhookMessage) - def sync_del_by_webhook(self, event: Event): - """ - emby删除媒体库同步删除历史记录 - webhook - """ - if not self._enabled or str(self._sync_type) != "webhook": - return - - event_data = event.event_data - event_type = event_data.event - - # Emby Webhook event_type = library.deleted - if not event_type or str(event_type) != 'library.deleted': - return - - # 媒体类型 - media_type = event_data.media_type - # 媒体名称 - media_name = event_data.item_name - # 媒体路径 - media_path = event_data.item_path - # tmdb_id - tmdb_id = event_data.tmdb_id - # 季数 - season_num = event_data.season_id - # 集数 - episode_num = event_data.episode_id - - """ - 执行删除逻辑 - """ - if self._exclude_path and media_path and any( - os.path.abspath(media_path).startswith(os.path.abspath(path)) for path in - self._exclude_path.split(",")): - logger.info(f"媒体路径 {media_path} 已被排除,暂不处理") - # 发送消息通知网盘删除插件删除网盘资源 - self.eventmanager.send_event(EventType.PluginAction, - { - "action": "networkdisk_del", - "media_path": media_path, - "media_name": media_name, - "tmdb_id": tmdb_id, - "media_type": media_type, - "season_num": season_num, - "episode_num": episode_num, - }) - return - - # 兼容emby webhook season删除没有发送tmdbid - if not tmdb_id and str(media_type) != 'Season': - logger.error(f"{media_name} 同步删除失败,未获取到TMDB ID,请检查媒体库媒体是否刮削") - return - - self.__sync_del(media_type=media_type, - media_name=media_name, - media_path=media_path, - tmdb_id=tmdb_id, - season_num=season_num, - episode_num=episode_num) - - @eventmanager.register(EventType.WebhookMessage) - def sync_del_by_plugin(self, event): - """ - emby删除媒体库同步删除历史记录 - Scripter X插件 - """ - if not self._enabled or str(self._sync_type) != "plugin": - return - - event_data = event.event_data - event_type = event_data.event - - # Scripter X插件 event_type = media_del - if not event_type or str(event_type) != 'media_del': - return - - # Scripter X插件 需要是否虚拟标识 - item_isvirtual = event_data.item_isvirtual - if not item_isvirtual: - logger.error("Scripter X插件方式,item_isvirtual参数未配置,为防止误删除,暂停插件运行") - self.update_config({ - "enabled": False, - "del_source": self._del_source, - "exclude_path": self._exclude_path, - "library_path": self._library_path, - "notify": self._notify, - "cron": self._cron, - "sync_type": self._sync_type, - }) - return - - # 如果是虚拟item,则直接return,不进行删除 - if item_isvirtual == 'True': - return - - # 媒体类型 - media_type = event_data.item_type - # 媒体名称 - media_name = event_data.item_name - # 媒体路径 - media_path = event_data.item_path - # tmdb_id - tmdb_id = event_data.tmdb_id - # 季数 - season_num = event_data.season_id - # 集数 - episode_num = event_data.episode_id - - """ - 执行删除逻辑 - """ - if self._exclude_path and media_path and any( - os.path.abspath(media_path).startswith(os.path.abspath(path)) for path in - self._exclude_path.split(",")): - logger.info(f"媒体路径 {media_path} 已被排除,暂不处理") - # 发送消息通知网盘删除插件删除网盘资源 - self.eventmanager.send_event(EventType.PluginAction, - { - "action": "networkdisk_del", - "media_path": media_path, - "media_name": media_name, - "tmdb_id": tmdb_id, - "media_type": media_type, - "season_num": season_num, - "episode_num": episode_num, - }) - return - - if not tmdb_id or not str(tmdb_id).isdigit(): - logger.error(f"{media_name} 同步删除失败,未获取到TMDB ID,请检查媒体库媒体是否刮削") - return - - self.__sync_del(media_type=media_type, - media_name=media_name, - media_path=media_path, - tmdb_id=tmdb_id, - season_num=season_num, - episode_num=episode_num) - - def __sync_del(self, media_type: str, media_name: str, media_path: str, - tmdb_id: int, season_num: str, episode_num: str): - if not media_type: - logger.error(f"{media_name} 同步删除失败,未获取到媒体类型,请检查媒体是否刮削") - return - - # 处理路径映射 (处理同一媒体多分辨率的情况) - if self._library_path: - paths = self._library_path.split("\n") - for path in paths: - sub_paths = path.split(":") - if len(sub_paths) < 2: - continue - media_path = media_path.replace(sub_paths[0], sub_paths[1]).replace('\\', '/') - - # 兼容重新整理的场景 - if Path(media_path).exists(): - logger.warn(f"转移路径 {media_path} 未被删除或重新生成,跳过处理") - return - - # 查询转移记录 - msg, transfer_history = self.__get_transfer_his(media_type=media_type, - media_name=media_name, - media_path=media_path, - tmdb_id=tmdb_id, - season_num=season_num, - episode_num=episode_num) - - logger.info(f"正在同步删除{msg}") - - if not transfer_history: - logger.warn( - f"{media_type} {media_name} 未获取到可删除数据,请检查路径映射是否配置错误,请检查tmdbid获取是否正确") - return - - # 开始删除 - year = None - del_torrent_hashs = [] - stop_torrent_hashs = [] - error_cnt = 0 - image = 'https://emby.media/notificationicon.png' - for transferhis in transfer_history: - title = transferhis.title - if title not in media_name: - logger.warn( - f"当前转移记录 {transferhis.id} {title} {transferhis.tmdbid} 与删除媒体{media_name}不符,防误删,暂不自动删除") - continue - image = transferhis.image or image - year = transferhis.year - - # 0、删除转移记录 - self._transferhis.delete(transferhis.id) - - # 删除种子任务 - if self._del_source: - # 1、直接删除源文件 - if transferhis.src and Path(transferhis.src).suffix in settings.RMT_MEDIAEXT: - self._transferchain.delete_files(Path(transferhis.src)) - if transferhis.download_hash: - try: - # 2、判断种子是否被删除完 - delete_flag, success_flag, handle_torrent_hashs = self.handle_torrent( - type=transferhis.type, - src=transferhis.src, - torrent_hash=transferhis.download_hash) - if not success_flag: - error_cnt += 1 - else: - if delete_flag: - del_torrent_hashs += handle_torrent_hashs - else: - stop_torrent_hashs += handle_torrent_hashs - except Exception as e: - logger.error("删除种子失败:%s" % str(e)) - - logger.info(f"同步删除 {msg} 完成!") - - media_type = MediaType.MOVIE if media_type in ["Movie", "MOV"] else MediaType.TV - - # 发送消息 - if self._notify: - backrop_image = self.chain.obtain_specific_image( - mediaid=tmdb_id, - mtype=media_type, - image_type=MediaImageType.Backdrop, - season=season_num, - episode=episode_num - ) or image - - torrent_cnt_msg = "" - if del_torrent_hashs: - torrent_cnt_msg += f"删除种子{len(set(del_torrent_hashs))}个\n" - if stop_torrent_hashs: - stop_cnt = 0 - # 排除已删除 - for stop_hash in set(stop_torrent_hashs): - if stop_hash not in set(del_torrent_hashs): - stop_cnt += 1 - if stop_cnt > 0: - torrent_cnt_msg += f"暂停种子{stop_cnt}个\n" - if error_cnt: - torrent_cnt_msg += f"删种失败{error_cnt}个\n" - # 发送通知 - self.post_message( - mtype=NotificationType.MediaServer, - title="媒体库同步删除任务完成", - image=backrop_image, - text=f"{msg}\n" - f"删除记录{len(transfer_history)}个\n" - f"{torrent_cnt_msg}" - f"时间 {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}" - ) - - # 读取历史记录 - history = self.get_data('history') or [] - - # 获取poster - poster_image = self.chain.obtain_specific_image( - mediaid=tmdb_id, - mtype=media_type, - image_type=MediaImageType.Poster, - ) or image - history.append({ - "type": media_type.value, - "title": media_name, - "year": year, - "path": media_path, - "season": season_num if season_num and str(season_num).isdigit() else None, - "episode": episode_num if episode_num and str(episode_num).isdigit() else None, - "image": poster_image, - "del_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())), - "unique": f"{media_name}:{tmdb_id}:{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}" - }) - - # 保存历史 - self.save_data("history", history) - - def __get_transfer_his(self, media_type: str, media_name: str, media_path: str, - tmdb_id: int, season_num: str, episode_num: str): - """ - 查询转移记录 - """ - # 季数 - if season_num and str(season_num).isdigit(): - season_num = str(season_num).rjust(2, '0') - else: - season_num = None - # 集数 - if episode_num and str(episode_num).isdigit(): - episode_num = str(episode_num).rjust(2, '0') - else: - episode_num = None - - # 类型 - mtype = MediaType.MOVIE if media_type in ["Movie", "MOV"] else MediaType.TV - - # 删除电影 - if mtype == MediaType.MOVIE: - msg = f'电影 {media_name} {tmdb_id}' - transfer_history: List[TransferHistory] = self._transferhis.get_by(tmdbid=tmdb_id, - mtype=mtype.value, - dest=media_path) - # 删除电视剧 - elif mtype == MediaType.TV and not season_num and not episode_num: - msg = f'剧集 {media_name} {tmdb_id}' - transfer_history: List[TransferHistory] = self._transferhis.get_by(tmdbid=tmdb_id, - mtype=mtype.value) - # 删除季 S02 - elif mtype == MediaType.TV and season_num and not episode_num: - if not season_num or not str(season_num).isdigit(): - logger.error(f"{media_name} 季同步删除失败,未获取到具体季") - return - msg = f'剧集 {media_name} S{season_num} {tmdb_id}' - if tmdb_id and str(tmdb_id).isdigit(): - # 根据tmdb_id查询转移记录 - transfer_history: List[TransferHistory] = self._transferhis.get_by(tmdbid=tmdb_id, - mtype=mtype.value, - season=f'S{season_num}') - else: - # 兼容emby webhook不发送tmdb场景 - transfer_history: List[TransferHistory] = self._transferhis.get_by(mtype=mtype.value, - season=f'S{season_num}', - dest=media_path) - # 删除剧集S02E02 - elif mtype == MediaType.TV and season_num and episode_num: - if not season_num or not str(season_num).isdigit() or not episode_num or not str(episode_num).isdigit(): - logger.error(f"{media_name} 集同步删除失败,未获取到具体集") - return - msg = f'剧集 {media_name} S{season_num}E{episode_num} {tmdb_id}' - transfer_history: List[TransferHistory] = self._transferhis.get_by(tmdbid=tmdb_id, - mtype=mtype.value, - season=f'S{season_num}', - episode=f'E{episode_num}', - dest=media_path) - else: - return "", [] - - return msg, transfer_history - - def sync_del_by_log(self): - """ - emby删除媒体库同步删除历史记录 - 日志方式 - """ - # 读取历史记录 - history = self.get_data('history') or [] - last_time = self.get_data("last_time") or None - del_medias = [] - - # 媒体服务器类型,多个以,分隔 - if not settings.MEDIASERVER: - return - media_servers = settings.MEDIASERVER.split(',') - for media_server in media_servers: - if media_server == 'emby': - del_medias.extend(self.parse_emby_log(last_time)) - elif media_server == 'jellyfin': - del_medias.extend(self.parse_jellyfin_log(last_time)) - elif media_server == 'plex': - # TODO plex解析日志 - return - - if not del_medias: - logger.error("未解析到已删除媒体信息") - return - - # 遍历删除 - last_del_time = None - for del_media in del_medias: - # 删除时间 - del_time = del_media.get("time") - last_del_time = del_time or datetime.datetime.now() - # 媒体类型 Movie|Series|Season|Episode - media_type = del_media.get("type") - # 媒体名称 蜀山战纪 - media_name = del_media.get("name") - # 媒体年份 2015 - media_year = del_media.get("year") - # 媒体路径 /data/series/国产剧/蜀山战纪 (2015)/Season 2/蜀山战纪 - S02E01 - 第1集.mp4 - media_path = del_media.get("path") - # 季数 S02 - media_season = del_media.get("season") - # 集数 E02 - media_episode = del_media.get("episode") - - # 排除路径不处理 - if self._exclude_path and media_path and any( - os.path.abspath(media_path).startswith(os.path.abspath(path)) for path in - self._exclude_path.split(",")): - logger.info(f"媒体路径 {media_path} 已被排除,暂不处理") - self.save_data("last_time", last_del_time) - return - - # 处理路径映射 (处理同一媒体多分辨率的情况) - if self._library_path: - paths = self._library_path.split("\n") - for path in paths: - sub_paths = path.split(":") - if len(sub_paths) < 2: - continue - media_path = media_path.replace(sub_paths[0], sub_paths[1]).replace('\\', '/') - - # 获取删除的记录 - # 删除电影 - if media_type == "Movie": - msg = f'电影 {media_name}' - transfer_history: List[TransferHistory] = self._transferhis.get_by( - title=media_name, - year=media_year, - dest=media_path) - # 删除电视剧 - elif media_type == "Series": - msg = f'剧集 {media_name}' - transfer_history: List[TransferHistory] = self._transferhis.get_by( - title=media_name, - year=media_year) - # 删除季 S02 - elif media_type == "Season": - msg = f'剧集 {media_name} {media_season}' - transfer_history: List[TransferHistory] = self._transferhis.get_by( - title=media_name, - year=media_year, - season=media_season) - # 删除剧集S02E02 - elif media_type == "Episode": - msg = f'剧集 {media_name} {media_season}{media_episode}' - transfer_history: List[TransferHistory] = self._transferhis.get_by( - title=media_name, - year=media_year, - season=media_season, - episode=media_episode, - dest=media_path) - else: - self.save_data("last_time", last_del_time) - continue - - logger.info(f"正在同步删除 {msg}") - - if not transfer_history: - logger.info(f"未获取到 {msg} 转移记录,请检查路径映射是否配置错误,请检查tmdbid获取是否正确") - self.save_data("last_time", last_del_time) - continue - - logger.info(f"获取到删除历史记录数量 {len(transfer_history)}") - - # 开始删除 - image = 'https://emby.media/notificationicon.png' - del_torrent_hashs = [] - stop_torrent_hashs = [] - error_cnt = 0 - for transferhis in transfer_history: - title = transferhis.title - if title not in media_name: - logger.warn( - f"当前转移记录 {transferhis.id} {title} {transferhis.tmdbid} 与删除媒体{media_name}不符,防误删,暂不自动删除") - self.save_data("last_time", last_del_time) - continue - image = transferhis.image or image - # 0、删除转移记录 - self._transferhis.delete(transferhis.id) - - # 删除种子任务 - if self._del_source: - # 1、直接删除源文件 - if transferhis.src and Path(transferhis.src).suffix in settings.RMT_MEDIAEXT: - self._transferchain.delete_files(Path(transferhis.src)) - if transferhis.download_hash: - try: - # 2、判断种子是否被删除完 - delete_flag, success_flag, handle_torrent_hashs = self.handle_torrent( - type=transferhis.type, - src=transferhis.src, - torrent_hash=transferhis.download_hash) - if not success_flag: - error_cnt += 1 - else: - if delete_flag: - del_torrent_hashs += handle_torrent_hashs - else: - stop_torrent_hashs += handle_torrent_hashs - except Exception as e: - logger.error("删除种子失败:%s" % str(e)) - - logger.info(f"同步删除 {msg} 完成!") - - # 发送消息 - if self._notify: - torrent_cnt_msg = "" - if del_torrent_hashs: - torrent_cnt_msg += f"删除种子{len(set(del_torrent_hashs))}个\n" - if stop_torrent_hashs: - stop_cnt = 0 - # 排除已删除 - for stop_hash in set(stop_torrent_hashs): - if stop_hash not in set(del_torrent_hashs): - stop_cnt += 1 - if stop_cnt > 0: - torrent_cnt_msg += f"暂停种子{stop_cnt}个\n" - self.post_message( - mtype=NotificationType.MediaServer, - title="媒体库同步删除任务完成", - text=f"{msg}\n" - f"删除记录{len(transfer_history)}个\n" - f"{torrent_cnt_msg}" - f"时间 {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}", - image=image) - - history.append({ - "type": "电影" if media_type == "Movie" else "电视剧", - "title": media_name, - "year": media_year, - "path": media_path, - "season": media_season, - "episode": media_episode, - "image": image, - "del_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())) - }) - - # 保存历史 - self.save_data("history", history) - - self.save_data("last_time", last_del_time) - - def handle_torrent(self, type: str, src: str, torrent_hash: str): - """ - 判断种子是否局部删除 - 局部删除则暂停种子 - 全部删除则删除种子 - """ - download_id = torrent_hash - download = settings.DEFAULT_DOWNLOADER - history_key = "%s-%s" % (download, torrent_hash) - plugin_id = "TorrentTransfer" - transfer_history = self.get_data(key=history_key, - plugin_id=plugin_id) - logger.info(f"查询到 {history_key} 转种历史 {transfer_history}") - - handle_torrent_hashs = [] - try: - # 删除本次种子记录 - self._downloadhis.delete_file_by_fullpath(fullpath=src) - - # 根据种子hash查询所有下载器文件记录 - download_files = self._downloadhis.get_files_by_hash(download_hash=torrent_hash) - if not download_files: - logger.error( - f"未查询到种子任务 {torrent_hash} 存在文件记录,未执行下载器文件同步或该种子已被删除") - return False, False, 0 - - # 查询未删除数 - no_del_cnt = 0 - for download_file in download_files: - if download_file and download_file.state and int(download_file.state) == 1: - no_del_cnt += 1 - - if no_del_cnt > 0: - logger.info( - f"查询种子任务 {torrent_hash} 存在 {no_del_cnt} 个未删除文件,执行暂停种子操作") - delete_flag = False - else: - logger.info( - f"查询种子任务 {torrent_hash} 文件已全部删除,执行删除种子操作") - delete_flag = True - - # 如果有转种记录,则删除转种后的下载任务 - if transfer_history and isinstance(transfer_history, dict): - download = transfer_history['to_download'] - download_id = transfer_history['to_download_id'] - delete_source = transfer_history['delete_source'] - - # 删除种子 - if delete_flag: - # 删除转种记录 - self.del_data(key=history_key, plugin_id=plugin_id) - - # 转种后未删除源种时,同步删除源种 - if not delete_source: - logger.info(f"{history_key} 转种时未删除源下载任务,开始删除源下载任务…") - - # 删除源种子 - logger.info(f"删除源下载器下载任务:{settings.DEFAULT_DOWNLOADER} - {torrent_hash}") - self.chain.remove_torrents(torrent_hash) - handle_torrent_hashs.append(torrent_hash) - - # 删除转种后任务 - logger.info(f"删除转种后下载任务:{download} - {download_id}") - # 删除转种后下载任务 - self.chain.remove_torrents(hashs=torrent_hash, - downloader=download) - handle_torrent_hashs.append(download_id) - else: - # 暂停种子 - # 转种后未删除源种时,同步暂停源种 - if not delete_source: - logger.info(f"{history_key} 转种时未删除源下载任务,开始暂停源下载任务…") - - # 暂停源种子 - logger.info(f"暂停源下载器下载任务:{settings.DEFAULT_DOWNLOADER} - {torrent_hash}") - self.chain.stop_torrents(torrent_hash) - handle_torrent_hashs.append(torrent_hash) - - logger.info(f"暂停转种后下载任务:{download} - {download_id}") - # 删除转种后下载任务 - self.chain.stop_torrents(hashs=download_id, downloader=download) - handle_torrent_hashs.append(download_id) - else: - # 未转种de情况 - if delete_flag: - # 删除源种子 - logger.info(f"删除源下载器下载任务:{download} - {download_id}") - self.chain.remove_torrents(download_id) - else: - # 暂停源种子 - logger.info(f"暂停源下载器下载任务:{download} - {download_id}") - self.chain.stop_torrents(download_id) - handle_torrent_hashs.append(download_id) - - # 处理辅种 - handle_torrent_hashs = self.__del_seed(download_id=download_id, - delete_flag=delete_flag, - handle_torrent_hashs=handle_torrent_hashs) - # 处理合集 - if str(type) == "电视剧": - handle_torrent_hashs = self.__del_collection(src=src, - delete_flag=delete_flag, - torrent_hash=torrent_hash, - download_files=download_files, - handle_torrent_hashs=handle_torrent_hashs) - return delete_flag, True, handle_torrent_hashs - except Exception as e: - logger.error(f"删种失败: {str(e)}") - return False, False, 0 - - def __del_collection(self, src: str, delete_flag: bool, torrent_hash: str, download_files: list, - handle_torrent_hashs: list): - """ - 处理合集 - """ - try: - src_download_files = self._downloadhis.get_files_by_fullpath(fullpath=src) - if src_download_files: - for download_file in src_download_files: - # src查询记录 判断download_hash是否不一致 - if download_file and download_file.download_hash and str(download_file.download_hash) != str( - torrent_hash): - # 查询新download_hash对应files数量 - hash_download_files = self._downloadhis.get_files_by_hash( - download_hash=download_file.download_hash) - # 新download_hash对应files数量 > 删种download_hash对应files数量 = 合集种子 - if hash_download_files \ - and len(hash_download_files) > len(download_files) \ - and hash_download_files[0].id > download_files[-1].id: - # 查询未删除数 - no_del_cnt = 0 - for hash_download_file in hash_download_files: - if hash_download_file and hash_download_file.state and int( - hash_download_file.state) == 1: - no_del_cnt += 1 - if no_del_cnt > 0: - logger.info(f"合集种子 {download_file.download_hash} 文件未完全删除,执行暂停种子操作") - delete_flag = False - - # 删除合集种子 - if delete_flag: - self.chain.remove_torrents(hashs=download_file.download_hash, - downloader=download_file.downloader) - logger.info(f"删除合集种子 {download_file.downloader} {download_file.download_hash}") - else: - # 暂停合集种子 - self.chain.stop_torrents(hashs=download_file.download_hash, - downloader=download_file.downloader) - logger.info(f"暂停合集种子 {download_file.downloader} {download_file.download_hash}") - # 已处理种子+1 - handle_torrent_hashs.append(download_file.download_hash) - - # 处理合集辅种 - handle_torrent_hashs = self.__del_seed(download_id=download_file.download_hash, - delete_flag=delete_flag, - handle_torrent_hashs=handle_torrent_hashs) - except Exception as e: - logger.error(f"处理 {torrent_hash} 合集失败") - print(str(e)) - - return handle_torrent_hashs - - def __del_seed(self, download_id, delete_flag, handle_torrent_hashs): - """ - 删除辅种 - """ - # 查询是否有辅种记录 - history_key = download_id - plugin_id = "IYUUAutoSeed" - seed_history = self.get_data(key=history_key, - plugin_id=plugin_id) or [] - logger.info(f"查询到 {history_key} 辅种历史 {seed_history}") - - # 有辅种记录则处理辅种 - if seed_history and isinstance(seed_history, list): - for history in seed_history: - downloader = history.get("downloader") - torrents = history.get("torrents") - if not downloader or not torrents: - return - if not isinstance(torrents, list): - torrents = [torrents] - - # 删除辅种历史 - for torrent in torrents: - handle_torrent_hashs.append(torrent) - # 删除辅种 - if delete_flag: - logger.info(f"删除辅种:{downloader} - {torrent}") - self.chain.remove_torrents(hashs=torrent, - downloader=downloader) - # 暂停辅种 - else: - self.chain.stop_torrents(hashs=torrent, download=downloader) - logger.info(f"辅种:{downloader} - {torrent} 暂停") - - # 处理辅种的辅种 - handle_torrent_hashs = self.__del_seed(download_id=torrent, - delete_flag=delete_flag, - handle_torrent_hashs=handle_torrent_hashs) - - # 删除辅种历史 - if delete_flag: - self.del_data(key=history_key, - plugin_id=plugin_id) - return handle_torrent_hashs - - @staticmethod - def parse_emby_log(last_time): - """ - 获取emby日志列表、解析emby日志 - """ - - def __parse_log(file_name: str, del_list: list): - """ - 解析emby日志 - """ - log_url = f"[HOST]System/Logs/{file_name}?api_key=[APIKEY]" - log_res = Emby().get_data(log_url) - if not log_res or log_res.status_code != 200: - logger.error("获取emby日志失败,请检查服务器配置") - return del_list - - # 正则解析删除的媒体信息 - pattern = r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}.\d{3}) Info App: Removing item from database, Type: (\w+), Name: (.*), Path: (.*), Id: (\d+)' - matches = re.findall(pattern, log_res.text) - - # 循环获取媒体信息 - for match in matches: - mtime = match[0] - # 排除已处理的媒体信息 - if last_time and mtime < last_time: - continue - - mtype = match[1] - name = match[2] - path = match[3] - - year = None - year_pattern = r'\(\d+\)' - year_match = re.search(year_pattern, path) - if year_match: - year = year_match.group()[1:-1] - - season = None - episode = None - if mtype == 'Episode' or mtype == 'Season': - name_pattern = r"\/([\u4e00-\u9fa5]+)(?= \()" - season_pattern = r"Season\s*(\d+)" - episode_pattern = r"S\d+E(\d+)" - name_match = re.search(name_pattern, path) - season_match = re.search(season_pattern, path) - episode_match = re.search(episode_pattern, path) - - if name_match: - name = name_match.group(1) - - if season_match: - season = season_match.group(1) - if int(season) < 10: - season = f'S0{season}' - else: - season = f'S{season}' - else: - season = None - - if episode_match: - episode = episode_match.group(1) - episode = f'E{episode}' - else: - episode = None - - media = { - "time": mtime, - "type": mtype, - "name": name, - "year": year, - "path": path, - "season": season, - "episode": episode, - } - logger.debug(f"解析到删除媒体:{json.dumps(media)}") - del_list.append(media) - - return del_list - - log_files = [] - try: - # 获取所有emby日志 - log_list_url = "[HOST]System/Logs/Query?Limit=3&api_key=[APIKEY]" - log_list_res = Emby().get_data(log_list_url) - - if log_list_res and log_list_res.status_code == 200: - log_files_dict = json.loads(log_list_res.text) - for item in log_files_dict.get("Items"): - if str(item.get('Name')).startswith("embyserver"): - log_files.append(str(item.get('Name'))) - except Exception as e: - print(str(e)) - - if not log_files: - log_files.append("embyserver.txt") - - del_medias = [] - log_files.reverse() - for log_file in log_files: - del_medias = __parse_log(file_name=log_file, - del_list=del_medias) - - return del_medias - - @staticmethod - def parse_jellyfin_log(last_time: datetime): - """ - 获取jellyfin日志列表、解析jellyfin日志 - """ - - def __parse_log(file_name: str, del_list: list): - """ - 解析jellyfin日志 - """ - log_url = f"[HOST]System/Logs/Log?name={file_name}&api_key=[APIKEY]" - log_res = Jellyfin().get_data(log_url) - if not log_res or log_res.status_code != 200: - logger.error("获取jellyfin日志失败,请检查服务器配置") - return del_list - - # 正则解析删除的媒体信息 - pattern = r'\[(.*?)\].*?Removing item, Type: "(.*?)", Name: "(.*?)", Path: "(.*?)"' - matches = re.findall(pattern, log_res.text) - - # 循环获取媒体信息 - for match in matches: - mtime = match[0] - # 排除已处理的媒体信息 - if last_time and mtime < last_time: - continue - - mtype = match[1] - name = match[2] - path = match[3] - - year = None - year_pattern = r'\(\d+\)' - year_match = re.search(year_pattern, path) - if year_match: - year = year_match.group()[1:-1] - - season = None - episode = None - if mtype == 'Episode' or mtype == 'Season': - name_pattern = r"\/([\u4e00-\u9fa5]+)(?= \()" - season_pattern = r"Season\s*(\d+)" - episode_pattern = r"S\d+E(\d+)" - name_match = re.search(name_pattern, path) - season_match = re.search(season_pattern, path) - episode_match = re.search(episode_pattern, path) - - if name_match: - name = name_match.group(1) - - if season_match: - season = season_match.group(1) - if int(season) < 10: - season = f'S0{season}' - else: - season = f'S{season}' - else: - season = None - - if episode_match: - episode = episode_match.group(1) - episode = f'E{episode}' - else: - episode = None - - media = { - "time": mtime, - "type": mtype, - "name": name, - "year": year, - "path": path, - "season": season, - "episode": episode, - } - logger.debug(f"解析到删除媒体:{json.dumps(media)}") - del_list.append(media) - - return del_list - - log_files = [] - try: - # 获取所有jellyfin日志 - log_list_url = "[HOST]System/Logs?api_key=[APIKEY]" - log_list_res = Jellyfin().get_data(log_list_url) - - if log_list_res and log_list_res.status_code == 200: - log_files_dict = json.loads(log_list_res.text) - for item in log_files_dict: - if str(item.get('Name')).startswith("log_"): - log_files.append(str(item.get('Name'))) - except Exception as e: - print(str(e)) - - if not log_files: - log_files.append("log_%s.log" % datetime.date.today().strftime("%Y%m%d")) - - del_medias = [] - log_files.reverse() - for log_file in log_files: - del_medias = __parse_log(file_name=log_file, - del_list=del_medias) - - return del_medias - - def get_state(self): - return self._enabled - - def stop_service(self): - """ - 退出插件 - """ - try: - if self._scheduler: - self._scheduler.remove_all_jobs() - if self._scheduler.running: - self._scheduler.shutdown() - self._scheduler = None - except Exception as e: - logger.error("退出插件失败:%s" % str(e)) - - @eventmanager.register(EventType.DownloadFileDeleted) - def downloadfile_del_sync(self, event: Event): - """ - 下载文件删除处理事件 - """ - if not event: - return - event_data = event.event_data - src = event_data.get("src") - if not src: - return - # 查询下载hash - download_hash = self._downloadhis.get_hash_by_fullpath(src) - if download_hash: - download_history = self._downloadhis.get_by_hash(download_hash) - self.handle_torrent(type=download_history.type, src=src, torrent_hash=download_hash) - else: - logger.warn(f"未查询到文件 {src} 对应的下载记录") - - @staticmethod - def get_tmdbimage_url(path: str, prefix="w500"): - if not path: - return "" - tmdb_image_url = f"https://{settings.TMDB_IMAGE_DOMAIN}" - return tmdb_image_url + f"/t/p/{prefix}{path}" diff --git a/plugins.v2/messageforward/__init__.py b/plugins.v2/messageforward/__init__.py deleted file mode 100644 index 7a6b940..0000000 --- a/plugins.v2/messageforward/__init__.py +++ /dev/null @@ -1,430 +0,0 @@ -import json -import re -from datetime import datetime - -from app.core.config import settings -from app.plugins import _PluginBase -from app.core.event import eventmanager -from app.schemas.types import EventType, MessageChannel -from app.utils.http import RequestUtils -from typing import Any, List, Dict, Tuple, Optional -from app.log import logger - - -class MessageForward(_PluginBase): - # 插件名称 - plugin_name = "消息转发" - # 插件描述 - plugin_desc = "根据正则转发通知到其他WeChat应用。" - # 插件图标 - plugin_icon = "forward.png" - # 插件版本 - plugin_version = "1.1" - # 插件作者 - plugin_author = "thsrite" - # 作者主页 - author_url = "https://github.com/thsrite" - # 插件配置项ID前缀 - plugin_config_prefix = "messageforward_" - # 加载顺序 - plugin_order = 16 - # 可使用的用户级别 - auth_level = 1 - - # 私有属性 - _enabled = False - _wechat = None - _pattern = None - _pattern_token = {} - - # 企业微信发送消息URL - _send_msg_url = f"{settings.WECHAT_PROXY}/cgi-bin/message/send?access_token=%s" - # 企业微信获取TokenURL - _token_url = f"{settings.WECHAT_PROXY}/cgi-bin/gettoken?corpid=%s&corpsecret=%s" - - def init_plugin(self, config: dict = None): - if config: - self._enabled = config.get("enabled") - self._wechat = config.get("wechat") - self._pattern = config.get("pattern") - - # 获取token存库 - if self._enabled and self._wechat: - self.__save_wechat_token() - - def get_state(self) -> bool: - return self._enabled - - @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、数据结构 - """ - 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': 'VTextarea', - 'props': { - 'model': 'wechat', - 'rows': '5', - 'label': '应用配置', - 'placeholder': 'appid:corpid:appsecret(一行一个配置)' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VTextarea', - 'props': { - 'model': 'pattern', - 'rows': '6', - 'label': '正则配置', - 'placeholder': '对应上方应用配置,一行一个,一一对应' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '根据正则表达式,把MoviePilot的消息转发到多个微信应用。' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '应用配置可加注释:' - 'appid:corpid:appsecret#站点通知' - } - } - ] - } - ] - } - ] - } - ], { - "enabled": False, - "wechat": "", - "pattern": "" - } - - def get_page(self) -> List[dict]: - pass - - @eventmanager.register(EventType.NoticeMessage) - def send(self, event): - """ - 消息转发 - """ - if not self._enabled: - return - - # 消息体 - data = event.event_data - channel = data['channel'] - if channel and channel != MessageChannel.Wechat: - return - - title = data['title'] - text = data['text'] - image = data['image'] - userid = data['userid'] - - # 正则匹配 - patterns = self._pattern.split("\n") - for index, pattern in enumerate(patterns): - msg_match = re.search(pattern, title) - if msg_match: - access_token, appid = self.__flush_access_token(index) - if not access_token: - logger.error("未获取到有效token,请检查配置") - continue - - # 发送消息 - if image: - self.__send_image_message(title, text, image, userid, access_token, appid, index) - else: - self.__send_message(title, text, userid, access_token, appid, index) - - def __save_wechat_token(self): - """ - 获取并存储wechat token - """ - # 解析配置 - wechats = self._wechat.split("\n") - for index, wechat in enumerate(wechats): - # 排除注释 - wechat = wechat.split("#")[0] - wechat_config = wechat.split(":") - if len(wechat_config) != 3: - logger.error(f"{wechat} 应用配置不正确") - continue - appid = wechat_config[0] - corpid = wechat_config[1] - appsecret = wechat_config[2] - - # 已过期,重新获取token - access_token, expires_in, access_token_time = self.__get_access_token(corpid=corpid, - appsecret=appsecret) - if not access_token: - # 没有token,获取token - logger.error(f"wechat配置 appid = {appid} 获取token失败,请检查配置") - continue - - self._pattern_token[index] = { - "appid": appid, - "corpid": corpid, - "appsecret": appsecret, - "access_token": access_token, - "expires_in": expires_in, - "access_token_time": access_token_time, - } - - def __flush_access_token(self, index: int, force: bool = False): - """ - 获取第i个配置wechat token - """ - wechat_token = self._pattern_token[index] - if not wechat_token: - logger.error(f"未获取到第 {index} 条正则对应的wechat应用token,请检查配置") - return None - access_token = wechat_token['access_token'] - expires_in = wechat_token['expires_in'] - access_token_time = wechat_token['access_token_time'] - appid = wechat_token['appid'] - corpid = wechat_token['corpid'] - appsecret = wechat_token['appsecret'] - - # 判断token有效期 - if force or (datetime.now() - access_token_time).seconds >= expires_in: - # 重新获取token - access_token, expires_in, access_token_time = self.__get_access_token(corpid=corpid, - appsecret=appsecret) - if not access_token: - logger.error(f"wechat配置 appid = {appid} 获取token失败,请检查配置") - return None, None - - self._pattern_token[index] = { - "appid": appid, - "corpid": corpid, - "appsecret": appsecret, - "access_token": access_token, - "expires_in": expires_in, - "access_token_time": access_token_time, - } - return access_token, appid - - def __send_message(self, title: str, text: str = None, userid: str = None, access_token: str = None, - appid: str = None, index: int = None) -> Optional[bool]: - """ - 发送文本消息 - :param title: 消息标题 - :param text: 消息内容 - :param userid: 消息发送对象的ID,为空则发给所有人 - :return: 发送状态,错误信息 - """ - if text: - conent = "%s\n%s" % (title, text.replace("\n\n", "\n")) - else: - conent = title - - if not userid: - userid = "@all" - req_json = { - "touser": userid, - "msgtype": "text", - "agentid": appid, - "text": { - "content": conent - }, - "safe": 0, - "enable_id_trans": 0, - "enable_duplicate_check": 0 - } - return self.__post_request(access_token=access_token, req_json=req_json, index=index, title=title) - - def __send_image_message(self, title: str, text: str, image_url: str, userid: str = None, - access_token: str = None, appid: str = None, index: int = None) -> Optional[bool]: - """ - 发送图文消息 - :param title: 消息标题 - :param text: 消息内容 - :param image_url: 图片地址 - :param userid: 消息发送对象的ID,为空则发给所有人 - :return: 发送状态,错误信息 - """ - if text: - text = text.replace("\n\n", "\n") - if not userid: - userid = "@all" - req_json = { - "touser": userid, - "msgtype": "news", - "agentid": appid, - "news": { - "articles": [ - { - "title": title, - "description": text, - "picurl": image_url, - "url": '' - } - ] - } - } - return self.__post_request(access_token=access_token, req_json=req_json, index=index, title=title) - - def __post_request(self, access_token: str, req_json: dict, index: int, title: str, retry: int = 0) -> bool: - message_url = self._send_msg_url % access_token - """ - 向微信发送请求 - """ - try: - res = RequestUtils(content_type='application/json').post( - message_url, - data=json.dumps(req_json, ensure_ascii=False).encode('utf-8') - ) - if res and res.status_code == 200: - ret_json = res.json() - if ret_json.get('errcode') == 0: - logger.info(f"转发消息 {title} 成功") - return True - else: - if ret_json.get('errcode') == 81013: - return False - - logger.error(f"转发消息 {title} 失败,错误信息:{ret_json}") - if ret_json.get('errcode') == 42001 or ret_json.get('errcode') == 40014: - logger.info("token已过期,正在重新刷新token重试") - # 重新获取token - access_token, appid = self.__flush_access_token(index=index, - force=True) - if access_token: - retry += 1 - # 重发请求 - if retry <= 3: - return self.__post_request(access_token=access_token, - req_json=req_json, - index=index, - title=title, - retry=retry) - return False - elif res is not None: - logger.error(f"转发消息 {title} 失败,错误码:{res.status_code},错误原因:{res.reason}") - return False - else: - logger.error(f"转发消息 {title} 失败,未获取到返回信息") - return False - except Exception as err: - logger.error(f"转发消息 {title} 异常,错误信息:{str(err)}") - return False - - def __get_access_token(self, corpid: str, appsecret: str): - """ - 获取微信Token - :return: 微信Token - """ - try: - token_url = self._token_url % (corpid, appsecret) - res = RequestUtils().get_res(token_url) - if res: - ret_json = res.json() - if ret_json.get('errcode') == 0: - access_token = ret_json.get('access_token') - expires_in = ret_json.get('expires_in') - access_token_time = datetime.now() - - return access_token, expires_in, access_token_time - else: - logger.error(f"{ret_json.get('errmsg')}") - return None, None, None - else: - logger.error(f"{corpid} {appsecret} 获取token失败") - return None, None, None - except Exception as e: - logger.error(f"获取微信access_token失败,错误信息:{str(e)}") - return None, None, None - - def stop_service(self): - """ - 退出插件 - """ - pass diff --git a/plugins.v2/qbcommand/__init__.py b/plugins.v2/qbcommand/__init__.py deleted file mode 100644 index 78f6916..0000000 --- a/plugins.v2/qbcommand/__init__.py +++ /dev/null @@ -1,1171 +0,0 @@ -from typing import List, Tuple, Dict, Any -from enum import Enum -from urllib.parse import urlparse -import urllib -from app.log import logger -from app.modules.qbittorrent import Qbittorrent -from app.plugins import _PluginBase -from app.schemas import NotificationType -from app.schemas.types import EventType -from apscheduler.triggers.cron import CronTrigger -from app.core.event import eventmanager, Event -from apscheduler.schedulers.background import BackgroundScheduler -from app.core.config import settings -from app.helper.sites import SitesHelper -from app.db.site_oper import SiteOper -from app.utils.string import StringUtils -from datetime import datetime, timedelta -import pytz -import time - - -class QbCommand(_PluginBase): - # 插件名称 - plugin_name = "QB远程操作" - # 插件描述 - plugin_desc = "通过定时任务或交互命令远程操作QB暂停/开始/限速等" - # 插件图标 - plugin_icon = "Qbittorrent_A.png" - # 插件版本 - plugin_version = "1.5" - # 插件作者 - plugin_author = "DzAvril" - # 作者主页 - author_url = "https://github.com/DzAvril" - # 插件配置项ID前缀 - plugin_config_prefix = "qbcommand_" - # 加载顺序 - plugin_order = 1 - # 可使用的用户级别 - auth_level = 1 - - # 私有属性 - _sites = None - _siteoper = None - _qb = None - _enabled: bool = False - _notify: bool = False - _pause_cron = None - _resume_cron = None - _only_pause_once = False - _only_resume_once = False - _only_pause_upload = False - _only_pause_download = False - _only_pause_checking = False - _upload_limit = 0 - _enable_upload_limit = False - _download_limit = 0 - _enable_download_limit = False - _op_site_ids = [] - _op_sites = [] - _multi_level_root_domain = ["edu.cn", "com.cn", "net.cn", "org.cn"] - _scheduler = None - _exclude_dirs = "" - def init_plugin(self, config: dict = None): - self._sites = SitesHelper() - self._siteoper = SiteOper() - # 停止现有任务 - self.stop_service() - # 读取配置 - if config: - self._enabled = config.get("enabled") - self._notify = config.get("notify") - self._pause_cron = config.get("pause_cron") - self._resume_cron = config.get("resume_cron") - self._only_pause_once = config.get("onlypauseonce") - self._only_resume_once = config.get("onlyresumeonce") - self._only_pause_upload = config.get("onlypauseupload") - self._only_pause_download = config.get("onlypausedownload") - self._only_pause_checking = config.get("onlypausechecking") - self._download_limit = config.get("download_limit") - self._upload_limit = config.get("upload_limit") - self._enable_download_limit = config.get("enable_download_limit") - self._enable_upload_limit = config.get("enable_upload_limit") - self._qb = Qbittorrent() - self._op_site_ids = config.get("op_site_ids") or [] - # 查询所有站点 - all_sites = [site for site in self._sites.get_indexers() if not site.get("public")] + self.__custom_sites() - # 过滤掉没有选中的站点 - self._op_sites = [site for site in all_sites if site.get("id") in self._op_site_ids] - self._exclude_dirs = config.get("exclude_dirs") or "" - - if self._only_pause_once or self._only_resume_once: - if self._only_pause_once and self._only_resume_once: - logger.warning("只能选择一个: 立即暂停或立即开始所有任务") - elif self._only_pause_once: - self._scheduler = BackgroundScheduler(timezone=settings.TZ) - logger.info(f"立即运行一次暂停所有任务") - self._scheduler.add_job( - self.pause_torrent, - "date", - run_date=datetime.now(tz=pytz.timezone(settings.TZ)) - + timedelta(seconds=3), - ) - elif self._only_resume_once: - self._scheduler = BackgroundScheduler(timezone=settings.TZ) - logger.info(f"立即运行一次开始所有任务") - self._scheduler.add_job( - self.resume_torrent, - "date", - run_date=datetime.now(tz=pytz.timezone(settings.TZ)) - + timedelta(seconds=3), - ) - - self._only_resume_once = False - self._only_pause_once = False - self.update_config( - { - "onlypauseonce": False, - "onlyresumeonce": False, - "enabled": self._enabled, - "notify": self._notify, - "pause_cron": self._pause_cron, - "resume_cron": self._resume_cron, - "op_site_ids": self._op_site_ids, - "exclude_dirs": self._exclude_dirs, - } - ) - - # 启动任务 - if self._scheduler.get_jobs(): - self._scheduler.print_jobs() - self._scheduler.start() - - if ( - self._only_pause_upload - or self._only_pause_download - or self._only_pause_checking - ): - if self._only_pause_upload: - self._scheduler = BackgroundScheduler(timezone=settings.TZ) - logger.info(f"立即运行一次暂停所有上传任务") - self._scheduler.add_job( - self.pause_torrent, - "date", - run_date=datetime.now(tz=pytz.timezone(settings.TZ)) - + timedelta(seconds=3), - kwargs={ - 'type': self.TorrentType.UPLOADING - } - ) - if self._only_pause_download: - self._scheduler = BackgroundScheduler(timezone=settings.TZ) - logger.info(f"立即运行一次暂停所有下载任务") - self._scheduler.add_job( - self.pause_torrent, - "date", - run_date=datetime.now(tz=pytz.timezone(settings.TZ)) - + timedelta(seconds=3), - kwargs={ - 'type': self.TorrentType.DOWNLOADING - } - ) - if self._only_pause_checking: - self._scheduler = BackgroundScheduler(timezone=settings.TZ) - logger.info(f"立即运行一次暂停所有检查任务") - self._scheduler.add_job( - self.pause_torrent, - "date", - run_date=datetime.now(tz=pytz.timezone(settings.TZ)) - + timedelta(seconds=3), - kwargs={ - 'type': self.TorrentType.CHECKING - } - ) - - self._only_pause_upload = False - self._only_pause_download = False - self._only_pause_checking = False - self.update_config( - { - "onlypauseupload": False, - "onlypausedownload": False, - "onlypausechecking": False, - "enabled": self._enabled, - "notify": self._notify, - "pause_cron": self._pause_cron, - "resume_cron": self._resume_cron, - "op_site_ids": self._op_site_ids, - } - ) - - # 启动任务 - if self._scheduler.get_jobs(): - self._scheduler.print_jobs() - self._scheduler.start() - - self.set_limit(self._upload_limit, self._download_limit) - - def get_state(self) -> bool: - return self._enabled - - class TorrentType(Enum): - ALL = 1 - DOWNLOADING = 2 - UPLOADING = 3 - CHECKING = 4 - - @staticmethod - def get_command() -> List[Dict[str, Any]]: - """ - 定义远程控制命令 - :return: 命令关键字、事件、描述、附带数据 - """ - return [ - { - "cmd": "/pause_torrents", - "event": EventType.PluginAction, - "desc": "暂停QB所有任务", - "category": "QB", - "data": {"action": "pause_torrents"}, - }, - { - "cmd": "/pause_upload_torrents", - "event": EventType.PluginAction, - "desc": "暂停QB上传任务", - "category": "QB", - "data": {"action": "pause_upload_torrents"}, - }, - { - "cmd": "/pause_download_torrents", - "event": EventType.PluginAction, - "desc": "暂停QB下载任务", - "category": "QB", - "data": {"action": "pause_download_torrents"}, - }, - { - "cmd": "/pause_checking_torrents", - "event": EventType.PluginAction, - "desc": "暂停QB检查任务", - "category": "QB", - "data": {"action": "pause_checking_torrents"}, - }, - { - "cmd": "/resume_torrents", - "event": EventType.PluginAction, - "desc": "开始QB所有任务", - "category": "QB", - "data": {"action": "resume_torrents"}, - }, - { - "cmd": "/qb_status", - "event": EventType.PluginAction, - "desc": "QB当前任务状态", - "category": "QB", - "data": {"action": "qb_status"}, - }, - { - "cmd": "/toggle_upload_limit", - "event": EventType.PluginAction, - "desc": "QB切换上传限速状态", - "category": "QB", - "data": {"action": "toggle_upload_limit"}, - }, - { - "cmd": "/toggle_download_limit", - "event": EventType.PluginAction, - "desc": "QB切换下载限速状态", - "category": "QB", - "data": {"action": "toggle_download_limit"}, - }, - ] - - def __custom_sites(self) -> List[Any]: - custom_sites = [] - custom_sites_config = self.get_config("CustomSites") - if custom_sites_config and custom_sites_config.get("enabled"): - custom_sites = custom_sites_config.get("sites") - return custom_sites - - 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 and self._pause_cron and self._resume_cron: - return [ - { - "id": "QbPause", - "name": "暂停QB所有任务", - "trigger": CronTrigger.from_crontab(self._pause_cron), - "func": self.pause_torrent, - "kwargs": {}, - }, - { - "id": "QbResume", - "name": "开始QB所有任务", - "trigger": CronTrigger.from_crontab(self._resume_cron), - "func": self.resume_torrent, - "kwargs": {}, - }, - ] - if self._enabled and self._pause_cron: - return [ - { - "id": "QbPause", - "name": "暂停QB所有任务", - "trigger": CronTrigger.from_crontab(self._pause_cron), - "func": self.pause_torrent, - "kwargs": {}, - } - ] - if self._enabled and self._resume_cron: - return [ - { - "id": "QbResume", - "name": "开始QB所有任务", - "trigger": CronTrigger.from_crontab(self._resume_cron), - "func": self.resume_torrent, - "kwargs": {}, - } - ] - return [] - - def get_all_torrents(self): - all_torrents, error = self._qb.get_torrents() - if error: - logger.error(f"获取QB种子失败: {error}") - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【QB远程操作】", - text=f"获取QB种子失败,请检查QB配置", - ) - return [] - - if not all_torrents: - logger.warning("QB没有种子") - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【QB远程操作】", - text=f"QB中没有种子", - ) - return [] - return all_torrents - - @staticmethod - def get_torrents_status(torrents): - downloading_torrents = [] - uploading_torrents = [] - paused_torrents = [] - checking_torrents = [] - error_torrents = [] - for torrent in torrents: - if torrent.state_enum.is_uploading and not torrent.state_enum.is_paused: - uploading_torrents.append(torrent.get("hash")) - elif ( - torrent.state_enum.is_downloading - and not torrent.state_enum.is_paused - and not torrent.state_enum.is_checking - ): - downloading_torrents.append(torrent.get("hash")) - elif torrent.state_enum.is_checking: - checking_torrents.append(torrent.get("hash")) - elif torrent.state_enum.is_paused: - paused_torrents.append(torrent.get("hash")) - elif torrent.state_enum.is_errored: - error_torrents.append(torrent.get("hash")) - - return ( - downloading_torrents, - uploading_torrents, - paused_torrents, - checking_torrents, - error_torrents, - ) - - @eventmanager.register(EventType.PluginAction) - def handle_pause_torrent(self, event: Event): - if not self._enabled: - return - if event: - event_data = event.event_data - if not event_data or event_data.get("action") != "pause_torrents": - return - self.pause_torrent() - - @eventmanager.register(EventType.PluginAction) - def handle_pause_upload_torrent(self, event: Event): - if not self._enabled: - return - if event: - event_data = event.event_data - if not event_data or event_data.get("action") != "pause_upload_torrents": - return - self.pause_torrent(self.TorrentType.UPLOADING) - - @eventmanager.register(EventType.PluginAction) - def handle_pause_download_torrent(self, event: Event): - if not self._enabled: - return - if event: - event_data = event.event_data - if not event_data or event_data.get("action") != "pause_download_torrents": - return - self.pause_torrent(self.TorrentType.DOWNLOADING) - - @eventmanager.register(EventType.PluginAction) - def handle_pause_checking_torrent(self, event: Event): - if not self._enabled: - return - if event: - event_data = event.event_data - if not event_data or event_data.get("action") != "pause_checking_torrents": - return - self.pause_torrent(self.TorrentType.CHECKING) - - def pause_torrent(self, type: TorrentType = TorrentType.ALL): - if not self._enabled: - return - - all_torrents = self.get_all_torrents() - hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( - self.get_torrents_status(all_torrents) - ) - - logger.info( - f"暂定任务启动 \n" - f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n" - f"暂停操作中请稍等...\n", - ) - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【QB暂停任务启动】", - text=f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n" - f"暂停操作中请稍等...\n", - ) - pause_torrents = self.filter_pause_torrents(all_torrents) - hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( - self.get_torrents_status(pause_torrents) - ) - if type == self.TorrentType.DOWNLOADING: - to_be_paused = hash_downloading - elif type == self.TorrentType.UPLOADING: - to_be_paused = hash_uploading - elif type == self.TorrentType.CHECKING: - to_be_paused = hash_checking - else: - to_be_paused = hash_downloading + hash_uploading + hash_checking - - if len(to_be_paused) > 0: - if self._qb.stop_torrents(ids=to_be_paused): - logger.info(f"暂停了{len(to_be_paused)}个种子") - else: - logger.error(f"暂停种子失败") - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【QB远程操作】", - text=f"暂停种子失败", - ) - # 每个种子等待1ms以让状态切换成功,至少等待1S - wait_time = 0.001 * len(to_be_paused) + 1 - time.sleep(wait_time) - - all_torrents = self.get_all_torrents() - hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( - self.get_torrents_status(all_torrents) - ) - logger.info( - f"暂定任务完成 \n" - f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n" - ) - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【QB暂停任务完成】", - text=f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n", - ) - - def __is_excluded(self, file_path) -> bool: - """ - 是否排除目录 - """ - for exclude_dir in self._exclude_dirs.split("\n"): - if exclude_dir and exclude_dir in str(file_path): - return True - return False - def filter_pause_torrents(self, all_torrents): - torrents = [] - for torrent in all_torrents: - if self.__is_excluded(torrent.get("content_path")): - continue - torrents.append(torrent) - return torrents - - @eventmanager.register(EventType.PluginAction) - def handle_resume_torrent(self, event: Event): - if not self._enabled: - return - if event: - event_data = event.event_data - if not event_data or event_data.get("action") != "resume_torrents": - return - self.resume_torrent() - - def resume_torrent(self): - if not self._enabled: - return - - all_torrents = self.get_all_torrents() - hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( - self.get_torrents_status(all_torrents) - ) - logger.info( - f"QB开始任务启动 \n" - f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n" - f"开始操作中请稍等...\n", - ) - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【QB开始任务启动】", - text=f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n" - f"开始操作中请稍等...\n", - ) - - resume_torrents = self.filter_resume_torrents(all_torrents) - hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( - self.get_torrents_status(resume_torrents) - ) - if not self._qb.start_torrents(ids=hash_paused): - logger.error(f"开始种子失败") - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【QB远程操作】", - text=f"开始种子失败", - ) - # 每个种子等待1ms以让状态切换成功,至少等待1S - wait_time = 0.001 * len(hash_paused) + 1 - time.sleep(wait_time) - - all_torrents = self.get_all_torrents() - hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( - self.get_torrents_status(all_torrents) - ) - logger.info( - f"开始任务完成 \n" - f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n" - ) - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【QB开始任务完成】", - text=f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n", - ) - - def filter_resume_torrents(self, all_torrents): - """ - 过滤掉不参与保种的种子 - """ - if len(self._op_sites) == 0: - return all_torrents - - urls = [site.get("url") for site in self._op_sites] - op_sites_main_domains = [] - for url in urls: - domain = StringUtils.get_url_netloc(url) - main_domain = self.get_main_domain(domain[1]) - op_sites_main_domains.append(main_domain) - - torrents = [] - for torrent in all_torrents: - if torrent.get("state") == "pausedUP": - tracker_url = self.get_torrent_tracker(torrent) - if not tracker_url: - logger.info(f"获取种子 {torrent.name} Tracker失败,不过滤该种子") - torrents.append(torrent) - _, tracker_domain = StringUtils.get_url_netloc(tracker_url) - if not tracker_domain: - logger.info(f"获取种子 {torrent.name} Tracker失败,不过滤该种子") - torrents.append(torrent) - tracker_main_domain = self.get_main_domain(domain=tracker_domain) - if tracker_main_domain in op_sites_main_domains: - logger.info( - f"种子 {torrent.name} 属于站点{tracker_main_domain},不执行操作" - ) - continue - - torrents.append(torrent) - return torrents - - @eventmanager.register(EventType.PluginAction) - def handle_qb_status(self, event: Event): - if not self._enabled: - return - if event: - event_data = event.event_data - if not event_data or event_data.get("action") != "qb_status": - return - self.qb_status() - - def qb_status(self): - if not self._enabled: - return - - all_torrents = self.get_all_torrents() - hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( - self.get_torrents_status(all_torrents) - ) - logger.info( - f"QB任务状态 \n" - f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n" - ) - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【QB任务状态】", - text=f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n" - ) - - @eventmanager.register(EventType.PluginAction) - def handle_toggle_upload_limit(self, event: Event): - if not self._enabled: - return - if event: - event_data = event.event_data - if not event_data or event_data.get("action") != "toggle_upload_limit": - return - self.set_limit(self._upload_limit, self._download_limit) - - @eventmanager.register(EventType.PluginAction) - def handle_toggle_download_limit(self, event: Event): - if not self._enabled: - return - if event: - event_data = event.event_data - if not event_data or event_data.get("action") != "toggle_download_limit": - return - self.set_limit(self._upload_limit, self._download_limit) - - def set_both_limit(self, upload_limit, download_limit): - if not self._enable_upload_limit or not self._enable_upload_limit: - return True - - if ( - not upload_limit - or not upload_limit.isdigit() - or not download_limit - or not download_limit.isdigit() - ): - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【QB远程操作】", - text=f"设置QB限速失败,download_limit或upload_limit不是一个数值", - ) - return False - - return self._qb.set_speed_limit( - download_limit=int(download_limit), upload_limit=int(upload_limit) - ) - - def set_upload_limit(self, upload_limit): - if not self._enable_upload_limit: - return True - - if not upload_limit or not upload_limit.isdigit(): - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【QB远程操作】", - text=f"设置QB限速失败,upload_limit不是一个数值", - ) - return False - - download_limit_current_val, _ = self._qb.get_speed_limit() - return self._qb.set_speed_limit( - download_limit=int(download_limit_current_val), - upload_limit=int(upload_limit), - ) - - def set_download_limit(self, download_limit): - if not self._enable_download_limit: - return True - - if not download_limit or not download_limit.isdigit(): - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【QB远程操作】", - text=f"设置QB限速失败,download_limit不是一个数值", - ) - return False - - _, upload_limit_current_val = self._qb.get_speed_limit() - return self._qb.set_speed_limit( - download_limit=int(download_limit), - upload_limit=int(upload_limit_current_val), - ) - - def set_limit(self, upload_limit, download_limit): - # 限速,满足以下三种情况设置限速 - # 1. 插件启用 && download_limit启用 - # 2. 插件启用 && upload_limit启用 - # 3. 插件启用 && download_limit启用 && upload_limit启用 - - flag = None - if self._enabled and self._enable_download_limit and self._enable_upload_limit: - flag = self.set_both_limit(upload_limit, download_limit) - - elif flag is None and self._enabled and self._enable_download_limit: - flag = self.set_download_limit(download_limit) - - elif flag is None and self._enabled and self._enable_upload_limit: - flag = self.set_upload_limit(upload_limit) - - if flag == True: - logger.info(f"设置QB限速成功") - if self._notify: - if upload_limit == 0: - text = f"上传无限速" - else: - text = f"上传限速:{upload_limit} KB/s" - if download_limit == 0: - text += f"\n下载无限速" - else: - text += f"\n下载限速:{download_limit} KB/s" - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【QB远程操作】", - text=text, - ) - elif flag == False: - logger.error(f"QB设置限速失败") - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【QB远程操作】", - text=f"设置QB限速失败", - ) - - def get_torrent_tracker(self, torrent): - """ - qb解析 tracker - :return: tracker url - """ - if not torrent: - return None - tracker = torrent.get("tracker") - if tracker and len(tracker) > 0: - return tracker - magnet_uri = torrent.get("magnet_uri") - if not magnet_uri or len(magnet_uri) <= 0: - return None - magnet_uri_obj = urlparse(magnet_uri) - query = urllib.parse.parse_qs(magnet_uri_obj.query) - tr = query["tr"] - if not tr or len(tr) <= 0: - return None - return tr[0] - - def get_main_domain(self, domain): - """ - 获取域名的主域名 - :param domain: 原域名 - :return: 主域名 - """ - if not domain: - return None - domain_arr = domain.split(".") - 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]}" - - def match_multi_level_root_domain(self, domain): - """ - 匹配多级根域名 - :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 get_form(self) -> Tuple[List[dict], Dict[str, Any]]: - customSites = self.__custom_sites() - - site_options = [ - {"title": site.name, "value": site.id} - for site in self._siteoper.list_order_by_pri() - ] + [ - {"title": site.get("name"), "value": site.get("id")} for site in customSites - ] - return [ - { - "component": "VForm", - "content": [ - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "enabled", - "label": "启用插件", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "notify", - "label": "发送通知", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "onlypauseonce", - "label": "立即暂停所有任务", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "onlyresumeonce", - "label": "立即开始所有任务", - }, - } - ], - }, - ], - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VTextField", - "props": { - "model": "pause_cron", - "label": "暂停周期", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VTextField", - "props": { - "model": "resume_cron", - "label": "开始周期", - }, - } - ], - }, - ], - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "enable_upload_limit", - "label": "上传限速", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "enable_download_limit", - "label": "下载限速", - }, - } - ], - }, - ], - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VTextField", - "props": { - "model": "upload_limit", - "label": "上传限速 KB/s", - "placeholder": "KB/s", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VTextField", - "props": { - "model": "download_limit", - "label": "下载限速 KB/s", - "placeholder": "KB/s", - }, - } - ], - }, - ], - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "onlypauseupload", - "label": "暂停上传任务", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "onlypausedownload", - "label": "暂停下载任务", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "onlypausechecking", - "label": "暂停检查任务", - }, - } - ], - }, - ], - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": {"cols": 12}, - "content": [ - { - "component": "VSelect", - "props": { - "chips": True, - "multiple": True, - "model": "op_site_ids", - "label": "停止保种站点(暂停保种后不会被恢复)", - "items": site_options, - }, - } - ], - } - ], - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": {"cols": 12}, - "content": [ - { - "component": "VTextarea", - "props": { - "model": "exclude_dirs", - "label": "不暂停保种目录", - "rows": 5, - "placeholder": "该目录下的做种不会暂停,一行一个目录", - }, - } - ], - } - ], - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": { - "cols": 12, - }, - "content": [ - { - "component": "VAlert", - "props": { - "type": "info", - "variant": "tonal", - "text": "开始周期和暂停周期使用Cron表达式,如:0 0 0 * *,仅针对开始/暂定全部任务", - }, - } - ], - }, - { - "component": "VCol", - "props": { - "cols": 12, - }, - "content": [ - { - "component": "VAlert", - "props": { - "type": "info", - "variant": "tonal", - "text": "交互命令有暂停QB种子、开始QB种子、QB切换上传限速状态、QB切换下载限速状态", - }, - } - ], - }, - ], - }, - ], - } - ], { - "enabled": False, - "notify": True, - "onlypauseonce": False, - "onlyresumeonce": False, - "onlypauseupload": False, - "onlypausedownload": False, - "onlypausechecking": False, - "upload_limit": 0, - "download_limit": 0, - "enable_upload_limit": False, - "enable_download_limit": False, - "op_site_ids": [], - } - - def get_page(self) -> List[dict]: - pass - - def stop_service(self): - """ - 退出插件 - """ - try: - if self._scheduler: - self._scheduler.remove_all_jobs() - if self._scheduler.running: - self._scheduler.shutdown() - self._scheduler = None - except Exception as e: - logger.error("退出插件失败:%s" % str(e)) diff --git a/plugins.v2/syncdownloadfiles/__init__.py b/plugins.v2/syncdownloadfiles/__init__.py deleted file mode 100644 index 15c8a42..0000000 --- a/plugins.v2/syncdownloadfiles/__init__.py +++ /dev/null @@ -1,579 +0,0 @@ -import time -from datetime import datetime -from pathlib import Path -from typing import Any, List, Dict, Tuple, Optional - -from apscheduler.schedulers.background import BackgroundScheduler - -from app.core.config import settings -from app.db.downloadhistory_oper import DownloadHistoryOper -from app.db.transferhistory_oper import TransferHistoryOper -from app.log import logger -from app.modules.qbittorrent import Qbittorrent -from app.modules.transmission import Transmission -from app.plugins import _PluginBase - - -class SyncDownloadFiles(_PluginBase): - # 插件名称 - plugin_name = "下载器文件同步" - # 插件描述 - plugin_desc = "同步下载器的文件信息到数据库,删除文件时联动删除下载任务。" - # 插件图标 - plugin_icon = "Youtube-dl_A.png" - # 插件版本 - plugin_version = "1.1.1" - # 插件作者 - plugin_author = "thsrite" - # 作者主页 - author_url = "https://github.com/thsrite" - # 插件配置项ID前缀 - plugin_config_prefix = "syncdownloadfiles_" - # 加载顺序 - plugin_order = 20 - # 可使用的用户级别 - auth_level = 1 - - # 私有属性 - _enabled = False - # 任务执行间隔 - _time = None - qb = None - tr = None - _onlyonce = False - _history = False - _clear = False - _downloaders = [] - _dirs = None - downloadhis = None - transferhis = None - - # 定时器 - _scheduler: Optional[BackgroundScheduler] = None - - def init_plugin(self, config: dict = None): - # 停止现有任务 - self.stop_service() - - self.qb = Qbittorrent() - self.tr = Transmission() - self.downloadhis = DownloadHistoryOper() - self.transferhis = TransferHistoryOper() - - if config: - self._enabled = config.get('enabled') - self._time = config.get('time') or 6 - self._history = config.get('history') - self._clear = config.get('clear') - self._onlyonce = config.get("onlyonce") - self._downloaders = config.get('downloaders') or [] - self._dirs = config.get("dirs") or "" - - if self._clear: - # 清理下载器文件记录 - self.downloadhis.truncate_files() - # 清理下载器最后处理记录 - for downloader in self._downloaders: - # 获取最后同步时间 - self.del_data(f"last_sync_time_{downloader}") - # 关闭clear - self._clear = False - self.__update_config() - - if self._onlyonce: - # 执行一次 - # 关闭onlyonce - self._onlyonce = False - self.__update_config() - - self.sync() - - def sync(self): - """ - 同步所选下载器种子记录 - """ - start_time = datetime.now() - logger.info("开始同步下载器任务文件记录") - - if not self._downloaders: - logger.error("未选择同步下载器,停止运行") - return - - # 遍历下载器同步记录 - for downloader in self._downloaders: - # 获取最后同步时间 - last_sync_time = self.get_data(f"last_sync_time_{downloader}") - - logger.info(f"开始扫描下载器 {downloader} ...") - downloader_obj = self.__get_downloader(downloader) - # 获取下载器中已完成的种子 - torrents = downloader_obj.get_completed_torrents() - if torrents: - logger.info(f"下载器 {downloader} 已完成种子数:{len(torrents)}") - else: - logger.info(f"下载器 {downloader} 没有已完成种子") - continue - - # 把种子按照名称和种子大小分组,获取添加时间最早的一个,认定为是源种子,其余为辅种 - torrents = self.__get_origin_torrents(torrents, downloader) - logger.info(f"下载器 {downloader} 去除辅种,获取到源种子数:{len(torrents)}") - - for torrent in torrents: - # 返回false,标识后续种子已被同步 - sync_flag = self.__compare_time(torrent, downloader, last_sync_time) - - if not sync_flag: - logger.info(f"最后同步时间{last_sync_time}, 之前种子已被同步,结束当前下载器 {downloader} 任务") - break - - # 获取种子hash - hash_str = self.__get_hash(torrent, downloader) - - # 判断是否是mp下载,判断download_hash是否在downloadhistory表中,是则不处理 - downloadhis = self.downloadhis.get_by_hash(hash_str) - if downloadhis: - downlod_files = self.downloadhis.get_files_by_hash(hash_str) - if downlod_files: - logger.info(f"种子 {hash_str} 通过MoviePilot下载,跳过处理") - continue - - # 获取种子download_dir - download_dir = self.__get_download_dir(torrent, downloader) - - # 处理路径映射 - if self._dirs: - paths = self._dirs.split("\n") - for path in paths: - sub_paths = path.split(":") - download_dir = download_dir.replace(sub_paths[0], sub_paths[1]).replace('\\', '/') - - # 获取种子name - torrent_name = self.__get_torrent_name(torrent, downloader) - # 种子保存目录 - save_path = Path(download_dir).joinpath(torrent_name) - # 获取种子文件 - torrent_files = self.__get_torrent_files(torrent, downloader, downloader_obj) - logger.info(f"开始同步种子 {hash_str}, 文件数 {len(torrent_files)}") - - download_files = [] - for file in torrent_files: - # 过滤掉没下载的文件 - if not self.__is_download(file, downloader): - continue - # 种子文件路径 - file_path_str = self.__get_file_path(file, downloader) - file_path = Path(file_path_str) - # 只处理视频格式 - if not file_path.suffix \ - or file_path.suffix not in settings.RMT_MEDIAEXT: - continue - # 种子文件根路程 - root_path = file_path.parts[0] - # 不含种子名称的种子文件相对路径 - if root_path == torrent_name: - rel_path = str(file_path.relative_to(root_path)) - else: - rel_path = str(file_path) - # 完整路径 - full_path = save_path.joinpath(rel_path) - if self._history: - transferhis = self.transferhis.get_by_src(str(full_path)) - if transferhis and not transferhis.download_hash: - logger.info(f"开始补充转移记录:{transferhis.id} download_hash {hash_str}") - self.transferhis.update_download_hash(historyid=transferhis.id, - download_hash=hash_str) - - # 种子文件记录 - download_files.append( - { - "download_hash": hash_str, - "downloader": downloader, - "fullpath": str(full_path), - "savepath": str(save_path), - "filepath": rel_path, - "torrentname": torrent_name, - } - ) - - if download_files: - # 登记下载文件 - self.downloadhis.add_files(download_files) - logger.info(f"种子 {hash_str} 同步完成") - - logger.info(f"下载器种子文件同步完成!") - self.save_data(f"last_sync_time_{downloader}", - time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))) - - # 计算耗时 - end_time = datetime.now() - - logger.info(f"下载器任务文件记录已同步完成。总耗时 {(end_time - start_time).seconds} 秒") - - def __update_config(self): - self.update_config({ - "enabled": self._enabled, - "time": self._time, - "history": self._history, - "clear": self._clear, - "onlyonce": self._onlyonce, - "downloaders": self._downloaders, - "dirs": self._dirs - }) - - @staticmethod - def __get_origin_torrents(torrents: Any, dl_tpe: str): - # 把种子按照名称和种子大小分组,获取添加时间最早的一个,认定为是源种子,其余为辅种 - grouped_data = {} - - # 排序种子,根据种子添加时间倒序 - if dl_tpe == "qbittorrent": - torrents = sorted(torrents, key=lambda x: x.get("added_on"), reverse=True) - # 遍历原始数组,按照size和name进行分组 - for torrent in torrents: - size = torrent.get('size') - name = torrent.get('name') - key = (size, name) # 使用元组作为字典的键 - - # 如果分组键不存在,则将当前元素作为最小元素添加到字典中 - if key not in grouped_data: - grouped_data[key] = torrent - else: - # 如果分组键已存在,则比较当前元素的time是否更小,如果更小则更新字典中的元素 - if torrent.get('added_on') < grouped_data[key].get('added_on'): - grouped_data[key] = torrent - else: - torrents = sorted(torrents, key=lambda x: x.added_date, reverse=True) - # 遍历原始数组,按照size和name进行分组 - for torrent in torrents: - size = torrent.total_size - name = torrent.name - key = (size, name) # 使用元组作为字典的键 - - # 如果分组键不存在,则将当前元素作为最小元素添加到字典中 - if key not in grouped_data: - grouped_data[key] = torrent - else: - # 如果分组键已存在,则比较当前元素的time是否更小,如果更小则更新字典中的元素 - if torrent.added_date < grouped_data[key].added_date: - grouped_data[key] = torrent - - # 新的数组 - return list(grouped_data.values()) - - @staticmethod - def __compare_time(torrent: Any, dl_tpe: str, last_sync_time: str = None): - if last_sync_time: - # 获取种子时间 - if dl_tpe == "qbittorrent": - torrent_date = time.localtime(torrent.get("added_on")) # 将时间戳转换为时间元组 - torrent_date = time.strftime("%Y-%m-%d %H:%M:%S", torrent_date) # 格式化时间 - else: - torrent_date = torrent.added_date - - # 之后的种子已经同步了 - if last_sync_time > str(torrent_date): - return False - - return True - - @staticmethod - def __is_download(file: Any, dl_type: str): - """ - 判断文件是否被下载 - """ - try: - if dl_type == "qbittorrent": - return True - else: - return file.completed and file.completed > 0 - except Exception as e: - print(str(e)) - return True - - @staticmethod - def __get_file_path(file: Any, dl_type: str): - """ - 获取文件路径 - """ - try: - return file.get("name") if dl_type == "qbittorrent" else file.name - except Exception as e: - print(str(e)) - return "" - - @staticmethod - def __get_torrent_files(torrent: Any, dl_type: str, downloader_obj): - """ - 获取种子文件 - """ - try: - return torrent.files if dl_type == "qbittorrent" else downloader_obj.get_files(tid=torrent.id) - except Exception as e: - print(str(e)) - return "" - - @staticmethod - def __get_torrent_name(torrent: Any, dl_type: str): - """ - 获取种子name - """ - try: - return torrent.get("name") if dl_type == "qbittorrent" else torrent.name - except Exception as e: - print(str(e)) - return "" - - @staticmethod - def __get_download_dir(torrent: Any, dl_type: str): - """ - 获取种子download_dir - """ - try: - return torrent.get("save_path") if dl_type == "qbittorrent" else torrent.download_dir - except Exception as e: - print(str(e)) - return "" - - @staticmethod - def __get_hash(torrent: Any, dl_type: str): - """ - 获取种子hash - """ - try: - return torrent.get("hash") if dl_type == "qbittorrent" else torrent.hashString - except Exception as e: - print(str(e)) - return "" - - def __get_downloader(self, dtype: str): - """ - 根据类型返回下载器实例 - """ - if dtype == "qbittorrent": - return self.qb - elif dtype == "transmission": - return self.tr - else: - return None - - def get_state(self) -> bool: - return True if self._enabled and self._time else False - - @staticmethod - def get_command() -> List[Dict[str, Any]]: - pass - - 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.get_state(): - return [{ - "id": "SyncDownloadFiles", - "name": "同步下载器文件记录服务", - "trigger": "interval", - "func": self.sync, - "kwargs": {"seconds": float(str(self._time).strip()) * 3600} - }] - return [] - - def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: - """ - 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 - """ - return [ - { - 'component': 'VForm', - 'content': [ - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'enabled', - 'label': '开启插件', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'onlyonce', - 'label': '立即运行一次', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'history', - 'label': '补充整理历史记录', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'clear', - 'label': '清理数据', - } - } - ] - }, - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'time', - 'label': '同步时间间隔(小时)' - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VSelect', - 'props': { - 'chips': True, - 'multiple': True, - 'model': 'downloaders', - 'label': '同步下载器', - 'items': [ - {'title': 'Qbittorrent', 'value': 'qbittorrent'}, - {'title': 'Transmission', 'value': 'transmission'} - ] - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12 - }, - 'content': [ - { - 'component': 'VTextarea', - 'props': { - 'model': 'dirs', - 'label': '目录映射', - 'rows': 5, - 'placeholder': '每一行一个目录,下载器保存目录:MoviePilot映射目录' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '适用于非MoviePilot下载的任务;下载器种子数据较多时,同步时间将会较长,请耐心等候,可查看实时日志了解同步进度;时间间隔建议最少每6小时执行一次,防止上次任务没处理完。' - } - } - ] - } - ] - } - ] - } - ], { - "enabled": False, - "onlyonce": False, - "history": False, - "clear": False, - "time": 6, - "dirs": "", - "downloaders": [] - } - - def get_page(self) -> List[dict]: - pass - - def stop_service(self): - """ - 退出插件 - """ - try: - if self._scheduler: - self._scheduler.remove_all_jobs() - if self._scheduler.running: - self._scheduler.shutdown() - self._scheduler = None - except Exception as e: - logger.error("退出插件失败:%s" % str(e)) diff --git a/plugins.v2/trackereditor/__init__.py b/plugins.v2/trackereditor/__init__.py deleted file mode 100644 index 872e657..0000000 --- a/plugins.v2/trackereditor/__init__.py +++ /dev/null @@ -1,454 +0,0 @@ -from typing import List, Tuple, Dict, Any, Union, Optional - -from apscheduler.triggers.cron import CronTrigger - -from app.log import logger -from app.modules.qbittorrent import Qbittorrent -from qbittorrentapi.torrents import TorrentInfoList -from app.modules.transmission import Transmission -from transmission_rpc.torrent import Torrent -from app.plugins import _PluginBase -from app.schemas import NotificationType - - -class TrackerEditor(_PluginBase): - # 插件名称 - plugin_name = "Tracker替换" - # 插件描述 - plugin_desc = "批量替换种子tracker,支持周期性巡检(如为TR,仅支持4.0以上版本)" - # 插件图标 - plugin_icon = "trackereditor_A.png" - # 插件版本 - plugin_version = "1.5" - # 插件作者 - plugin_author = "honue" - # 作者主页 - author_url = "https://github.com/honue" - # 插件配置项ID前缀 - plugin_config_prefix = "trackereditor_" - # 加载顺序 - plugin_order = 30 - # 可使用的用户级别 - auth_level = 1 - - _downloader_type: str = None - _username: str = None - _password: str = None - _host: str = None - _port: int = None - _target_domain: str = None - _replace_domain: str = None - - _onlyonce: bool = False - _downloader: Union[Qbittorrent, Transmission] = None - - _run_con_enable: bool = False - _run_con: Optional[str] = None - _notify: bool = False - - def init_plugin(self, config: dict = None): - if config: - self._onlyonce = config.get("onlyonce") - self._downloader_type = config.get("downloader_type") - self._host = config.get("host") - self._port = config.get("port") - self._username = config.get("username") - self._password = config.get("password") - self._target_domain = config.get("target_domain") - self._replace_domain = config.get("replace_domain") - self._run_con_enable = config.get("run_con_enable") - self._run_con = config.get("run_con") - self._notify = config.get("notify") - - if self._onlyonce: - # 执行替换 - self.task() - self._onlyonce = False - # 更新onlyonce属性 - self.__update_config() - - def task(self): - logger.info(f"{'*' * 30}TrackerEditor: 开始执行Tracker替换{'*' * 30}") - torrent_total_cnt: int = 0 - torrent_update_cnt: int = 0 - if self._downloader_type == "qbittorrent": - self._downloader = Qbittorrent(self._host, self._port, self._username, self._password) - torrent_info_list: TorrentInfoList - torrent_info_list, error = self._downloader.get_torrents() - torrent_total_cnt = len(torrent_info_list) - if error: - return - for torrent in torrent_info_list: - for tracker in torrent.trackers: - if self._target_domain in tracker.url: - original_url = tracker.url - new_url = tracker.url.replace(self._target_domain, self._replace_domain) - logger.info(f"{original_url} 替换为\n {new_url}") - torrent.edit_tracker(orig_url=original_url, new_url=new_url) - torrent_update_cnt += 1 - - elif self._downloader_type == "transmission": - self._downloader = Transmission(self._host, self._port, self._username, self._password) - tr_version = self._downloader.get_session().get('version') - # "4.0.3 (6b0e49bbb2)" "3.00 (bb6b5a062e)" - torrent_list: List[Torrent] - torrent_list, error = self._downloader.get_torrents() - torrent_total_cnt = len(torrent_list) - if error: - return - for torrent in torrent_list: - new_tracker_list = [] - for tracker in torrent.tracker_list: - if self._target_domain in tracker: - new_url = tracker.replace(self._target_domain, self._replace_domain) - new_tracker_list.append(new_url) - logger.info(f"{tracker} 替换为\n {new_url}") - torrent_update_cnt += 1 - else: - new_tracker_list.append(tracker) - if int(tr_version[0]) >= 4: - # 版本大于等于4.x - __tracker_list = [new_tracker_list] - else: - __tracker_list = new_tracker_list - if torrent_update_cnt > 0: - update_result = self._downloader.update_tracker(hash_string=torrent.hashString, tracker_list=__tracker_list) - if not update_result: - logger.error(f"执行tracker修改出错,中止本次执行") - torrent_update_cnt = 0 - break - if torrent_update_cnt == 0: - logger.info(f"tracker修改条数为0") - logger.info(f"{'*' * 30}TrackerEditor: Tracker替换完成{'*' * 30}") - if (self._run_con_enable and self._notify) or (self._onlyonce and self._notify): - title = '【Tracker替换】' - msg = f'''扫描下载器{self._downloader_type}\n总的种子数: {torrent_total_cnt}\n已修改种子数: {torrent_update_cnt}''' - self.send_site_message(title, msg) - - def __update_config(self): - self.update_config({ - "onlyonce": self._onlyonce, - "downloader_type": self._downloader_type, - "username": self._username, - "password": self._password, - "host": self._host, - "port": self._port, - "target_domain": self._target_domain, - "replace_domain": self._replace_domain, - "run_cron_enable": self._run_con_enable, - "run_cron": self._run_con, - "notify": self._notify - }) - - @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]]: - return [ - { - 'component': 'VForm', - 'content': [ - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'run_con_enable', - 'label': '启用周期性巡检 (注: 请开启时,务必填写cron表达式)', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'onlyonce', - 'label': '立即运行一次', - } - } - ] - }] - }, { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'notify', - 'label': '发送通知', - } - } - ] - }] - }, - { - 'component': 'VRow', - 'content': [ - - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'run_con', - 'label': 'cron表达式', - 'placeholder': '* * * * *' - } - } - ] - }, { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VSelect', - 'props': { - 'model': 'downloader_type', - 'label': '下载器类型', - 'items': [ - {'title': 'Qbittorrent', 'value': 'qbittorrent'}, - {'title': 'Transmission', 'value': 'transmission'} - ] - } - } - ] - }] - }, { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'host', - 'label': 'host主机ip', - 'placeholder': '192.168.2.100' - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'port', - 'label': 'qb/tr端口', - 'placeholder': '8989' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'username', - 'label': '用户名', - 'placeholder': 'username' - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'password', - 'label': '密码', - 'placeholder': 'password' - } - } - ] - } - ] - }, { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'target_domain', - 'label': '待替换文本', - 'placeholder': 'target.com' - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'replace_domain', - 'label': '替换的文本', - 'placeholder': 'replace.net' - } - } - ] - } - ] - }, { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '对下载器中所有符合代替换文本的tacker进行字符串replace替换' + '\n' + - '现有tracker: https://baidu.com/announce.php?passkey=xxxx' + '\n' + - '待替换 baidu.com 或 https://baidu.com' + '\n' + - '用于替换的文本 qq.com 或 https://qq.com' + '\n' + - '结果为 https://qq.com/announce.php?passkey=xxxx', - 'style': 'white-space: pre-line;' - } - }, - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '强烈建议自己先添加一个tracker测试替换是否符合预期,程序是否正常运行', - 'style': 'white-space: pre-line;' - } - }, - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '周期性巡检时指的是允许设置间隔一段进行巡检下载器中的种子Tracker' + '\n' - '当匹配到等待替换的tracker时,进行替换,其中cron表达式是5位,例如:* * * * * 指的是每过一分钟轮训一次', - 'style': 'white-space: pre-line;' - } - } - ] - } - ] - } - ] - } - ], { - "onlyonce": False, - "downloader_type": "qbittorrent", - "host": "192.168.2.100", - "port": 8989, - "username": "username", - "password": "password", - "target_domain": "", - "replace_domain": "", - "run_con_enable": False, - "run_con": "", - "notify": True - } - - def get_page(self) -> List[dict]: - pass - - def get_state(self) -> bool: - return True - - def stop_service(self): - pass - - def get_service(self) -> List[Dict[str, Any]]: - if self._run_con_enable and self._run_con: - logger.info(f"{'*' * 30}TrackerEditor: 注册公共调度服务{'*' * 30}") - return [ - { - "id": "TrackerChangeRun", - "name": "启用周期性Tracker替换", - "trigger": CronTrigger.from_crontab(self._run_con), - "func": self.task, - "kwargs": {} - }] - - return [] - - def send_site_message(self, title, message): - self.post_message( - mtype=NotificationType.SiteMessage, - title=title, - text=message - ) diff --git a/plugins.v2/trcommand/__init__.py b/plugins.v2/trcommand/__init__.py deleted file mode 100644 index ff8af5c..0000000 --- a/plugins.v2/trcommand/__init__.py +++ /dev/null @@ -1,732 +0,0 @@ -from typing import List, Tuple, Dict, Any - -from app.log import logger -from app.modules.transmission import Transmission -from app.plugins import _PluginBase -from app.schemas import NotificationType -from app.schemas.types import EventType -from apscheduler.triggers.cron import CronTrigger -from app.core.event import eventmanager, Event -import time - - -class TrCommand(_PluginBase): - # 插件名称 - plugin_name = "TR远程操作" - # 插件描述 - plugin_desc = "通过定时任务或交互命令远程操作TR暂停/开始/限速等。" - # 插件图标 - plugin_icon = "Transmission_A.png" - # 插件版本 - plugin_version = "1.1" - # 插件作者 - plugin_author = "Hoey" - # 作者主页 - author_url = "https://github.com/hoey94" - # 插件配置项ID前缀 - plugin_config_prefix = "trcommand_" - # 加载顺序 - plugin_order = 1 - # 可使用的用户级别 - auth_level = 1 - - # 私有属性 - _tr = None - _enabled: bool = False - _notify: bool = False - _pause_cron = None - _resume_cron = None - _only_pause_once = False - _only_resume_once = False - _upload_limit = 0 - _enable_upload_limit = False - _download_limit = 0 - _enable_download_limit = False - - def init_plugin(self, config: dict = None): - # 停止现有任务 - self.stop_service() - # 读取配置 - if config: - self._enabled = config.get("enabled") - self._notify = config.get("notify") - self._pause_cron = config.get("pause_cron") - self._resume_cron = config.get("resume_cron") - self._only_pause_once = config.get("onlypauseonce") - self._only_resume_once = config.get("onlyresumeonce") - self._download_limit = config.get("download_limit") - self._upload_limit = config.get("upload_limit") - self._enable_download_limit = config.get("enable_download_limit") - self._enable_upload_limit = config.get("enable_upload_limit") - self._tr = Transmission() - - if self._only_pause_once or self._only_resume_once: - if self._only_pause_once and self._only_resume_once: - logger.warning("只能选择一个: 立即暂停或立即开始所有任务") - elif self._only_pause_once: - self.pause_torrent() - elif self._only_resume_once: - self.resume_torrent() - - self._only_resume_once = False - self._only_pause_once = False - self.update_config( - { - "onlypauseonce": False, - "onlyresumeonce": False, - "enabled": self._enabled, - "notify": self._notify, - "pause_cron": self._pause_cron, - "resume_cron": self._resume_cron, - } - ) - - # 限速 - self.set_limit(self._upload_limit, self._download_limit) - - def get_state(self) -> bool: - return self._enabled - - @staticmethod - def get_command() -> List[Dict[str, Any]]: - """ - 定义远程控制命令 - :return: 命令关键字、事件、描述、附带数据 - """ - return [ - { - "cmd": "/pause_torrents", - "event": EventType.PluginAction, - "desc": "暂停TR种子", - "category": "TR", - "data": {"action": "pause_torrents"}, - }, - { - "cmd": "/resume_torrents", - "event": EventType.PluginAction, - "desc": "开始TR种子", - "category": "TR", - "data": {"action": "resume_torrents"}, - }, - { - "cmd": "/toggle_upload_limit", - "event": EventType.PluginAction, - "desc": "TR切换上传限速状态", - "category": "TR", - "data": {"action": "toggle_upload_limit"}, - }, - { - "cmd": "/toggle_download_limit", - "event": EventType.PluginAction, - "desc": "TR切换下载限速状态", - "category": "TR", - "data": {"action": "toggle_download_limit"}, - }, - ] - - 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 and self._pause_cron and self._resume_cron: - return [ - { - "id": "TrPause", - "name": "暂停TR所有任务", - "trigger": CronTrigger.from_crontab(self._pause_cron), - "func": self.pause_torrent, - "kwargs": {}, - }, - { - "id": "TrResume", - "name": "开始TR所有任务", - "trigger": CronTrigger.from_crontab(self._resume_cron), - "func": self.resume_torrent, - "kwargs": {}, - }, - ] - if self._enabled and self._pause_cron: - return [ - { - "id": "TrPause", - "name": "暂停TR所有任务", - "trigger": CronTrigger.from_crontab(self._pause_cron), - "func": self.pause_torrent, - "kwargs": {}, - } - ] - if self._enabled and self._resume_cron: - return [ - { - "id": "TrResume", - "name": "开始TR所有任务", - "trigger": CronTrigger.from_crontab(self._resume_cron), - "func": self.resume_torrent, - "kwargs": {}, - } - ] - return [] - - def get_all_torrents(self): - all_torrents, error = self._tr.get_torrents() - if error: - logger.error(f"获取TR种子失败: {error}") - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【TR远程操作】", - text=f"获取TR种子失败,请检查TR配置", - ) - return [] - - if not all_torrents: - logger.warning("TR没有种子") - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【TR远程操作】", - text=f"TR中没有种子", - ) - return [] - return all_torrents - - @staticmethod - def get_torrents_status(torrents): - downloading_torrents = [] - uploading_torrents = [] - paused_torrents = [] - checking_torrents = [] - error_torrents = [] - for torrent in torrents: - match torrent.status.lower(): - case 'stopped': - paused_torrents.append(torrent.id) - case 'check_pending': - checking_torrents.append(torrent.id) - case 'checking': - checking_torrents.append(torrent.id) - case 'download_pending': - downloading_torrents.append(torrent.id) - case 'downloading': - downloading_torrents.append(torrent.id) - case 'seed_pending': - uploading_torrents.append(torrent.id) - case 'seeding': - uploading_torrents.append(torrent.id) - - return ( - downloading_torrents, - uploading_torrents, - paused_torrents, - checking_torrents, - error_torrents, - ) - - @eventmanager.register(EventType.PluginAction) - def handle_pause_torrent(self, event: Event): - if not self._enabled: - return - if event: - event_data = event.event_data - if not event_data or event_data.get("action") != "pause_torrents": - return - self.pause_torrent() - - def pause_torrent(self): - if not self._enabled: - return - - all_torrents = self.get_all_torrents() - hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( - self.get_torrents_status(all_torrents) - ) - to_be_paused = hash_downloading + hash_uploading + hash_checking - logger.info( - f"暂定任务启动 \n" - f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n" - f"暂停操作中请稍等...\n", - ) - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【TR暂停任务启动】", - text=f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n" - f"暂停操作中请稍等...\n", - ) - if len(to_be_paused) > 0: - if self._tr.stop_torrents(ids=to_be_paused): - logger.info(f"暂停了{len(to_be_paused)}个种子") - else: - logger.error(f"暂停种子失败") - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【TR远程操作】", - text=f"暂停种子失败", - ) - # 每个种子等待1ms以让状态切换成功,至少等待1S - wait_time = 0.001 * len(to_be_paused) + 1 - time.sleep(wait_time) - - all_torrents = self.get_all_torrents() - hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( - self.get_torrents_status(all_torrents) - ) - logger.info( - f"暂定任务完成 \n" - f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n" - ) - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【TR暂停任务完成】", - text=f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n", - ) - - @eventmanager.register(EventType.PluginAction) - def handle_resume_torrent(self, event: Event): - if not self._enabled: - return - if event: - event_data = event.event_data - if not event_data or event_data.get("action") != "resume_torrents": - return - self.resume_torrent() - - def resume_torrent(self): - if not self._enabled: - return - - all_torrents = self.get_all_torrents() - hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( - self.get_torrents_status(all_torrents) - ) - logger.info( - f"TR开始任务启动 \n" - f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n" - f"开始操作中请稍等...\n", - ) - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【TR开始任务启动】", - text=f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n" - f"开始操作中请稍等...\n", - ) - if not self._tr.start_torrents(ids=hash_paused): - logger.error(f"开始种子失败") - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【TR远程操作】", - text=f"开始种子失败", - ) - # 每个种子等待1ms以让状态切换成功,至少等待1S - wait_time = 0.001 * len(hash_paused) + 1 - time.sleep(wait_time) - - all_torrents = self.get_all_torrents() - hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( - self.get_torrents_status(all_torrents) - ) - logger.info( - f"开始任务完成 \n" - f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n" - ) - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【TR开始任务完成】", - text=f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n", - ) - - @eventmanager.register(EventType.PluginAction) - def handle_toggle_upload_limit(self, event: Event): - if not self._enabled: - return - if event: - event_data = event.event_data - if not event_data or event_data.get("action") != "toggle_upload_limit": - return - - self.set_limit(self._upload_limit, self._download_limit) - - @eventmanager.register(EventType.PluginAction) - def handle_toggle_download_limit(self, event: Event): - if not self._enabled: - return - if event: - event_data = event.event_data - if not event_data or event_data.get("action") != "toggle_download_limit": - return - self.set_limit(self._upload_limit, self._download_limit) - - def set_both_limit(self, upload_limit, download_limit): - if not self._enable_upload_limit or not self._enable_upload_limit: - return True - - if not upload_limit or not upload_limit.isdigit() or not download_limit or not download_limit.isdigit(): - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【TR远程操作】", - text=f"设置TR限速失败,download_limit或upload_limit不是一个数值", - ) - return False - - return self._tr.set_speed_limit( - download_limit=int(download_limit), upload_limit=int(upload_limit) - ) - - def set_upload_limit(self, upload_limit): - if not self._enable_upload_limit: - return True - - if not upload_limit or not upload_limit.isdigit(): - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【TR远程操作】", - text=f"设置TR限速失败,upload_limit不是一个数值", - ) - return False - - download_limit_current_val, _ = self._tr.get_speed_limit() - return self._tr.set_speed_limit( - download_limit=int(download_limit_current_val), upload_limit=int(upload_limit) - ) - - def set_download_limit(self, download_limit): - if not self._enable_download_limit: - return True - - if not download_limit or not download_limit.isdigit(): - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【TR远程操作】", - text=f"设置TR限速失败,download_limit不是一个数值", - ) - return False - - _, upload_limit_current_val = self._tr.get_speed_limit() - return self._tr.set_speed_limit( - download_limit=int(download_limit), upload_limit=int(upload_limit_current_val) - ) - - def set_limit(self, upload_limit, download_limit): - # 限速,满足以下三种情况设置限速 - # 1. 插件启用 && download_limit启用 - # 2. 插件启用 && upload_limit启用 - # 3. 插件启用 && download_limit启用 && upload_limit启用 - - flag = None - if self._enabled and self._enable_download_limit and self._enable_upload_limit: - flag = self.set_both_limit(upload_limit, download_limit) - - elif flag is None and self._enabled and self._enable_download_limit: - flag = self.set_download_limit(download_limit) - - elif flag is None and self._enabled and self._enable_upload_limit: - flag = self.set_upload_limit(upload_limit) - - if flag: - logger.info(f"设置TR限速成功") - if self._notify: - if upload_limit == 0: - text = f"上传无限速" - else: - text = f"上传限速:{upload_limit} KB/s" - if download_limit == 0: - text += f"\n下载无限速" - else: - text += f"\n下载限速:{download_limit} KB/s" - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【TR远程操作】", - text=text, - ) - elif not flag: - logger.error(f"TR设置限速失败") - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【TR远程操作】", - text=f"设置TR限速失败", - ) - - def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: - return [ - { - "component": "VForm", - "content": [ - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "enabled", - "label": "启用插件", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "notify", - "label": "发送通知", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "onlypauseonce", - "label": "立即暂停所有任务", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "onlyresumeonce", - "label": "立即开始所有任务", - }, - } - ], - }, - ], - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VTextField", - "props": { - "model": "pause_cron", - "label": "暂停周期", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VTextField", - "props": { - "model": "resume_cron", - "label": "开始周期", - }, - } - ], - }, - ], - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "enable_upload_limit", - "label": "上传限速", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "enable_download_limit", - "label": "下载限速", - }, - } - ], - }, - ], - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VTextField", - "props": { - "model": "upload_limit", - "label": "上传限速 KB/s", - "placeholder": "KB/s", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VTextField", - "props": { - "model": "download_limit", - "label": "下载限速 KB/s", - "placeholder": "KB/s", - }, - } - ], - }, - ], - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": { - "cols": 12, - }, - "content": [ - { - "component": "VAlert", - "props": { - "type": "info", - "variant": "tonal", - "text": "开始周期和暂停周期使用Cron表达式,如:0 0 0 * *", - }, - } - ], - }, - { - "component": "VCol", - "props": { - "cols": 12, - }, - "content": [ - { - "component": "VAlert", - "props": { - "type": "info", - "variant": "tonal", - "text": "交互命令有暂停TR种子、开始TR种子、TR切换上传限速状态、TR切换下载限速状态", - }, - } - ], - }, - { - "component": "VCol", - "props": { - "cols": 12, - }, - "content": [ - { - "component": "VAlert", - "props": { - "type": "info", - "variant": "tonal", - "text": "PT精神重在分享,请勿恶意限速,因此导致账号被封禁作者概不负责", - }, - } - ], - } - ], - }, - ], - } - ], { - "enabled": False, - "notify": True, - "onlypauseonce": False, - "onlyresumeonce": False, - "upload_limit": 0, - "download_limit": 0, - "enable_upload_limit": False, - "enable_download_limit": False, - } - - def get_page(self) -> List[dict]: - pass - - def stop_service(self): - pass diff --git a/plugins.v2/vcbanimemonitor/__init__.py b/plugins.v2/vcbanimemonitor/__init__.py deleted file mode 100644 index f81f257..0000000 --- a/plugins.v2/vcbanimemonitor/__init__.py +++ /dev/null @@ -1,1124 +0,0 @@ -import datetime -import re -import shutil -import threading -import time -import traceback -from pathlib import Path -from time import sleep -from typing import List, Tuple, Dict, Any, Optional -import pytz -import qbittorrentapi -from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.triggers.cron import CronTrigger -from watchdog.events import FileSystemEventHandler -from watchdog.observers import Observer -from watchdog.observers.polling import PollingObserver -from app import schemas -from app.chain.media import MediaChain -from app.chain.tmdb import TmdbChain -from app.chain.transfer import TransferChain -from app.core.config import settings -from app.core.context import MediaInfo -from app.db.downloadhistory_oper import DownloadHistoryOper -from app.db.transferhistory_oper import TransferHistoryOper -from app.log import logger -from app.modules.qbittorrent import Qbittorrent -from app.plugins import _PluginBase -from app.plugins.vcbanimemonitor.remeta import ReMeta -from app.schemas import Notification, NotificationType, TransferInfo -from app.schemas.types import EventType, MediaType, SystemConfigKey -from app.utils.string import StringUtils -from app.utils.system import SystemUtils - -lock = threading.Lock() - - -class FileMonitorHandler(FileSystemEventHandler): - """ - 目录监控响应类 - """ - - def __init__(self, monpath: str, sync: Any, **kwargs): - super(FileMonitorHandler, self).__init__(**kwargs) - self._watch_path = monpath - self.sync = sync - - def on_created(self, event): - self.sync.event_handler(event=event, text="创建", - mon_path=self._watch_path, event_path=event.src_path) - - def on_moved(self, event): - self.sync.event_handler(event=event, text="移动", - mon_path=self._watch_path, event_path=event.dest_path) - - -class TorrentHandler(FileSystemEventHandler): - def __init__(self, monpath: str, sync: Any, **kwargs): - super(TorrentHandler, self).__init__(**kwargs) - self._watch_path = monpath - self.sync = sync - - def on_created(self, event): - self.sync.torrent_event(event=event, text="创建", - mon_path=self._watch_path) - - def on_moved(self, event): - self.sync.torrent_event(event=event, text="移动", - mon_path=self._watch_path) - - -class VCBAnimeMonitor(_PluginBase): - # 插件名称 - plugin_name = "整理VCB动漫压制组作品" - # 插件描述 - plugin_desc = "一款辅助整理&提高识别VCB-Stuido动漫压制组作品的插件" - # 插件图标 - plugin_icon = "vcbmonitor.png" - # 插件版本 - plugin_version = "1.8.2.2" - # 插件作者 - plugin_author = "pixel@qingwa" - # 作者主页 - author_url = "https://github.com/Pixel-LH" - # 插件配置项ID前缀 - plugin_config_prefix = "vcbanimemonitor_" - # 加载顺序 - plugin_order = 4 - # 可使用的用户级别 - auth_level = 2 - - # 私有属性 - _switch_ova = False - _torrents_path = None - new_save_path = None - qb = None - _scheduler = None - transferhis = None - downloadhis = None - transferchian = None - tmdbchain = None - mediaChain = None - _observer = [] - _enabled = False - _notify = False - _onlyonce = False - _cron = None - _size = 0 - _scrape = True - # 模式 compatibility/fast - _mode = "fast" - # 转移方式 - _transfer_type = settings.TRANSFER_TYPE - _monitor_dirs = "" - _exclude_keywords = "" - _interval: int = 10 - # 存储源目录与目的目录关系 - _dirconf: Dict[str, Optional[Path]] = {} - # 存储源目录转移方式 - _transferconf: Dict[str, Optional[str]] = {} - _medias = {} - # 退出事件 - _event = threading.Event() - - def init_plugin(self, config: dict = None): - self.transferhis = TransferHistoryOper() - self.downloadhis = DownloadHistoryOper() - self.transferchian = TransferChain() - self.mediaChain = MediaChain() - self.tmdbchain = TmdbChain() - # 清空配置 - self._dirconf = {} - self._transferconf = {} - - # 读取配置 - if config: - self._enabled = config.get("enabled") - self._notify = config.get("notify") - self._onlyonce = config.get("onlyonce") - self._mode = config.get("mode") - self._transfer_type = config.get("transfer_type") - self._monitor_dirs = config.get("monitor_dirs") or "" - self._exclude_keywords = config.get("exclude_keywords") or "" - self._interval = config.get("interval") or 10 - self._cron = config.get("cron") - self._size = config.get("size") or 0 - self._scrape = config.get("scrape") - self._switch_ova = config.get("ova") - self._torrents_path = config.get("torrents_path") or "" - - # 停止现有任务 - self.stop_service() - - if self._enabled or self._onlyonce: - # 定时服务管理器 - self._scheduler = BackgroundScheduler(timezone=settings.TZ) - # 追加入库消息统一发送服务 - self._scheduler.add_job(self.send_msg, trigger='interval', seconds=15) - self.qb = Qbittorrent() - - # 读取目录配置 - monitor_dirs = self._monitor_dirs.split("\n") - if not monitor_dirs: - return - - # 启用种子目录监控 - if self._torrents_path and Path(self._torrents_path).exists() and self._enabled: - # 只取第一个目录作为新的保存 - try: - first_path = monitor_dirs[0] - if SystemUtils.is_windows(): - self.new_save_path = first_path.split(':')[0] + ":" + first_path.split(':')[1] - else: - self.new_save_path = first_path.split(':')[0] - except Exception: - logger.error(f"目录保存失败,请检查输入目录是否合法") - # print(self.new_save_path) - try: - observer = Observer() - self._observer.append(observer) - observer.schedule(TorrentHandler(monpath=self._torrents_path, sync=self), path=self._torrents_path, - recursive=True) - observer.daemon = True - observer.start() - logger.info(f"{self._torrents_path} 的种子目录监控服务启动,开启监控新增的VCB-Studio种子文件") - except Exception as e: - logger.debug(f"{self._torrents_path} 启动种子目录监控失败:{str(e)}") - else: - logger.info("种子目录为空,不转移qb中正在下载的VCB-Studio文件") - - for mon_path in monitor_dirs: - # 格式源目录:目的目录 - if not mon_path: - continue - - # 自定义转移方式 - _transfer_type = self._transfer_type - if mon_path.count("#") == 1: - _transfer_type = mon_path.split("#")[1] - mon_path = mon_path.split("#")[0] - - # 存储目的目录 - if SystemUtils.is_windows(): - if mon_path.count(":") > 1: - paths = [mon_path.split(":")[0] + ":" + mon_path.split(":")[1], - mon_path.split(":")[2] + ":" + mon_path.split(":")[3]] - else: - paths = [mon_path] - else: - paths = mon_path.split(":") - - # 目的目录 - target_path = None - if len(paths) > 1: - mon_path = paths[0] - target_path = Path(paths[1]) - self._dirconf[mon_path] = target_path - else: - self._dirconf[mon_path] = None - - # 转移方式 - self._transferconf[mon_path] = _transfer_type - - # 启用目录监控 - if self._enabled: - # 检查媒体库目录是不是下载目录的子目录 - try: - if target_path and target_path.is_relative_to(Path(mon_path)): - logger.warn(f"{target_path} 是监控目录 {mon_path} 的子目录,无法监控") - self.systemmessage.put(f"{target_path} 是下载目录 {mon_path} 的子目录,无法监控", - title="整理VCB动漫压制组作品") - continue - except Exception as e: - logger.debug(str(e)) - pass - - try: - if self._mode == "compatibility": - # 兼容模式,目录同步性能降低且NAS不能休眠,但可以兼容挂载的远程共享目录如SMB - observer = PollingObserver(timeout=10) - else: - # 内部处理系统操作类型选择最优解 - observer = Observer(timeout=10) - self._observer.append(observer) - observer.schedule(FileMonitorHandler(mon_path, self), path=mon_path, recursive=True) - observer.daemon = True - observer.start() - logger.info(f"{mon_path} 的目录监控服务启动") - except Exception as e: - err_msg = str(e) - if "inotify" in err_msg and "reached" in err_msg: - logger.warn( - f"目录监控服务启动出现异常:{err_msg},请在宿主机上(不是docker容器内)执行以下命令并重启:" - + """ - echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf - echo fs.inotify.max_user_instances=524288 | sudo tee -a /etc/sysctl.conf - sudo sysctl -p - """) - else: - logger.error(f"{mon_path} 启动目录监控失败:{err_msg}") - self.systemmessage.put(f"{mon_path} 启动目录监控失败:{err_msg}", title="整理VCB动漫压制组作品") - - # 运行一次定时服务 - if self._onlyonce: - logger.info("目录监控服务启动,立即运行一次") - self._scheduler.add_job(func=self.sync_all, trigger='date', - run_date=datetime.datetime.now( - tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3) - ) - # 关闭一次性开关 - self._onlyonce = False - # 保存配置 - self.__update_config() - - # 启动定时服务 - if self._scheduler.get_jobs(): - self._scheduler.print_jobs() - self._scheduler.start() - - def __update_config(self): - """ - 更新配置 - """ - self.update_config({ - "enabled": self._enabled, - "notify": self._notify, - "onlyonce": self._onlyonce, - "mode": self._mode, - "transfer_type": self._transfer_type, - "monitor_dirs": self._monitor_dirs, - "exclude_keywords": self._exclude_keywords, - "interval": self._interval, - "cron": self._cron, - "size": self._size, - "scrape": self._scrape, - "ova": self._switch_ova, - "torrents_path": self._torrents_path - }) - - def __save_data(self, key: str, value: Any): - self.save_data(key, value) - - def __get_data(self, key: str): - return self.get_data(key) - - def sync_all(self): - """ - 立即运行一次,全量同步目录中所有文件 - """ - logger.info("开始全量同步监控目录 ...") - # 清空历史的ova记录 - self.plugindata.truncate() - - # 遍历所有监控目录 - for mon_path in self._dirconf.keys(): - # 遍历目录下所有文件 - for file_path in SystemUtils.list_files(Path(mon_path), settings.RMT_MEDIAEXT): - self.__handle_file(event_path=str(file_path), mon_path=mon_path) - - logger.info("全量同步监控目录完成!") - - def event_handler(self, event, mon_path: str, text: str, event_path: str): - """ - 处理文件变化 - :param event: 事件 - :param mon_path: 监控目录 - :param text: 事件描述 - :param event_path: 事件文件路径 - """ - if not event.is_directory: - # 文件发生变化 - logger.debug("文件%s:%s" % (text, event_path)) - self.__handle_file(event_path=event_path, mon_path=mon_path) - - def __handle_file(self, event_path: str, mon_path: str): - """ - 同步一个文件 - :param event_path: 事件文件路径 - :param mon_path: 监控目录 - """ - file_path = Path(event_path) - try: - if not file_path.exists(): - return - # 全程加锁 - with lock: - transfer_history = self.transferhis.get_by_src(event_path) - if transfer_history: - logger.debug("文件已处理过:%s" % event_path) - return - - # 回收站及隐藏的文件不处理 - if event_path.find('/@Recycle/') != -1 \ - or event_path.find('/#recycle/') != -1 \ - or event_path.find('/.') != -1 \ - or event_path.find('/@eaDir') != -1: - logger.debug(f"{event_path} 是回收站或隐藏的文件") - return - - # 命中过滤关键字不处理 - if self._exclude_keywords: - for keyword in self._exclude_keywords.split("\n"): - if keyword and re.findall(keyword, event_path): - logger.info(f"{event_path} 命中过滤关键字 {keyword},不处理") - return - - # 整理屏蔽词不处理 - transfer_exclude_words = self.systemconfig.get(SystemConfigKey.TransferExcludeWords) - if transfer_exclude_words: - for keyword in transfer_exclude_words: - if not keyword: - continue - if keyword and re.search(r"%s" % keyword, event_path, re.IGNORECASE): - logger.info(f"{event_path} 命中整理屏蔽词 {keyword},不处理") - return - - # 不是媒体文件不处理 - if file_path.suffix not in settings.RMT_MEDIAEXT: - logger.debug(f"{event_path} 不是媒体文件") - return - - # 判断是不是蓝光目录 - bluray_flag = False - if re.search(r"BDMV[/\\]STREAM", event_path, re.IGNORECASE): - bluray_flag = True - # 截取BDMV前面的路径 - blurray_dir = event_path[:event_path.find("BDMV")] - file_path = Path(blurray_dir) - logger.info(f"{event_path} 是蓝光目录,更正文件路径为:{str(file_path)}") - - # 查询历史记录,已转移的不处理 - if self.transferhis.get_by_src(str(file_path)): - logger.info(f"{file_path} 已整理过") - return - - # 元数据 - if file_path.parent.name.lower() in ["sps", "scans", "cds", "previews", "extras"]: - logger.warn("位于特典或其他特殊目录下,跳过处理") - return - - if 'VCB-Studio' not in file_path.stem.strip(): - logger.warn("不属于VCB的作品,不处理!") - return - - remeta = ReMeta(ova_switch=self._switch_ova) - file_meta = remeta.handel_file(file_path=file_path) - if file_meta: - if not file_meta.name: - logger.error(f"{file_path.name} 无法识别有效信息") - return - if remeta.is_ova and not self._switch_ova: - logger.warn(f"{file_path.name} 为OVA资源,未开启OVA开关,不处理") - return - if remeta.is_ova and self._switch_ova: - logger.info(f"{file_path.name} 为OVA资源,开始历史记录处理") - ova_history_ep_list = self.get_data(file_meta.title) - if ova_history_ep_list and isinstance(ova_history_ep_list, list): - ep = file_meta.begin_episode - if ep in ova_history_ep_list: - for i in range(1, 100): - if ep + i not in ova_history_ep_list: - ova_history_ep_list.append(ep + i) - file_meta.begin_episode = ep + i - logger.info( - f"{file_path.name} 为OVA资源,历史记录中已存在,自动识别为第{ep + i}集") - break - else: - ova_history_ep_list.append(ep) - self.save_data(file_meta.title, ova_history_ep_list) - else: - self.save_data(file_meta.title, [file_meta.begin_episode]) - else: - return - - # 判断文件大小 - if self._size and float(self._size) > 0 and file_path.stat().st_size < float(self._size) * 1024 ** 3: - logger.info(f"{file_path} 文件大小小于监控文件大小,不处理") - return - - # 查询转移目的目录 - target: Path = self._dirconf.get(mon_path) - # 查询转移方式 - transfer_type = self._transferconf.get(mon_path) - - # 根据父路径获取下载历史 - download_history = None - if bluray_flag: - # 蓝光原盘,按目录名查询 - # FIXME 理论上DownloadHistory表中的path应该是全路径,但实际表中登记的数据只有目录名,暂按目录名查询 - download_history = self.downloadhis.get_by_path(file_path.name) - else: - # 按文件全路径查询 - download_file = self.downloadhis.get_file_by_fullpath(str(file_path)) - if download_file: - download_history = self.downloadhis.get_by_hash(download_file.download_hash) - - # 识别媒体信息 - if download_history and download_history.tmdbid: - mediainfo: MediaInfo = self.mediaChain.recognize_media(mtype=MediaType(download_history.type), - tmdbid=download_history.tmdbid, - doubanid=download_history.doubanid) - else: - mediainfo: MediaInfo = self.mediaChain.recognize_by_meta(file_meta) - - if not mediainfo: - logger.warn(f'未识别到媒体信息,标题:{file_meta.name}') - # self.save_data(plugin_id="vcbanimemonitor", key=file_meta.title, value="null") - # 新增转移成功历史记录 - his = self.transferhis.add_fail( - src_path=file_path, - mode=transfer_type, - meta=file_meta - ) - if self._notify: - self.chain.post_message(Notification( - mtype=NotificationType.Manual, - title=f"{file_path.name} 未识别到媒体信息,无法入库!\n" - f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别转移。" - )) - return - - # 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title - if not settings.SCRAP_FOLLOW_TMDB: - transfer_history = self.transferhis.get_by_type_tmdbid(tmdbid=mediainfo.tmdb_id, - mtype=mediainfo.type.value) - if transfer_history: - mediainfo.title = transfer_history.title - logger.info(f"{file_path.name} 识别为:{mediainfo.type.value} {mediainfo.title_year}") - - # 更新媒体图片 - self.chain.obtain_images(mediainfo=mediainfo) - - # 获取集数据 - if mediainfo.type == MediaType.TV: - episodes_info = self.tmdbchain.tmdb_episodes(tmdbid=mediainfo.tmdb_id, - season=file_meta.begin_season or 1) - else: - episodes_info = None - - # 获取下载Hash - download_hash = None - if download_history: - download_hash = download_history.download_hash - - # 转移 - transferinfo: TransferInfo = self.chain.transfer(mediainfo=mediainfo, - path=file_path, - transfer_type=transfer_type, - target=target, - meta=file_meta, - episodes_info=episodes_info) - - if not transferinfo: - logger.error("文件转移模块运行失败") - return - - if not transferinfo.success: - # 转移失败 - logger.warn(f"{file_path.name} 入库失败:{transferinfo.message}") - # 新增转移失败历史记录 - self.transferhis.add_fail( - src_path=file_path, - mode=transfer_type, - download_hash=download_hash, - meta=file_meta, - mediainfo=mediainfo, - transferinfo=transferinfo - ) - if self._notify: - self.chain.post_message(Notification( - mtype=NotificationType.Manual, - title=f"{mediainfo.title_year}{file_meta.season_episode} 入库失败!", - text=f"原因:{transferinfo.message or '未知'}", - image=mediainfo.get_message_image() - )) - return - - # 新增转移成功历史记录 - self.transferhis.add_success( - src_path=file_path, - mode=transfer_type, - download_hash=download_hash, - meta=file_meta, - mediainfo=mediainfo, - transferinfo=transferinfo - ) - - # 刮削单个文件 - if self._scrape: - self.chain.scrape_metadata(path=transferinfo.target_path, - mediainfo=mediainfo, - transfer_type=transfer_type) - - """ - { - "title_year season": { - "files": [ - { - "path":, - "mediainfo":, - "file_meta":, - "transferinfo": - } - ], - "time": "2023-08-24 23:23:23.332" - } - } - """ - # 发送消息汇总 - media_list = self._medias.get(mediainfo.title_year + " " + file_meta.season) or {} - if media_list: - media_files = media_list.get("files") or [] - if media_files: - file_exists = False - for file in media_files: - if str(file_path) == file.get("path"): - file_exists = True - break - if not file_exists: - media_files.append({ - "path": str(file_path), - "mediainfo": mediainfo, - "file_meta": file_meta, - "transferinfo": transferinfo - }) - else: - media_files = [ - { - "path": str(file_path), - "mediainfo": mediainfo, - "file_meta": file_meta, - "transferinfo": transferinfo - } - ] - media_list = { - "files": media_files, - "time": datetime.datetime.now() - } - else: - media_list = { - "files": [ - { - "path": str(file_path), - "mediainfo": mediainfo, - "file_meta": file_meta, - "transferinfo": transferinfo - } - ], - "time": datetime.datetime.now() - } - self._medias[mediainfo.title_year + " " + file_meta.season] = media_list - - # 广播事件 - self.eventmanager.send_event(EventType.TransferComplete, { - 'meta': file_meta, - 'mediainfo': mediainfo, - 'transferinfo': transferinfo - }) - - # 移动模式删除空目录 - if transfer_type == "move": - for file_dir in file_path.parents: - if len(str(file_dir)) <= len(str(Path(mon_path))): - # 重要,删除到监控目录为止 - break - files = SystemUtils.list_files(file_dir, settings.RMT_MEDIAEXT) - if not files: - logger.warn(f"移动模式,删除空目录:{file_dir}") - shutil.rmtree(file_dir, ignore_errors=True) - - except Exception as e: - logger.error("目录监控发生错误:%s - %s" % (str(e), traceback.format_exc())) - - def torrent_event(self, event, mon_path: str, text: str): - """ - 处理种子文件 - :param mon_path: 种子目录 - """ - evc_path = Path(event.src_path) - if not event.is_directory and (evc_path.suffix == ".torrent" or str(evc_path).split('.')[1] == "torrent"): - # 文件发生变化 - logger.debug("文件%s:%s" % (text, mon_path)) - self.__handle_torrent(torrent_path=self._torrents_path) - else: - logger.debug("不是种子文件:%s" % mon_path) - - def __handle_torrent(self, torrent_path: str): - torrent_path = Path(torrent_path) - try: - if not torrent_path.exists(): - return - # 只处理刚刚添加的种子也就是获取正在下载的种子 - # 等待种子文件下载完成 - time.sleep(5) - with lock: - torrents = self.qb.get_downloading_torrents() - for torrent in torrents: - if "VCB-Studio" in torrent.name: - logger.info(f"开始转移qb中正在下载的VCB资源,转移目录为:{self.new_save_path}") - # 原本存在的暂停的种子不处理 - if torrent.state_enum == qbittorrentapi.TorrentState.PAUSED_DOWNLOAD: - continue - if torrent.save_path == self.new_save_path: - continue - torrent.pause() - torrent.set_save_path(save_path=self.new_save_path) - torrent.resume() - else: - continue - except qbittorrentapi.exceptions.APIError as e: - logger.error(f"VCB辅助整理模块转移qb文件移动失败:{e}") - - def send_msg(self): - """ - 定时检查是否有媒体处理完,发送统一消息 - """ - if not self._medias or not self._medias.keys(): - return - - # 遍历检查是否已刮削完,发送消息 - for medis_title_year_season in list(self._medias.keys()): - media_list = self._medias.get(medis_title_year_season) - logger.info(f"开始处理媒体 {medis_title_year_season} 消息") - - if not media_list: - continue - - # 获取最后更新时间 - last_update_time = media_list.get("time") - media_files = media_list.get("files") - if not last_update_time or not media_files: - continue - - transferinfo = media_files[0].get("transferinfo") - file_meta = media_files[0].get("file_meta") - mediainfo = media_files[0].get("mediainfo") - # 判断剧集最后更新时间距现在是已超过10秒或者电影,发送消息 - if (datetime.datetime.now() - last_update_time).total_seconds() > int(self._interval) \ - or mediainfo.type == MediaType.MOVIE: - # 发送通知 - if self._notify: - - # 汇总处理文件总大小 - total_size = 0 - file_count = 0 - - # 剧集汇总 - episodes = [] - for file in media_files: - transferinfo = file.get("transferinfo") - total_size += transferinfo.total_size - file_count += 1 - - file_meta = file.get("file_meta") - if file_meta and file_meta.begin_episode: - episodes.append(file_meta.begin_episode) - - transferinfo.total_size = total_size - # 汇总处理文件数量 - transferinfo.file_count = file_count - - # 剧集季集信息 S01 E01-E04 || S01 E01、E02、E04 - season_episode = None - # 处理文件多,说明是剧集,显示季入库消息 - if mediainfo.type == MediaType.TV: - # 季集文本 - season_episode = f"{file_meta.season} {StringUtils.format_ep(episodes)}" - # 发送消息 - self.transferchian.send_transfer_message(meta=file_meta, - mediainfo=mediainfo, - transferinfo=transferinfo, - season_episode=season_episode) - # 发送完消息,移出key - del self._medias[medis_title_year_season] - continue - - def get_state(self) -> bool: - return self._enabled - - 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 and self._cron: - return [{ - "id": "vcbanimemonitor", - "name": "vcbanimemonitor", - "trigger": CronTrigger.from_crontab(self._cron), - "func": self.sync_all, - "kwargs": {} - }] - return [] - - def sync(self) -> schemas.Response: - """ - API调用目录同步 - """ - self.sync_all() - return schemas.Response(success=True) - - def get_api(self) -> List[Dict[str, Any]]: - pass - - def get_command(self) -> List[Dict[str, Any]]: - pass - - def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: - return [ - { - 'component': 'VForm', - 'content': [ - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'enabled', - 'label': '启用插件', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'notify', - 'label': '发送通知', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'onlyonce', - 'label': '立即运行一次', - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'ova', - 'label': '开启识别OVA/OAD文件', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'scrape', - 'label': '刮削元数据', - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSelect', - 'props': { - 'model': 'mode', - 'label': '监控模式', - 'items': [ - {'title': '兼容模式', 'value': 'compatibility'}, - {'title': '性能模式', 'value': 'fast'} - ] - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSelect', - 'props': { - 'model': 'transfer_type', - 'label': '转移方式', - 'items': [ - {'title': '移动', 'value': 'move'}, - {'title': '复制', 'value': 'copy'}, - {'title': '硬链接', 'value': 'link'}, - {'title': '软链接', 'value': 'softlink'}, - {'title': 'Rclone复制', 'value': 'rclone_copy'}, - {'title': 'Rclone移动', 'value': 'rclone_move'} - ] - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'interval', - 'label': '入库消息延迟', - 'placeholder': '10' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'cron', - 'label': '定时全量同步周期', - 'placeholder': '5位cron表达式,留空关闭' - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'size', - 'label': '监控文件大小(GB)', - 'placeholder': '0' - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'torrents_path', - 'label': '监控种子目录', - 'placeholder': '填入路径代表启用' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12 - }, - 'content': [ - { - 'component': 'VTextarea', - 'props': { - 'model': 'monitor_dirs', - 'label': '监控目录', - 'rows': 4, - 'placeholder': '每一行一个目录,支持以下几种配置方式,转移方式支持 move、copy、link、softlink、rclone_copy、rclone_move:\n' - '监控目录\n' - '监控目录#转移方式\n' - '监控目录:转移目的目录\n' - '监控目录:转移目的目录#转移方式' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VTextarea', - 'props': { - 'model': 'exclude_keywords', - 'label': '排除关键词', - 'rows': 2, - 'placeholder': '每一行一个关键词' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '核心用法与目录同步插件相同,不同点在于只识别处理VCB-Studio资源。' - '默认不处理SPs、CDs、SCans目录下的文件,OVA/OAD集数暂时根据入库顺序累加命名,' - '因此不保证与TMDB集数匹配。部分季度以罗马音音译为名的作品暂时无法识别出准确季度。' - '有想法,有问题欢迎点击插件作者主页提issue!' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '最佳使用方式:监控目录单独设置一个作为保存VCB-Studio资源的目录,' - '填入监控种子目录,开启后会将正在QB(仅支持QB)下载器内正在下载的VCB-Studio资源转移到监控目录实现自动整理(' - '仅支持第一个监控目录),' - '监控种子目录为空则不转移文件' - } - } - ] - } - ] - }, - ] - }, - ], { - "enabled": False, - "notify": False, - "onlyonce": False, - "mode": "fast", - "transfer_type": settings.TRANSFER_TYPE, - "monitor_dirs": "", - "exclude_keywords": "", - "interval": 10, - "cron": "", - "size": 0, - "ova": False, - "torrents_path": "", - } - - def get_page(self) -> List[dict]: - pass - - def stop_service(self): - """ - 退出插件 - """ - if self._observer: - for observer in self._observer: - try: - observer.stop() - observer.join() - except Exception as e: - print(str(e)) - self._observer = [] - if self._scheduler: - self._scheduler.remove_all_jobs() - if self._scheduler.running: - self._event.set() - self._scheduler.shutdown() - self._event.clear() - self._scheduler = None diff --git a/plugins.v2/vcbanimemonitor/remeta.py b/plugins.v2/vcbanimemonitor/remeta.py deleted file mode 100644 index ea261eb..0000000 --- a/plugins.v2/vcbanimemonitor/remeta.py +++ /dev/null @@ -1,284 +0,0 @@ -import concurrent -import re -from dataclasses import dataclass -from pathlib import Path -from typing import List -from app.chain.media import MediaChain -from app.chain.tmdb import TmdbChain -from app.core.metainfo import MetaInfoPath -from app.log import logger -from app.schemas import MediaType - -season_patterns = [ - {"pattern": re.compile(r"S(\d+)$", re.IGNORECASE), "group": 1}, - {"pattern": re.compile(r"(\d+)$", re.IGNORECASE), "group": 1}, - {"pattern": re.compile(r"(\d+)(st|nd|rd|th)?\s*season", re.IGNORECASE), "group": 1}, - {"pattern": re.compile(r"(.*) ?\s*season (\d+)", re.IGNORECASE), "group": 2}, - {"pattern": re.compile(r"\s(II|III|IV|V|VI|VII|VIII|IX|X)$", re.IGNORECASE), "group": "1"} -] -episode_patterns = [ - {"pattern": re.compile(r"(\d+)\((\d+)\)", re.IGNORECASE), "group": 2}, - {"pattern": re.compile(r"(\d+)", re.IGNORECASE), "group": 1}, - {"pattern": re.compile(r'(\d+)v\d+', re.IGNORECASE), "group": 1}, -] - -ova_patterns = [ - re.compile(r".*?(OVA|OAD).*?", re.IGNORECASE), - re.compile(r"\d+\.5"), - re.compile(r"00") -] - -final_season_patterns = [ - re.compile('final season', re.IGNORECASE), - re.compile('The Final', re.IGNORECASE), - re.compile(r'\sFinal') -] - -movie_patterns = [ - re.compile("Movie", re.IGNORECASE), - re.compile("the Movie", re.IGNORECASE), -] - - -@dataclass -class VCBMetaBase: - # 转化为小写后的原始文件名称 (不含后缀) - original_title: str = "" - # 解析后不包含季度和集数的标题 - title: str = "" - # 类型:TV / Movie (默认TV) - type: str = "TV" - # 可能含有季度的标题,一级解析后的标题 - season_title: str = "" - # 可能含有集数的字符串列表 - ep_title: List[str] = None - # 识别出来的季度 - season: int = None - # 识别出来的集数 - ep: int = None - # 是否是OVA/OAD - is_ova: bool = False - # TMDB ID - tmdb_id: int = None - - -blocked_words = ["vcb-studio", "360p", "480p", "720p", "1080p", "2160p", "hdr", "x265", "x264", "aac", "flac"] - - -class ReMeta: - - def __init__(self, ova_switch: bool = False, custom_season_patterns: list[dict] = None): - self.meta = None - # TODO:自定义季度匹配规则 - self.custom_season_patterns = custom_season_patterns - self.season_patterns = season_patterns - self.ova_switch = ova_switch - self.vcb_meta = VCBMetaBase() - self.is_ova = False - - def is_tv(self, title: str) -> bool: - """ - 判断是否是TV - """ - if title.count("[") != 4 and title.count("]") != 4: - self.vcb_meta.type = "Movie" - self.vcb_meta.title = re.sub(r'\[.*?\]', '', title).strip() - return False - return True - - def handel_file(self, file_path: Path): - file_name = file_path.stem.strip().lower() - self.vcb_meta.original_title = file_name - if not self.is_tv(file_name): - logger.warn( - "不符合VCB-Studio的剧集命名规范,归类为电影,跳过剧集模块处理。注意:年份较为久远的作品可能在此会判断错误") - self.parse_movie() - else: - self.tv_mode() - self.is_ova = self.vcb_meta.is_ova - meta = MetaInfoPath(file_path) - meta.title = self.vcb_meta.title - meta.en_name = self.vcb_meta.title - if self.vcb_meta.type == "Movie": - meta.type = MediaType.MOVIE - else: - meta.type = MediaType.TV - if self.vcb_meta.ep is not None: - meta.begin_episode = self.vcb_meta.ep - if self.vcb_meta.season is not None: - meta.begin_season = self.vcb_meta.season - if self.vcb_meta.tmdb_id is not None: - meta.tmdbid = self.vcb_meta.tmdb_id - return meta - - def split_season_ep(self): - # 把所有的[] 里面的内容获取出来,不需要[]本身 - self.vcb_meta.ep_title = re.findall(r'\[(.*?)\]', self.vcb_meta.original_title) - # 去除所有[]后只剩下剧名 - self.vcb_meta.season_title = re.sub(r"\[.*?\]", "", self.vcb_meta.original_title).strip() - if self.vcb_meta.ep_title: - self.culling_blocked_words() - logger.info( - f"分离出包含可能季度的内容部分:{self.vcb_meta.season_title} | 可能包含集数的内容部分: {self.vcb_meta.ep_title}") - self.vcb_meta.title = self.vcb_meta.season_title - if not self.vcb_meta.ep_title: - self.vcb_meta.title = self.vcb_meta.season_title - logger.warn("未识别出可能存在集数位置的信息,跳过剩余识别步骤!") - - def tv_mode(self): - logger.info("开始分离季度和集数部分") - self.split_season_ep() - if not self.vcb_meta.ep_title: - return - self.parse_season() - self.parse_episode() - - def parse_season(self): - """ - 从标题中解析季度 - """ - flag = False - for pattern in season_patterns: - match = pattern["pattern"].search(self.vcb_meta.season_title) - if match: - if isinstance(pattern["group"], int): - self.vcb_meta.season = int(match.group(pattern["group"])) - else: - self.vcb_meta.season = self.roman_to_int(match.group(pattern["group"])) - # 匹配成功后,标题中去除季度信息 - self.vcb_meta.title = pattern["pattern"].sub("", self.vcb_meta.season_title).strip - logger.info(f"识别出季度为{self.vcb_meta.season}") - return - logger.info(f"正常匹配季度失败,开始匹配ova/oad/最终季度") - if not flag: - # 匹配是否为最终季 - for pattern in final_season_patterns: - if pattern.search(self.vcb_meta.season_title): - logger.info("命中到最终季匹配规则") - self.vcb_meta.title = pattern.sub("", self.vcb_meta.season_title).strip() - self.handle_final_season() - return - logger.info("未识别出最终季度,开始匹配OVA/OAD") - # 匹配是否为OVA/OAD - if "ova" in self.vcb_meta.season_title or "oad" in self.vcb_meta.season_title: - logger.info("季度部分命中到OVA/OAD匹配规则") - if self.ova_switch: - logger.info("开启OVA/OAD处理逻辑") - self.vcb_meta.is_ova = True - for pattern in ova_patterns: - if pattern.search(self.vcb_meta.season_title): - self.vcb_meta.title = pattern.sub("", self.vcb_meta.season_title).strip() - self.vcb_meta.title = re.sub("ova|oad", "", self.vcb_meta.season_title).strip() - self.vcb_meta.season = 0 - return - logger.warn("未识别出季度,默认处理逻辑返回第一季") - self.vcb_meta.title = self.vcb_meta.season_title - self.vcb_meta.season = 1 - - def parse_episode(self): - """ - 从标题中解析集数 - """ - # 从ep_title中剔除不相关的内容之后只剩下存在集数的字符串 - ep = self.vcb_meta.ep_title[0] - for pattern in episode_patterns: - match = pattern["pattern"].search(ep) - if match: - self.vcb_meta.ep = int(match.group(pattern["group"])) - logger.info(f"识别出集数为{self.vcb_meta.ep}") - return - # 直接进入判断是否为OVA/OAD - for pattern in ova_patterns: - if pattern.search(ep): - self.vcb_meta.is_ova = True - # 直接获取数字 - self.vcb_meta.ep = int(re.search(r"\d+", ep).group()) or 1 - logger.info(f"OVA模式下识别出集数为{self.vcb_meta.ep}") - self.vcb_meta.season = 0 - return - - def culling_blocked_words(self): - """ - 从ep_title中剔除不相关的内容 - """ - blocked_set = set(blocked_words) # 将阻止词列表转换为集合 - result = [ep for ep in self.vcb_meta.ep_title if not any(word in ep for word in blocked_set)] - self.vcb_meta.ep_title = result - - def handle_final_season(self): - - _, medias = MediaChain().search(title=self.vcb_meta.title) - if not medias: - logger.warning("匹配到最终季时无法找到对应的媒体信息!季度返回默认值:1") - self.vcb_meta.season = 1 - return - - filter_medias = [media for media in medias if media.type == MediaType.TV] - if not filter_medias: - logger.warning("匹配到最终季时无法找到对应的媒体信息!季度返回默认值:1") - self.vcb_meta.season = 1 - return - medias = [media for media in filter_medias if media.popularity or media.vote_average] - if not medias: - logger.warning("匹配到最终季时无法找到对应的媒体信息!季度返回默认值:1") - self.vcb_meta.season = 1 - return - # 获取欢迎度最高或者评分最高的媒体 - medias_sorted = sorted(medias, key=lambda x: x.popularity or x.vote_average, reverse=True)[0] - self.vcb_meta.tmdb_id = medias_sorted.tmdb_id - if medias_sorted.tmdb_id: - seasons_info = TmdbChain().tmdb_seasons(tmdbid=medias_sorted.tmdb_id) - if seasons_info: - self.vcb_meta.season = len(seasons_info) - logger.info(f"获取到最终季度,季度为{self.vcb_meta.season}") - return - logger.warning("无法获取到最终季度信息,季度返回默认值:1") - self.vcb_meta.season = 1 - - - - def parse_movie(self): - logger.info("开始尝试剧场版模式解析") - for pattern in movie_patterns: - if pattern.search(self.vcb_meta.title): - logger.info("命中剧场版匹配规则,加上剧场版标识辅助识别") - self.vcb_meta.type = "Movie" - self.vcb_meta.title = pattern.sub("", self.vcb_meta.title).strip() - self.vcb_meta.title = self.vcb_meta.title - return - - def find_ova_episode(self): - """ - 搜索OVA的集数 - TODO:模糊匹配OVA的集数 - """ - pass - - - @staticmethod - def roman_to_int(s) -> int: - """ - :param s: 罗马数字字符串 - 罗马数字转整数 - """ - roman_dict = {'I': 1, 'V': 5, 'X': 10, 'L': 50, 'C': 100, 'D': 500, 'M': 1000} - total = 0 - prev_value = 0 - - for char in reversed(s): # 反向遍历罗马数字字符串 - current_value = roman_dict[char] - if current_value >= prev_value: - total += current_value # 如果当前值大于等于前一个值,加上当前值 - else: - total -= current_value # 如果当前值小于前一个值,减去当前值 - prev_value = current_value - - return total - - - -# if __name__ == '__main__': -# ReMeta( -# ova_switch=True, -# ).handel_file(Path( -# r"[Airota&Nekomoe kissaten&VCB-Studio] Yuru Camp [Heya Camp EP00][Ma10p_1080p][x265_flac].mkv")) From a2441cf69d93fc4509e08fdb87ab0aad0645d935 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 19 Oct 2024 23:55:09 +0800 Subject: [PATCH 110/218] =?UTF-8?q?refactor=EF=BC=9A=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=20v2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 14 +++++ plugins.v2/configcenter/__init__.py | 88 ++++++++++++++++++++++++++++- 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/package.v2.json b/package.v2.json index 5a23f55..079036a 100644 --- a/package.v2.json +++ b/package.v2.json @@ -174,5 +174,19 @@ "history": { "v2.0": "兼容MoviePilot V2 版本" } + }, + "ConfigCenter": { + "name": "配置中心", + "description": "快速调整部分系统设定。", + "labels": "系统设置", + "version": "3.0", + "icon": "setting.png", + "author": "jxxghp", + "level": 1, + "history": { + "v3.0": "兼容MoviePilot V2 版本", + "v2.6": "支持DOH相关配置项", + "v2.5": "增加Github加速服务器设置项" + } } } \ No newline at end of file diff --git a/plugins.v2/configcenter/__init__.py b/plugins.v2/configcenter/__init__.py index 733de8b..58178bb 100644 --- a/plugins.v2/configcenter/__init__.py +++ b/plugins.v2/configcenter/__init__.py @@ -17,7 +17,7 @@ class ConfigCenter(_PluginBase): # 插件图标 plugin_icon = "setting.png" # 插件版本 - plugin_version = "2.6" + plugin_version = "3.0" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -38,7 +38,8 @@ class ConfigCenter(_PluginBase): "RECOGNIZE_SOURCE", "SCRAP_FOLLOW_TMDB", "AUTO_DOWNLOAD_USER", "OCR_HOST", "DOWNLOAD_SUBTITLE", "PLUGIN_MARKET", "MOVIE_RENAME_FORMAT", "TV_RENAME_FORMAT", "FANART_ENABLE", "DOH_ENABLE", "SEARCH_MULTIPLE_NAME", "META_CACHE_EXPIRE", - "GITHUB_PROXY", "DOH_DOMAINS", "DOH_RESOLVERS" + "GITHUB_PROXY", "DOH_DOMAINS", "DOH_RESOLVERS", "AUXILIARY_AUTH_ENABLE", "MEDIASERVER_SYNC_INTERVAL", + "PIP_PROXY", "GLOBAL_IMAGE_CACHE" ] def init_plugin(self, config: dict = None): @@ -353,6 +354,57 @@ class ConfigCenter(_PluginBase): } } ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "PIP_PROXY", + "label": "PIP加速服务器", + "placeholder": "https://pypi.tuna.tsinghua.edu.cn/simple" + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "META_CACHE_EXPIRE", + "label": "元数据缓存时间(小时)", + "placeholder": "单位:小时" + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "MEDIASERVER_SYNC_INTERVAL", + "label": "媒体服务器同步间隔(小时)", + "placeholder": "单位:小时" + } + } + ] } ] }, @@ -559,6 +611,38 @@ class ConfigCenter(_PluginBase): } } ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 4 + }, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "AUXILIARY_AUTH_ENABLE", + "label": "启用用户辅助认证" + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 4 + }, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "GLOBAL_IMAGE_CACHE", + "label": "全局图片缓存" + } + } + ] } ] }, From d33739b7a72abf59a28e9ff157635defab54d0c2 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 20 Oct 2024 00:10:38 +0800 Subject: [PATCH 111/218] =?UTF-8?q?refactor=EF=BC=9A=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=88=A0=E7=A7=8D=20v2=20refactor=EF=BC=9AIYUU=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E8=BE=85=E7=A7=8D=20v2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 24 +++++ plugins.v2/iyuuautoseed/__init__.py | 121 +++++++++++++------------- plugins.v2/torrentremover/__init__.py | 58 +++++++----- 3 files changed, 124 insertions(+), 79 deletions(-) diff --git a/package.v2.json b/package.v2.json index 079036a..a59e5a6 100644 --- a/package.v2.json +++ b/package.v2.json @@ -188,5 +188,29 @@ "v2.6": "支持DOH相关配置项", "v2.5": "增加Github加速服务器设置项" } + }, + "TorrentRemover": { + "name": "自动删种", + "description": "自动删除下载器中的下载任务。", + "labels": "做种", + "version": "2.0", + "icon": "delete.jpg", + "author": "jxxghp", + "level": 2, + "history": { + "v2.0": "兼容MoviePilot V2 版本" + } + }, + "IYUUAutoSeed": { + "name": "IYUU自动辅种", + "description": "基于IYUU官方Api实现自动辅种。", + "labels": "做种,IYUU", + "version": "2.0", + "icon": "IYUU.png", + "author": "jxxghp", + "level": 2, + "history": { + "v2.0": "兼容MoviePilot V2 版本" + } } } \ No newline at end of file diff --git a/plugins.v2/iyuuautoseed/__init__.py b/plugins.v2/iyuuautoseed/__init__.py index 64e4f2f..3e2567b 100644 --- a/plugins.v2/iyuuautoseed/__init__.py +++ b/plugins.v2/iyuuautoseed/__init__.py @@ -13,14 +13,13 @@ from ruamel.yaml import CommentedMap from app.core.config import settings from app.core.event import eventmanager from app.db.site_oper import SiteOper +from app.helper.downloader import DownloaderHelper from app.helper.sites import SitesHelper from app.helper.torrent import TorrentHelper from app.log import logger -from app.modules.qbittorrent import Qbittorrent -from app.modules.transmission import Transmission from app.plugins import _PluginBase from app.plugins.iyuuautoseed.iyuu_helper import IyuuHelper -from app.schemas import NotificationType +from app.schemas import NotificationType, ServiceInfo from app.schemas.types import EventType from app.utils.http import RequestUtils from app.utils.string import StringUtils @@ -34,7 +33,7 @@ class IYUUAutoSeed(_PluginBase): # 插件图标 plugin_icon = "IYUU.png" # 插件版本 - plugin_version = "1.9.5" + plugin_version = "2.0" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -49,11 +48,10 @@ class IYUUAutoSeed(_PluginBase): # 私有属性 _scheduler = None iyuuhelper = None - qb = None - tr = None sites = None siteoper = None torrent = None + downloader_helper = None # 开关 _enabled = False _cron = None @@ -100,6 +98,7 @@ class IYUUAutoSeed(_PluginBase): self.sites = SitesHelper() self.siteoper = SiteOper() self.torrent = TorrentHelper() + self.downloader_helper = DownloaderHelper() # 读取配置 if config: self._enabled = config.get("enabled") @@ -134,8 +133,6 @@ class IYUUAutoSeed(_PluginBase): if self.get_state() or self._onlyonce: self.iyuuhelper = IyuuHelper(token=self._token) self._scheduler = BackgroundScheduler(timezone=settings.TZ) - self.qb = Qbittorrent() - self.tr = Transmission() if self._onlyonce: logger.info(f"辅种服务启动,立即运行一次") @@ -305,38 +302,19 @@ class IYUUAutoSeed(_PluginBase): { 'component': 'VCol', 'props': { - 'cols': 12, - 'md': 6 + 'cols': 12 }, 'content': [ { 'component': 'VSelect', 'props': { - 'chips': True, 'multiple': True, + 'chips': True, + 'clearable': True, 'model': 'downloaders', - 'label': '辅种下载器', - 'items': [ - {'title': 'Qbittorrent', 'value': 'qbittorrent'}, - {'title': 'Transmission', 'value': 'transmission'} - ] - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'size', - 'label': '辅种体积大于(GB)', - 'placeholder': '只有大于该值的才辅种' + 'label': '下载器', + 'items': [{"title": config.name, "value": config.name} + for config in self.downloader_helper.get_configs().values()] } } ] @@ -515,6 +493,33 @@ class IYUUAutoSeed(_PluginBase): def get_page(self) -> List[dict]: pass + @property + def service_infos(self) -> Optional[Dict[str, ServiceInfo]]: + """ + 服务信息 + """ + if not self._downloaders: + logger.warning("尚未配置下载器,请检查配置") + return None + + services = self.downloader_helper.get_services(name_filters=self._downloaders) + if not services: + logger.warning("获取下载器实例失败,请检查配置") + return None + + active_services = {} + for service_name, service_info in services.items(): + if service_info.instance.is_inactive(): + logger.warning(f"下载器 {service_name} 未连接,请检查配置") + else: + active_services[service_name] = service_info + + if not active_services: + logger.warning("没有已连接的下载器,请检查配置") + return None + + return active_services + def __update_config(self): self.update_config({ "enabled": self._enabled, @@ -537,16 +542,11 @@ class IYUUAutoSeed(_PluginBase): "permanent_error_caches": self._permanent_error_caches }) - def __get_downloader(self, dtype: str): + def __get_downloader(self, name: str): """ 根据类型返回下载器实例 """ - if dtype == "qbittorrent": - return self.qb - elif dtype == "transmission": - return self.tr - else: - return None + return self.service_infos.get(name).instance def auto_seed(self): """ @@ -821,33 +821,37 @@ class IYUUAutoSeed(_PluginBase): """ 添加下载任务 """ - if downloader == "qbittorrent": + service = self.service_infos.get(downloader) + if not service: + logger.error(f"下载器 {downloader} 未连接,添加下载任务失败!") + return None + if service.type == "qbittorrent": # 生成随机Tag tag = StringUtils.generate_random_str(10) torrent_tags.append(tag) - state = self.qb.add_torrent(content=content, - download_dir=save_path, - is_paused=True, - tag=torrent_tags, - category=self._categoryafterseed, - is_skip_checking=self._skipverify) + state = service.instance.add_torrent(content=content, + download_dir=save_path, + is_paused=True, + tag=torrent_tags, + category=self._categoryafterseed, + is_skip_checking=self._skipverify) if not state: return None else: # 获取种子Hash - torrent_hash = self.qb.get_torrent_id_by_tag(tags=tag) + torrent_hash = service.instance.get_torrent_id_by_tag(tags=tag) if not torrent_hash: logger.error(f"{downloader} 下载任务添加成功,但获取任务信息失败!") return None return torrent_hash - elif downloader == "transmission": + elif service.type == "transmission": # 添加任务 - torrent = self.tr.add_torrent(content=content, - download_dir=save_path, - is_paused=True, - labels=torrent_tags) + torrent = service.instance.add_torrent(content=content, + download_dir=save_path, + is_paused=True, + labels=torrent_tags) if not torrent: return None else: @@ -1044,7 +1048,7 @@ class IYUUAutoSeed(_PluginBase): 判断是否为mteam站点 """ return True if "m-team." in url else False - + def __is_monika(url: str): """ 判断是否为monika站点 @@ -1063,7 +1067,7 @@ class IYUUAutoSeed(_PluginBase): 将mteam种子下载链接域名替换为使用API """ api_url = re.sub(r'//[^/]+\.m-team', '//api.m-team', site.get('url')) - + res = RequestUtils( headers={ 'Content-Type': 'application/json', @@ -1078,7 +1082,7 @@ class IYUUAutoSeed(_PluginBase): logger.warn(f"m-team 获取种子下载链接失败:{tid}") return None return res.json().get("data") - + def __get_monika_torrent(tid: str, rssurl: str): """ Monika下载需要使用rsskey从站点配置中获取并拼接下载链接 @@ -1086,11 +1090,10 @@ class IYUUAutoSeed(_PluginBase): if not rssurl: logger.error("Monika站点的rss链接未配置") return None - + rss_match = re.search(r'/rss/\d+\.(\w+)', rssurl) rsskey = rss_match.group(1) - download_url = f"{site.get('url')}torrents/download/{tid}.{rsskey}" - return download_url + return f"{site.get('url')}torrents/download/{tid}.{rsskey}" def __is_special_site(url: str): """ diff --git a/plugins.v2/torrentremover/__init__.py b/plugins.v2/torrentremover/__init__.py index 73848e0..8a87421 100644 --- a/plugins.v2/torrentremover/__init__.py +++ b/plugins.v2/torrentremover/__init__.py @@ -9,11 +9,10 @@ from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger from app.core.config import settings +from app.helper.downloader import DownloaderHelper from app.log import logger -from app.modules.qbittorrent import Qbittorrent -from app.modules.transmission import Transmission from app.plugins import _PluginBase -from app.schemas import NotificationType +from app.schemas import NotificationType, ServiceInfo from app.utils.string import StringUtils lock = threading.Lock() @@ -27,7 +26,7 @@ class TorrentRemover(_PluginBase): # 插件图标 plugin_icon = "delete.jpg" # 插件版本 - plugin_version = "1.2.2" + plugin_version = "2.0" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -40,8 +39,7 @@ class TorrentRemover(_PluginBase): auth_level = 2 # 私有属性 - qb = None - tr = None + downloader_helper = None _event = threading.Event() _scheduler = None _enabled = False @@ -65,6 +63,7 @@ class TorrentRemover(_PluginBase): _torrentcategorys = None def init_plugin(self, config: dict = None): + self.downloader_helper = DownloaderHelper() if config: self._enabled = config.get("enabled") self._onlyonce = config.get("onlyonce") @@ -88,8 +87,6 @@ class TorrentRemover(_PluginBase): self.stop_service() if self.get_state() or self._onlyonce: - self.qb = Qbittorrent() - self.tr = Transmission() if self._onlyonce: self._scheduler = BackgroundScheduler(timezone=settings.TZ) logger.info(f"自动删种服务启动,立即运行一次") @@ -254,14 +251,13 @@ class TorrentRemover(_PluginBase): { 'component': 'VSelect', 'props': { - 'chips': True, 'multiple': True, + 'chips': True, + 'clearable': True, 'model': 'downloaders', 'label': '下载器', - 'items': [ - {'title': 'Qbittorrent', 'value': 'qbittorrent'}, - {'title': 'Transmission', 'value': 'transmission'} - ] + 'items': [{"title": config.name, "value": config.name} + for config in self.downloader_helper.get_configs().values()] } } ] @@ -588,16 +584,38 @@ class TorrentRemover(_PluginBase): except Exception as e: print(str(e)) - def __get_downloader(self, dtype: str): + @property + def service_infos(self) -> Optional[Dict[str, ServiceInfo]]: + """ + 服务信息 + """ + if not self._downloaders: + logger.warning("尚未配置下载器,请检查配置") + return None + + services = self.downloader_helper.get_services(name_filters=self._downloaders) + if not services: + logger.warning("获取下载器实例失败,请检查配置") + return None + + active_services = {} + for service_name, service_info in services.items(): + if service_info.instance.is_inactive(): + logger.warning(f"下载器 {service_name} 未连接,请检查配置") + else: + active_services[service_name] = service_info + + if not active_services: + logger.warning("没有已连接的下载器,请检查配置") + return None + + return active_services + + def __get_downloader(self, name: str): """ 根据类型返回下载器实例 """ - if dtype == "qbittorrent": - return self.qb - elif dtype == "transmission": - return self.tr - else: - return None + return self.service_infos.get(name).instance def delete_torrents(self): """ From 2291946fe89b05b17a8e6f446a8de4e52a21ddb6 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 20 Oct 2024 00:15:03 +0800 Subject: [PATCH 112/218] fix ui --- plugins.v2/configcenter/__init__.py | 34 ++++++++++++++--------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/plugins.v2/configcenter/__init__.py b/plugins.v2/configcenter/__init__.py index 58178bb..f6ddf87 100644 --- a/plugins.v2/configcenter/__init__.py +++ b/plugins.v2/configcenter/__init__.py @@ -297,6 +297,23 @@ class ConfigCenter(_PluginBase): } ] }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "META_CACHE_EXPIRE", + "label": "元数据缓存时间(小时)", + "placeholder": "单位:小时" + } + } + ] + }, { "component": "VCol", "props": { @@ -372,23 +389,6 @@ class ConfigCenter(_PluginBase): } ] }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "META_CACHE_EXPIRE", - "label": "元数据缓存时间(小时)", - "placeholder": "单位:小时" - } - } - ] - }, { "component": "VCol", "props": { From 90cd402a126bb7a2c04385d4119a005452e17927 Mon Sep 17 00:00:00 2001 From: ramen <1205925392@qq.com> Date: Sun, 20 Oct 2024 08:51:18 +0800 Subject: [PATCH 113/218] =?UTF-8?q?chromium=E8=BF=90=E8=A1=8C=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E4=B8=BAheadless=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/dynamicwechat/__init__.py | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/plugins/dynamicwechat/__init__.py b/plugins/dynamicwechat/__init__.py index bf8e0de..7fb076c 100644 --- a/plugins/dynamicwechat/__init__.py +++ b/plugins/dynamicwechat/__init__.py @@ -18,7 +18,7 @@ from app.helper.cookiecloud import CookieCloudHelper from app.log import logger from app.plugins import _PluginBase from app.plugins.dynamicwechat.update_help import PyCookieCloud -from app.schemas.types import EventType, NotificationType +from app.schemas.types import EventType class DynamicWeChat(_PluginBase): @@ -29,7 +29,7 @@ class DynamicWeChat(_PluginBase): # 插件图标 plugin_icon = "Wecom_A.png" # 插件版本 - plugin_version = "1.1.4" + plugin_version = "1.1.5" # 插件作者 plugin_author = "RamenRa" # 作者主页 @@ -199,7 +199,7 @@ class DynamicWeChat(_PluginBase): for url in retry_urls: ip_address = self.get_ip_from_url(url) if ip_address != "获取IP失败" and ip_address: - logger.info(f"IP获取成功: {url}:{ip_address}") + logger.info(f"IP获取成功: {url}: {ip_address}") break # 如果所有 URL 请求失败 @@ -447,7 +447,7 @@ class DynamicWeChat(_PluginBase): def refresh_cookie(self): # 保活 try: with sync_playwright() as p: - browser = p.chromium.launch(headless=False, args=['--lang=zh-CN']) + browser = p.chromium.launch(headless=True, args=['--lang=zh-CN']) context = browser.new_context() cookie = self.get_cookie() if cookie: @@ -493,7 +493,7 @@ class DynamicWeChat(_PluginBase): confirm_button.click() # 点击确认 time.sleep(3) # 等待处理 # 等待登录成功的元素出现 - success_element = page.wait_for_selector('#check_corp_info', timeout=10000) + success_element = page.wait_for_selector('#check_corp_info', timeout=5000) if success_element: logger.info("验证码登录成功!") return True @@ -503,7 +503,7 @@ class DynamicWeChat(_PluginBase): except Exception as e: logger.debug(str(e)) # try: # 没有登录成功,也没有短信验证码。 查找二维码是否还存在 - if self.find_qrc(page) and not task != 'refresh_cookie': # 延长任务找到的二维码不会被发送,所以不算用户没有扫码 + if self.find_qrc(page) and not task == 'refresh_cookie': # 延长任务找到的二维码不会被发送,所以不算用户没有扫码 logger.error(f"用户没有扫描二维码") return False @@ -513,15 +513,16 @@ class DynamicWeChat(_PluginBase): buttons = [ # ("//span[@class='frame_nav_item_title' and text()='应用管理']", "应用管理"), # ("//div[@class='app_index_item_title ' and contains(text(), 'MoviePilot')]", "MoviePilot"), - ("//div[contains(@class, 'js_show_ipConfig_dialog')]//a[contains(@class, '_mod_card_operationLink') and text()='配置']", - "配置") + ( + "//div[contains(@class, 'js_show_ipConfig_dialog')]//a[contains(@class, '_mod_card_operationLink') and text()='配置']", + "配置") ] if self._input_id_list: id_list = self._input_id_list.split(",") app_urls = [f"{bash_url}{app_id.strip()}" for app_id in id_list] for app_url in app_urls: page.goto(app_url) # 打开应用详情页 - # logger.info(f"已打开{app_url}") + app_id = app_url.split("/")[-1] time.sleep(2) # 依次点击每个按钮 for xpath, name in buttons: @@ -535,7 +536,7 @@ class DynamicWeChat(_PluginBase): input_area = page.locator('textarea.js_ipConfig_textarea') confirm = page.locator('.js_ipConfig_confirmBtn') input_area.fill(self._current_ip_address) # 填充 IP 地址 - logger.info("已输入公网IP:" + self._current_ip_address) + logger.info(f"应用ID: {app_id} 已输入公网IP:" + self._current_ip_address) confirm.click() # 点击确认按钮 time.sleep(3) # 等待处理 self._ip_changed = True @@ -964,11 +965,3 @@ class DynamicWeChat(_PluginBase): except Exception as e: logger.error(str(e)) - - - - - - - - From a9c96d40eaf2fd10380af56e68a7921a7365a129 Mon Sep 17 00:00:00 2001 From: ramen <1205925392@qq.com> Date: Sun, 20 Oct 2024 08:58:42 +0800 Subject: [PATCH 114/218] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=88=B01.1.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 66f7bb3..3e0169a 100644 --- a/package.json +++ b/package.json @@ -858,7 +858,7 @@ "name": "修改企业微信可信IP", "description": "优先使用cookie,当填写两个第三方token时手机微信可以更新cookie。验证码以?结尾发给企业微信应用。如:110301?", "labels": "消息通知", - "version": "1.1.4", + "version": "1.1.5", "icon": "Wecom_A.png", "author": "RamenRa", "level": 2, From 6e175d2986104d6f7329c18fb7e11f9b1ed6ade3 Mon Sep 17 00:00:00 2001 From: RamenRa <53000259+RamenRa@users.noreply.github.com> Date: Sun, 20 Oct 2024 09:00:12 +0800 Subject: [PATCH 115/218] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=88=B01.1.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 66f7bb3..3e0169a 100644 --- a/package.json +++ b/package.json @@ -858,7 +858,7 @@ "name": "修改企业微信可信IP", "description": "优先使用cookie,当填写两个第三方token时手机微信可以更新cookie。验证码以?结尾发给企业微信应用。如:110301?", "labels": "消息通知", - "version": "1.1.4", + "version": "1.1.5", "icon": "Wecom_A.png", "author": "RamenRa", "level": 2, From 0465e27179d9790a400420643a686dbd113ad381 Mon Sep 17 00:00:00 2001 From: ramen <1205925392@qq.com> Date: Sun, 20 Oct 2024 09:01:10 +0800 Subject: [PATCH 116/218] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=88=B01.1.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 3e0169a..db664bf 100644 --- a/package.json +++ b/package.json @@ -863,6 +863,7 @@ "author": "RamenRa", "level": 2, "history": { + "v1.1.5": "chromium运行设置为headless模式", "v1.1.4": "放弃self.post_message()的消息推送,还原成send_pushplus_message()", "v1.1.3": "关闭cookie输入框,延长cookie任务成功时不输出日志,使用设定中的CookieCloud设置" } From 514091d359cdbf5b49debc5ad818502abf81e6c1 Mon Sep 17 00:00:00 2001 From: ramen <1205925392@qq.com> Date: Sun, 20 Oct 2024 09:03:42 +0800 Subject: [PATCH 117/218] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=88=B01.1.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index db664bf..cc8d895 100644 --- a/package.json +++ b/package.json @@ -863,7 +863,7 @@ "author": "RamenRa", "level": 2, "history": { - "v1.1.5": "chromium运行设置为headless模式", + "v1.1.5": "将chromium运行设置为headless模式", "v1.1.4": "放弃self.post_message()的消息推送,还原成send_pushplus_message()", "v1.1.3": "关闭cookie输入框,延长cookie任务成功时不输出日志,使用设定中的CookieCloud设置" } From dc925dfa1b8103396421f10e02b8beea4584dd1f Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sun, 20 Oct 2024 23:46:07 +0800 Subject: [PATCH 118/218] feat(ConfigCenter): ensure runtime settings sync with env --- plugins.v2/configcenter/__init__.py | 293 ++++++++++++++-------------- 1 file changed, 147 insertions(+), 146 deletions(-) diff --git a/plugins.v2/configcenter/__init__.py b/plugins.v2/configcenter/__init__.py index f6ddf87..350d3e6 100644 --- a/plugins.v2/configcenter/__init__.py +++ b/plugins.v2/configcenter/__init__.py @@ -1,8 +1,5 @@ -import copy from typing import Any, List, Dict, Tuple -from dotenv import set_key - from app.core.config import settings from app.core.module import ModuleManager from app.log import logger @@ -32,7 +29,6 @@ class ConfigCenter(_PluginBase): # 私有属性 _enabled = False _params = "" - _writeenv = False settings_attributes = [ "GITHUB_TOKEN", "API_TOKEN", "TMDB_API_DOMAIN", "TMDB_IMAGE_DOMAIN", "WALLPAPER", "RECOGNIZE_SOURCE", "SCRAP_FOLLOW_TMDB", "AUTO_DOWNLOAD_USER", @@ -46,62 +42,40 @@ class ConfigCenter(_PluginBase): if not config: return - self._enabled = config.get("enabled") - self._writeenv = config.get("writeenv") - if not self._enabled: - return + # 清理插件配置,从而实现默认使用.env中的数据源 + self._params = config.pop("params", "") + if "undefined" in config: + del config["undefined"] + self.update_config(config={}) + + # 将自定义配置存储到 __ConfigCenter__ + self.update_config(plugin_id="__ConfigCenter__", config={"params": self._params}) + logger.info(f"正在应用配置中心配置:{config}") - for attribute in self.settings_attributes: - setattr(settings, attribute, config.get(attribute) or getattr(settings, attribute)) - # 自定义配置,以换行分隔 - self._params = config.get("params") or "" - for key, value in self.__parse_params(self._params).items(): - if hasattr(settings, key): - setattr(settings, key, str(value)) + + # 追加自定义配置中的内容 + params = self.__parse_params(self._params) or {} + config.update(**params) + + # 批量更新配置,并获取更新结果 + update_results = settings.update_settings(config) + + # 遍历更新结果 + for key, (success, message) in update_results.items(): + if not success: + self.__log_and_notify_error(f"配置项 '{key}' 更新失败:{message}") + elif message: + self.__log_and_notify_error(f"配置项 '{key}' 更新时出现警告:{message}") # 重新加载模块 - ModuleManager().stop() - ModuleManager().load_modules() + ModuleManager().reload() - # 如果写入app.env文件,则关闭插件开关 - if self._writeenv: - # 写入env文件 - self.update_env(config) - # 自动关闭插件 - self._enabled = False - logger.info("配置中心设置已写入app.env文件,插件关闭...") - # 保存配置 - config.update({"enabled": False}) - self.update_config(config) - - def update_env(self, config: dict): + def __log_and_notify_error(self, message): """ - 更新设置到app.env + 记录错误日志并发送系统通知 """ - if not config: - return - - # 避免修改原值 - conf = copy.deepcopy(config) - - # 自定义配置,以换行分隔 - config_params = self.__parse_params(conf.get("params")) - conf.update(config_params) - # 读写app.env - env_path = settings.CONFIG_PATH / "app.env" - for key, value in conf.items(): - if not key: - continue - # 如果参数不在支持列表中, 则跳过 - if key not in self.settings_attributes and key not in config_params: - continue - if value is None or str(value) == "None": - value = '' - else: - value = str(value) - set_key(env_path, key, value) - logger.info("app.env文件写入完成") - self.systemmessage.put("配置中心设置已写入app.env文件,插件关闭", title="配置中心") + logger.error(message) + self.systemmessage.put(message, title=self.plugin_name) @staticmethod def __parse_params(param_str: str) -> dict: @@ -130,7 +104,7 @@ class ConfigCenter(_PluginBase): return result def get_state(self) -> bool: - return self._enabled + return True @staticmethod def get_command() -> List[Dict[str, Any]]: @@ -143,9 +117,13 @@ class ConfigCenter(_PluginBase): """ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 """ + config = self.get_config(plugin_id="__ConfigCenter__") or {} + params_str = config.get("params") or "" + params = self.__parse_params(params_str) or {} + updated_params = {key: getattr(settings, key) for key in params if hasattr(settings, key)} + params_str = "\n".join(f"{key}={value}" for key, value in updated_params.items()) default_settings = { - "enabled": False, - "params": "", + "params": params_str } for attribute in self.settings_attributes: default_settings[attribute] = getattr(settings, attribute) @@ -156,43 +134,6 @@ class ConfigCenter(_PluginBase): { "component": "VRow", "content": [ - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "enabled", - "label": "启用插件" - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "writeenv", - "label": "写入app.env文件" - } - } - ] - }, - ] - }, - { - 'component': 'VRow', - 'content': [ { "component": "VCol", "props": { @@ -204,7 +145,9 @@ class ConfigCenter(_PluginBase): "component": "VTextField", "props": { "model": "GITHUB_TOKEN", - "label": "Github Token" + "label": "GitHub Token", + "hint": "GitHub Token,提高请求API限流阈值,格式: ghp_****", + "persistent-hint": True } } ] @@ -220,7 +163,9 @@ class ConfigCenter(_PluginBase): "component": "VTextField", "props": { "model": "API_TOKEN", - "label": "API密钥" + "label": "API密钥", + "hint": "用于Jellyseerr/Overseerr、媒体服务器Webhook等配置以及部分支持API_TOKEN的API请求", + "persistent-hint": True } } ] @@ -236,7 +181,9 @@ class ConfigCenter(_PluginBase): "component": "VTextField", "props": { "model": "TMDB_API_DOMAIN", - "label": "TMDB API地址" + "label": "TMDB API地址", + "hint": "TMDB API地址,无需修改,或配置为其他中转代理服务地址,确保连通性", + "persistent-hint": True } } ] @@ -252,7 +199,9 @@ class ConfigCenter(_PluginBase): "component": "VTextField", "props": { "model": "TMDB_IMAGE_DOMAIN", - "label": "TheMovieDb图片服务器" + "label": "TheMovieDb图片服务器", + "hint": "TheMovieDb图片服务器,无需修改,或修改为其他可用地址如 static-mdb.v.geilijiasu.com", + "persistent-hint": True } } ] @@ -270,9 +219,17 @@ class ConfigCenter(_PluginBase): "model": "RECOGNIZE_SOURCE", "label": "媒体信息识别来源", "items": [ - {"title": "TheMovieDb", "value": "themoviedb"}, - {"title": "豆瓣", "value": "douban"} - ] + { + "title": "TheMovieDb", + "value": "themoviedb" + }, + { + "title": "豆瓣", + "value": "douban" + } + ], + "hint": "媒体信息识别来源", + "persistent-hint": True } } ] @@ -290,9 +247,17 @@ class ConfigCenter(_PluginBase): "model": "SCRAP_SOURCE", "label": "刮削元数据及图片使用的数据源", "items": [ - {"title": "TheMovieDb", "value": "themoviedb"}, - {"title": "豆瓣", "value": "douban"}, - ] + { + "title": "TheMovieDb", + "value": "themoviedb" + }, + { + "title": "豆瓣", + "value": "douban" + } + ], + "hint": "刮削元数据及图片使用的数据源", + "persistent-hint": True } } ] @@ -309,7 +274,8 @@ class ConfigCenter(_PluginBase): "props": { "model": "META_CACHE_EXPIRE", "label": "元数据缓存时间(小时)", - "placeholder": "单位:小时" + "hint": "元数据缓存过期时间,0为系统默认", + "persistent-hint": True } } ] @@ -327,9 +293,17 @@ class ConfigCenter(_PluginBase): "model": "WALLPAPER", "label": "登录首页电影海报", "items": [ - {"title": "TheMovieDb电影海报", "value": "tmdb"}, - {"title": "Bing每日壁纸", "value": "bing"} - ] + { + "title": "TheMovieDb电影海报", + "value": "tmdb" + }, + { + "title": "Bing每日壁纸", + "value": "bing" + } + ], + "hint": "登录首页电影海报", + "persistent-hint": True } } ] @@ -337,8 +311,8 @@ class ConfigCenter(_PluginBase): ] }, { - 'component': 'VRow', - 'content': [ + "component": "VRow", + "content": [ { "component": "VCol", "props": { @@ -350,7 +324,9 @@ class ConfigCenter(_PluginBase): "component": "VTextField", "props": { "model": "OCR_HOST", - "label": "验证码识别服务器" + "label": "验证码识别服务器", + "hint": "验证码识别服务器地址", + "persistent-hint": True } } ] @@ -366,8 +342,9 @@ class ConfigCenter(_PluginBase): "component": "VTextField", "props": { "model": "GITHUB_PROXY", - "label": "Github加速服务器", - "placeholder": "https://mirror.ghproxy.com/" + "label": "GitHub加速服务器", + "hint": "GitHub加速服务器,格式: https://mirror.ghproxy.com/", + "persistent-hint": True } } ] @@ -384,7 +361,8 @@ class ConfigCenter(_PluginBase): "props": { "model": "PIP_PROXY", "label": "PIP加速服务器", - "placeholder": "https://pypi.tuna.tsinghua.edu.cn/simple" + "hint": "PIP加速服务器,格式: https://pypi.tuna.tsinghua.edu.cn/simple", + "persistent-hint": True } } ] @@ -401,7 +379,8 @@ class ConfigCenter(_PluginBase): "props": { "model": "MEDIASERVER_SYNC_INTERVAL", "label": "媒体服务器同步间隔(小时)", - "placeholder": "单位:小时" + "hint": "媒体服务器同步间隔", + "persistent-hint": True } } ] @@ -409,8 +388,8 @@ class ConfigCenter(_PluginBase): ] }, { - 'component': 'VRow', - 'content': [ + "component": "VRow", + "content": [ { "component": "VCol", "props": { @@ -423,7 +402,8 @@ class ConfigCenter(_PluginBase): "props": { "model": "DOH_DOMAINS", "label": "DOH解析的域名", - "placeholder": "多个域名使用,分隔" + "hint": "DOH解析的域名列表,多个域名使用逗号分隔", + "persistent-hint": True } } ] @@ -440,7 +420,8 @@ class ConfigCenter(_PluginBase): "props": { "model": "DOH_RESOLVERS", "label": "DOH解析服务器", - "placeholder": "多个地址使用,分隔" + "hint": "DOH解析服务器列表,多个服务器使用逗号分隔", + "persistent-hint": True } } ] @@ -448,19 +429,21 @@ class ConfigCenter(_PluginBase): ] }, { - 'component': 'VRow', - 'content': [ + "component": "VRow", + "content": [ { "component": "VCol", "props": { - "cols": 12, + "cols": 12 }, "content": [ { "component": "VTextarea", "props": { "model": "MOVIE_RENAME_FORMAT", - "label": "电影重命名格式" + "label": "电影重命名格式", + "hint": "电影重命名格式,使用Jinja2语法,每行一个配置项,参考:https://jinja.palletsprojects.com/en/3.0.x/templates/", + "persistent-hint": True } } ] @@ -468,19 +451,21 @@ class ConfigCenter(_PluginBase): ] }, { - 'component': 'VRow', - 'content': [ + "component": "VRow", + "content": [ { "component": "VCol", "props": { - "cols": 12, + "cols": 12 }, "content": [ { "component": "VTextarea", "props": { "model": "TV_RENAME_FORMAT", - "label": "电视剧重命名格式" + "label": "电视剧重命名格式", + "hint": "电视剧重命名格式,使用Jinja2语法,参考:https://jinja.palletsprojects.com/en/3.0.x/templates/", + "persistent-hint": True } } ] @@ -488,12 +473,12 @@ class ConfigCenter(_PluginBase): ] }, { - 'component': 'VRow', - 'content': [ + "component": "VRow", + "content": [ { "component": "VCol", "props": { - "cols": 12, + "cols": 12 }, "content": [ { @@ -501,7 +486,8 @@ class ConfigCenter(_PluginBase): "props": { "model": "PLUGIN_MARKET", "label": "插件市场", - "placeholder": "多个地址使用,分隔" + "hint": "插件市场仓库地址,多个地址使用逗号分隔,确保每个地址以/结尾", + "persistent-hint": True } } ] @@ -509,12 +495,12 @@ class ConfigCenter(_PluginBase): ] }, { - 'component': 'VRow', - 'content': [ + "component": "VRow", + "content": [ { "component": "VCol", "props": { - "cols": 12, + "cols": 12 }, "content": [ { @@ -522,7 +508,8 @@ class ConfigCenter(_PluginBase): "props": { "model": "params", "label": "自定义配置", - "placeholder": "每行一个配置项,格式:配置项=值" + "hint": "自定义配置,每行一个配置项,格式:配置项=值", + "persistent-hint": True } } ] @@ -530,8 +517,8 @@ class ConfigCenter(_PluginBase): ] }, { - 'component': 'VRow', - 'content': [ + "component": "VRow", + "content": [ { "component": "VCol", "props": { @@ -543,7 +530,9 @@ class ConfigCenter(_PluginBase): "component": "VSwitch", "props": { "model": "DOWNLOAD_SUBTITLE", - "label": "自动下载站点字幕" + "label": "自动下载站点字幕", + "hint": "自动下载站点字幕(如有)", + "persistent-hint": True } } ] @@ -559,7 +548,9 @@ class ConfigCenter(_PluginBase): "component": "VSwitch", "props": { "model": "SCRAP_FOLLOW_TMDB", - "label": "新增入库跟随TMDB信息变化" + "label": "新增入库跟随TMDB信息变化", + "hint": "新增入库媒体是否跟随TMDB信息变化", + "persistent-hint": True } } ] @@ -575,7 +566,9 @@ class ConfigCenter(_PluginBase): "component": "VSwitch", "props": { "model": "FANART_ENABLE", - "label": "使用Fanart图片数据源" + "label": "使用Fanart图片数据源", + "hint": "启用Fanart图片数据源", + "persistent-hint": True } } ] @@ -591,7 +584,9 @@ class ConfigCenter(_PluginBase): "component": "VSwitch", "props": { "model": "DOH_ENABLE", - "label": "启用DNS over HTTPS" + "label": "启用DNS over HTTPS", + "hint": "是否启用DNS over HTTPS,启用后对特定域名使用DOH解析以避免DNS污染", + "persistent-hint": True } } ] @@ -607,7 +602,9 @@ class ConfigCenter(_PluginBase): "component": "VSwitch", "props": { "model": "SEARCH_MULTIPLE_NAME", - "label": "资源搜索整合多名称搜索结果" + "label": "资源搜索整合多名称搜索结果", + "hint": "搜索多个名称时是否整合多名称的搜索结果,True/false", + "persistent-hint": True } } ] @@ -623,7 +620,9 @@ class ConfigCenter(_PluginBase): "component": "VSwitch", "props": { "model": "AUXILIARY_AUTH_ENABLE", - "label": "启用用户辅助认证" + "label": "启用用户辅助认证", + "hint": "是否启用用户辅助认证,允许通过外部服务进行认证、单点登录以及自动创建用户", + "persistent-hint": True } } ] @@ -639,7 +638,9 @@ class ConfigCenter(_PluginBase): "component": "VSwitch", "props": { "model": "GLOBAL_IMAGE_CACHE", - "label": "全局图片缓存" + "label": "全局图片缓存", + "hint": "是否启用全局图片缓存,将媒体图片缓存到本地", + "persistent-hint": True } } ] @@ -658,9 +659,9 @@ class ConfigCenter(_PluginBase): { 'component': 'VAlert', 'props': { - 'type': 'info', + 'type': 'warning', 'variant': 'tonal', - 'text': '注意:开启写入app.env后将直接修改配置文件,否则只是运行时修改生效对应配置(插件关闭且重启后配置失效);有些自定义配置需要重启才能生效。' + 'text': '注意:部分配置项的更改可能需要重启服务才能生效,为确保配置一致性,已在环境变量中的相关配置项,请手动更新' } } ] From fbb757c4b78b4a2f779779f04ab0ca521e65a59b Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sun, 20 Oct 2024 23:53:16 +0800 Subject: [PATCH 119/218] feat(ConfigCenter): v3.1 --- package.v2.json | 3 ++- plugins.v2/configcenter/__init__.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/package.v2.json b/package.v2.json index a59e5a6..fa00b79 100644 --- a/package.v2.json +++ b/package.v2.json @@ -179,11 +179,12 @@ "name": "配置中心", "description": "快速调整部分系统设定。", "labels": "系统设置", - "version": "3.0", + "version": "3.1", "icon": "setting.png", "author": "jxxghp", "level": 1, "history": { + "v3.1": "重构配置更新逻辑,从而与主程序保持一致", "v3.0": "兼容MoviePilot V2 版本", "v2.6": "支持DOH相关配置项", "v2.5": "增加Github加速服务器设置项" diff --git a/plugins.v2/configcenter/__init__.py b/plugins.v2/configcenter/__init__.py index 350d3e6..fce874b 100644 --- a/plugins.v2/configcenter/__init__.py +++ b/plugins.v2/configcenter/__init__.py @@ -14,7 +14,7 @@ class ConfigCenter(_PluginBase): # 插件图标 plugin_icon = "setting.png" # 插件版本 - plugin_version = "3.0" + plugin_version = "3.1" # 插件作者 plugin_author = "jxxghp" # 作者主页 From 8505f6f1b70553da9f0b81123507be1576e4fabf Mon Sep 17 00:00:00 2001 From: thsrite Date: Mon, 21 Oct 2024 15:18:44 +0800 Subject: [PATCH 120/218] =?UTF-8?q?fix=20=E4=BF=AE=E5=A4=8D=E8=BE=85?= =?UTF-8?q?=E5=8A=A9=E8=AF=86=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 3 ++- plugins.v2/chatgpt/__init__.py | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/package.v2.json b/package.v2.json index fa00b79..5bc4652 100644 --- a/package.v2.json +++ b/package.v2.json @@ -77,11 +77,12 @@ "name": "ChatGPT", "description": "消息交互支持与ChatGPT对话。", "labels": "消息通知,识别", - "version": "2.0", + "version": "2.0.1", "icon": "Chatgpt_A.png", "author": "jxxghp", "level": 1, "history": { + "v2.0.1": "修复辅助识别", "v2.0": "适配MoviePilot V2 版本,采用链式事件机制" } }, diff --git a/plugins.v2/chatgpt/__init__.py b/plugins.v2/chatgpt/__init__.py index 3ba05b6..bf64853 100644 --- a/plugins.v2/chatgpt/__init__.py +++ b/plugins.v2/chatgpt/__init__.py @@ -16,7 +16,7 @@ class ChatGPT(_PluginBase): # 插件图标 plugin_icon = "Chatgpt_A.png" # 插件版本 - plugin_version = "2.0" + plugin_version = "2.0.1" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -235,6 +235,8 @@ class ChatGPT(_PluginBase): """ 监听识别事件,使用ChatGPT辅助识别名称 """ + if not self._recognize: + return if not event.event_data: return title = event.event_data.get("title") From 6d6de8b22f4e0175feebc05f2da521639cb26d48 Mon Sep 17 00:00:00 2001 From: Attente <19653207+wikrin@users.noreply.github.com> Date: Mon, 21 Oct 2024 20:37:01 +0800 Subject: [PATCH 121/218] =?UTF-8?q?feat:=20=E4=BF=AE=E5=A4=8D=E4=BB=8EBGM?= =?UTF-8?q?=E8=8E=B7=E5=8F=96=E7=9A=84=E6=80=BB=E9=9B=86=E6=95=B0=E4=BC=9A?= =?UTF-8?q?=E8=A2=ABTMDB=E8=A6=86=E7=9B=96=E7=9A=84=E9=97=AE=E9=A2=98,=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=BC=80=E5=85=B3=E9=80=89=E9=A1=B9=E4=BB=A5?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=20=20=20=20=20=20=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=BA=95=E9=83=A8=E8=AF=B4=E6=98=8E.=20other:=20=E5=88=86?= =?UTF-8?q?=E7=A6=BB=E7=BB=84=E4=BB=B6,=20=E7=BB=84=E4=BB=B6=E6=94=AF?= =?UTF-8?q?=E6=8C=81HTML=E6=A0=BC=E5=BC=8F=E7=9A=84=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 8 +- plugins/bangumicoll/__init__.py | 314 +++++------------------- plugins/bangumicoll/page_components.py | 318 +++++++++++++++++++++++++ 3 files changed, 386 insertions(+), 254 deletions(-) create mode 100644 plugins/bangumicoll/page_components.py diff --git a/package.json b/package.json index 2e84c46..82e637b 100644 --- a/package.json +++ b/package.json @@ -3,17 +3,15 @@ "name": "Bangumi收藏订阅", "description": "Bangumi用户收藏添加到订阅", "labels": "订阅", - "version": "1.4", + "version": "1.5", "icon": "https://raw.githubusercontent.com/wikrin/MoviePilot-Plugins/main/icons/bangumi_b.png", "author": "Attente", "level": 1, "v2": true, "history": { + "v1.5": "修复总集数会同步TMDB变动的问题,增加开关选项", "v1.4": "结构优化", - "v1.3.1": "修复因修改季号导致未下载剧集而完成订阅的问题", - "v1.3": "添加订阅逻辑优化", - "v1.2.2": "新增: 订阅添加失败总览 修复: 其他方式添加的订阅反复添加的问题", - "v1.2.1": "修复tmdb没有查询到条目导致插件崩溃的问题" + "v1.3.1": "修复因修改季号导致未下载剧集而完成订阅的问题" } } } diff --git a/plugins/bangumicoll/__init__.py b/plugins/bangumicoll/__init__.py index 21d071e..192d236 100644 --- a/plugins/bangumicoll/__init__.py +++ b/plugins/bangumicoll/__init__.py @@ -1,11 +1,20 @@ +# 基础库 import datetime import json -import pytz from typing import Any, Dict, List, Optional, Type +# 第三方库 +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +import pytz +from sqlalchemy import JSON +from sqlalchemy.orm import Session + +# 项目库 from app.chain.subscribe import SubscribeChain, Subscribe from app.core.config import settings from app.core.context import MediaInfo +from app.core.meta import MetaBase from app.core.metainfo import MetaInfo from app.db.models.subscribehistory import SubscribeHistory from app.db.site_oper import SiteOper @@ -16,10 +25,6 @@ from app.log import logger from app.plugins import _PluginBase from app.schemas.types import NotificationType from app.utils.http import RequestUtils -from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.triggers.cron import CronTrigger -from sqlalchemy import JSON -from sqlalchemy.orm import Session class BangumiColl(_PluginBase): @@ -30,7 +35,7 @@ class BangumiColl(_PluginBase): # 插件图标 plugin_icon = "https://raw.githubusercontent.com/wikrin/MoviePilot-Plugins/main/icons/bangumi_b.png" # 插件版本 - plugin_version = "1.4" + plugin_version = "1.5" # 插件作者 plugin_author = "Attente" # 作者主页 @@ -50,6 +55,7 @@ class BangumiColl(_PluginBase): # 配置属性 _enabled: bool = False + _total_change: bool = False _cron: str = "" _notify: bool = False _onlyonce: bool = False @@ -76,16 +82,19 @@ class BangumiColl(_PluginBase): def load_config(self, config: dict): """加载配置""" if config: - self._enabled = config.get("enabled", self._enabled) - self._cron = config.get("cron", self._cron) - self._notify = config.get("notify", self._notify) - self._onlyonce = config.get("onlyonce", self._onlyonce) - self._include = config.get("include", self._include) - self._exclude = config.get("exclude", self._exclude) - self._uid = config.get("uid", self._uid) - self._collection_type = config.get("collection_type", [3]) - self._save_path = config.get("save_path", self._save_path) - self._sites = config.get("sites", self._sites) + # 遍历配置中的键并设置相应的属性 + for key in ( + "enabled", + "total_change", + "cron", + "notify", + "onlyonce", + "uid", + "collection_type", + "save_path", + "sites", + ): + setattr(self, f"_{key}", config.get(key, getattr(self, f"_{key}"))) def schedule_once(self): """调度一次性任务""" @@ -109,6 +118,7 @@ class BangumiColl(_PluginBase): { "enabled": self._enabled, "notify": self._notify, + "total_change": self._total_change, "onlyonce": self._onlyonce, "cron": self._cron, "uid": self._uid, @@ -121,199 +131,15 @@ class BangumiColl(_PluginBase): ) def get_form(self): + from .page_components import form + # 列出所有站点 sites_options = [ {"title": site.name, "value": site.id} for site in self.siteoper.list_order_by_pri() ] - return [ - { - 'component': 'VForm', - 'content': [ - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': {'cols': 12, 'md': 4}, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'enabled', - 'label': '启用插件', - }, - } - ], - }, - { - 'component': 'VCol', - 'props': {'cols': 12, 'md': 4}, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'notify', - 'label': '自动取消订阅并通知', - }, - } - ], - }, - { - 'component': 'VCol', - 'props': {'cols': 12, 'md': 4}, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'onlyonce', - 'label': '立即运行一次', - }, - } - ], - }, - ], - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': {'cols': 12, 'md': 6}, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'cron', - 'label': '执行周期', - 'placeholder': '5位cron表达式,留空自动', - }, - } - ], - }, - ], - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': {'cols': 12, 'md': 6}, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'uid', - 'label': 'UID/用户名', - 'placeholder': '设置了用户名填写用户名,否则填写UID', - }, - }, - ], - }, - { - 'component': 'VCol', - 'props': {'cols': 12, 'md': 6}, - 'content': [ - { - 'component': 'VSelect', - 'props': { - 'model': 'collection_type', - 'label': '收藏类型', - 'chips': True, - 'multiple': True, - 'items': [ - {'title': '在看', 'value': 3}, - {'title': '想看', 'value': 1}, - ], - }, - } - ], - }, - ], - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': {'cols': 12, 'md': 6}, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'include', - 'label': '包含', - 'placeholder': '暂未实现', - }, - } - ], - }, - { - 'component': 'VCol', - 'props': {'cols': 12, 'md': 6}, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'exclude', - 'label': '排除', - 'placeholder': '暂未实现', - }, - } - ], - }, - ], - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': {'cols': 12, 'md': 6}, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'save_path', - 'label': '保存目录', - 'placeholder': '留空自动', - }, - } - ], - }, - { - 'component': 'VCol', - 'props': {'cols': 12, 'md': 6}, - 'content': [ - { - 'component': 'VSelect', - 'props': { - 'model': 'sites', - 'label': '选择站点', - 'chips': True, - 'multiple': True, - 'items': sites_options, - }, - } - ], - }, - ], - }, - ], - } - ], { - "enabled": False, - "notify": False, - "onlyonce": False, - "cron": "", - "uid": "", - "collection_type": [3], - "include": "", - "exclude": "", - "save_path": "", - "sites": [], - } + return form(sites_options) def get_service(self) -> List[Dict[str, Any]]: """注册插件公共服务""" @@ -372,27 +198,22 @@ class BangumiColl(_PluginBase): def parse_collection_items(self, response) -> Dict[int, Dict[str, Any]]: """解析获取的收藏条目""" - data = response.json().get("data") + data = response.json().get("data", []) if not data: logger.error(f"Bangumi用户:{self._uid} ,没有任何收藏") return {} - items = {} logger.info("解析Bangumi条目信息...") - for item in data: - if item.get("type") not in self._collection_type: - logger.debug( - f"条目: {item['subject'].get('name_cn')} 类型:{item.get('type')} 不符合" - ) - continue - - items[item.get("subject_id")] = { + return { + item.get("subject_id"): { "name": item['subject'].get('name'), "name_cn": item['subject'].get('name_cn'), "date": item['subject'].get('date'), "eps": item['subject'].get('eps'), } - return items + for item in data + if item.get("type") in self._collection_type + } def manage_subscriptions(self, items: Dict[int, Dict[str, Any]]): """管理订阅的新增和删除""" @@ -422,6 +243,7 @@ class BangumiColl(_PluginBase): # 添加订阅 def add_subscribe(self, items: Dict[int, Dict[str, Any]]) -> Dict: """添加订阅""" + fail_items = {} for self._subid, item in items.items(): meta = MetaInfo(item.get("name_cn")) @@ -432,6 +254,7 @@ class BangumiColl(_PluginBase): meta.year = item.get("date")[:4] if item.get("date") else None mediainfo = self.chain.recognize_media(meta=meta) + meta.total_episode = item.get("eps", 0) if not mediainfo: fail_items[self._subid] = f"{item.get('name_cn')} 媒体信息识别失败" continue @@ -442,33 +265,28 @@ class BangumiColl(_PluginBase): mediainfo.tmdb_id, mediainfo.number_of_seasons ) if sid: - logger.info(f"{mediainfo.title_year} {meta.season} 正在订阅中") + logger.info(f"{mediainfo.title_year} 正在订阅中") if len(sid) == 1: self.subscribeoper.update( sid=sid[0].id, payload={"bangumiid": self._subid} ) - logger.info( - f"{mediainfo.title_year} {meta.season} Bangumi条目id更新成功" - ) + logger.info(f"{mediainfo.title_year} Bangumi条目id更新成功") continue sid, msg = self.subscribechain.add( title=mediainfo.title, year=mediainfo.year, - mtype=mediainfo.type, - tmdbid=mediainfo.tmdb_id, bangumiid=self._subid, - season=mediainfo.number_of_seasons, exist_ok=True, username="Bangumi订阅", - **self.prepare_kwargs(item, meta.begin_season, mediainfo), + **self.prepare_kwargs(meta, mediainfo), ) if not sid: fail_items[self._subid] = f"{item.get('name_cn')} {msg}" return fail_items - def prepare_kwargs(self, item: dict, meta_season: int, mediainfo: MediaInfo): + def prepare_kwargs(self, meta: MetaBase, mediainfo: MediaInfo) -> Dict: """准备额外参数""" kwargs = { "save_path": self._save_path, @@ -479,14 +297,24 @@ class BangumiColl(_PluginBase): ), } - if self.check_series_info(meta_season, item.get("eps", 0), mediainfo): - begin_ep, total_ep = self.get_eps() - prev_eps: list = [i for i in range(1, begin_ep)] + total_episode = len(mediainfo.seasons.get(mediainfo.number_of_seasons) or []) + if ( + meta.begin_season + and mediainfo.number_of_seasons != meta.begin_season + or total_episode != meta.total_episode + ): + meta = self.get_eps(meta) + total_ep: int = meta.end_episode if meta.end_episode else total_episode + lock_eps: int = total_ep - meta.begin_episode + 1 + prev_eps: list = [i for i in range(1, meta.begin_episode)] kwargs.update( { "total_episode": total_ep, - "start_episode": begin_ep, - "lack_episode": total_ep - begin_ep + 1, + "start_episode": meta.begin_episode, + "lack_episode": lock_eps, + "manual_total_episode": ( + 1 if meta.total_episode and self._total_change else 0 + ), # 手动修改过总集数 "note": ( prev_eps if self.are_types_equal("note") @@ -495,23 +323,12 @@ class BangumiColl(_PluginBase): } ) logger.info( - f"{mediainfo.title_year} 更新总集数为: {total_ep},开始集数为: {begin_ep}" + f"{mediainfo.title_year} 更新总集数为: {total_ep},开始集数为: {meta.begin_episode}" ) return kwargs - @staticmethod - def check_series_info(meta_season: int, bgm_eps: int, mediainfo: MediaInfo) -> bool: - """检查系列信息是否不一致""" - total_episode = len(mediainfo.seasons.get(mediainfo.number_of_seasons) or []) - return ( - meta_season - and mediainfo.number_of_seasons != meta_season - or (bgm_eps != 0 and total_episode != bgm_eps) - or (bgm_eps == 0 and not total_episode >= 12) - ) - - def update_media_info(self, item, mediainfo): + def update_media_info(self, item: dict, mediainfo: MediaInfo): """更新媒体信息""" for info in mediainfo.season_info: if self.are_dates(item.get("date"), info.get("air_date")): @@ -519,20 +336,19 @@ class BangumiColl(_PluginBase): mediainfo.number_of_episodes = info.get("episode_count") break - def get_eps(self) -> tuple: + def get_eps(self, meta: MetaBase) -> MetaBase: """获取Bangumi条目的集数信息""" try: res = self.get_bgm_res(addr="getEpisodes", id=self._subid) data = res.json().get("data", [{}])[0] - ep = data.get("ep", 1) - sort = data.get("sort", 1) - total = res.json().get("total", 24) - begin_ep = sort - ep + 1 - total_ep = sort - ep + total - return begin_ep, total_ep + prev = data.get("sort", 1) - data.get("ep", 1) + total = res.json().get("total", None) + meta.begin_episode = prev + 1 + meta.end_episode = prev + total if total else None except Exception as e: logger.error(f"获取集数信息失败: {str(e)}") - return 1, 24 # 默认值 + finally: + return meta # 移除订阅 def delete_subscribe(self, del_items: Dict[int, int]): diff --git a/plugins/bangumicoll/page_components.py b/plugins/bangumicoll/page_components.py new file mode 100644 index 0000000..044299c --- /dev/null +++ b/plugins/bangumicoll/page_components.py @@ -0,0 +1,318 @@ +from bs4 import BeautifulSoup + + +def form(sites_options) -> list: + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 3}, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + }, + } + ], + }, + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 3}, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '自动取消订阅并通知', + }, + } + ], + }, + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 3}, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'total_change', + 'label': '不跟随TMDB变动', + }, + } + ], + }, + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 3}, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + }, + } + ], + }, + ], + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': {'cols': 8, 'md': 4}, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '执行周期', + 'placeholder': '5位cron表达式,留空自动', + }, + } + ], + }, + { + 'component': 'VCol', + 'props': {'cols': 8, 'md': 4}, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'uid', + 'label': 'UID/用户名', + 'placeholder': '设置了用户名填写用户名,否则填写UID', + }, + }, + ], + }, + { + 'component': 'VCol', + 'props': {'cols': 8, 'md': 4}, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'collection_type', + 'label': '收藏类型', + 'chips': True, + 'multiple': True, + 'items': [ + {'title': '在看', 'value': 3}, + {'title': '想看', 'value': 1}, + ], + }, + } + ], + }, + ], + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 6}, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'include', + 'label': '包含', + 'placeholder': '暂未实现', + }, + } + ], + }, + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 6}, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'exclude', + 'label': '排除', + 'placeholder': '暂未实现', + }, + } + ], + }, + ], + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 6}, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'save_path', + 'label': '保存目录', + 'placeholder': '留空自动', + }, + } + ], + }, + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 6}, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'sites', + 'label': '选择站点', + 'chips': True, + 'multiple': True, + 'items': sites_options, + }, + } + ], + }, + ], + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + }, + 'content': parse_html( + '

注意: 该插件仅会将公开的收藏添加到订阅

' + ), + } + ], + } + ], + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + }, + 'content': parse_html( + '

注意: 开启自动取消订阅并通知后,已添加的订阅在下一次执行时若不在已选择的收藏类型中,将会被取消订阅。

' + ), + } + ], + } + ], + }, + ], + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + }, + 'content': parse_html( + '

注意: 开启不跟随TMDB变动后,从Bangumi API获取的总集数将不再跟随TMDB的集数变动。

' + ), + }, + ], + }, + ], + }, + ], { + "enabled": False, + "total_change": False, + "notify": False, + "onlyonce": False, + "cron": "", + "uid": "", + "collection_type": [3], + "include": "", + "exclude": "", + "save_path": "", + "sites": [], + } + + +def parse_html(html_string: str) -> list: + soup = BeautifulSoup(html_string, 'html.parser') + result: list = [] + + # 定义需要直接转为文本的标签 + inline_text_tags = {'strong', 'u', 'em', 'b', 'i'} + + def process_element(element: BeautifulSoup): + # 处理纯文本节点 + if element.name is None: + text = element.strip() + return text if text else "" + + # 处理HTML标签 + component = element.name + props = {attr: element[attr] for attr in element.attrs} + content = [] + + # 递归处理子元素 + for child in element.children: + child_content = process_element(child) + if isinstance(child_content, str): + content.append({'component': 'span', 'text': child_content}) + elif child_content: # 只有在child_content不为空时添加 + content.append(child_content) + + # 构建标签对象 + tag_data = { + 'component': component, + 'props': props, + 'content': content if component not in inline_text_tags else [], + } + + if content and component in inline_text_tags: + tag_data['text'] = ' '.join( + item['text'] for item in content if 'text' in item + ) + + return tag_data + + # 遍历所有子元素 + for element in soup.children: + element_content = process_element(element) + if element_content: # 只增加非空内容 + result.append(element_content) + + return result From 505df4345b6f3baf09ad754372efcefbb3ae5ade Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Mon, 21 Oct 2024 21:59:27 +0800 Subject: [PATCH 122/218] feat(ConfigCenter): v3.1.1 --- package.v2.json | 2 +- plugins.v2/configcenter/__init__.py | 1009 ++++++++++++++------------- 2 files changed, 526 insertions(+), 485 deletions(-) diff --git a/package.v2.json b/package.v2.json index fa00b79..3a9d10d 100644 --- a/package.v2.json +++ b/package.v2.json @@ -179,7 +179,7 @@ "name": "配置中心", "description": "快速调整部分系统设定。", "labels": "系统设置", - "version": "3.1", + "version": "3.1.1", "icon": "setting.png", "author": "jxxghp", "level": 1, diff --git a/plugins.v2/configcenter/__init__.py b/plugins.v2/configcenter/__init__.py index fce874b..62bd3fe 100644 --- a/plugins.v2/configcenter/__init__.py +++ b/plugins.v2/configcenter/__init__.py @@ -14,7 +14,7 @@ class ConfigCenter(_PluginBase): # 插件图标 plugin_icon = "setting.png" # 插件版本 - plugin_version = "3.1" + plugin_version = "3.1.1" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -29,14 +29,6 @@ class ConfigCenter(_PluginBase): # 私有属性 _enabled = False _params = "" - settings_attributes = [ - "GITHUB_TOKEN", "API_TOKEN", "TMDB_API_DOMAIN", "TMDB_IMAGE_DOMAIN", "WALLPAPER", - "RECOGNIZE_SOURCE", "SCRAP_FOLLOW_TMDB", "AUTO_DOWNLOAD_USER", - "OCR_HOST", "DOWNLOAD_SUBTITLE", "PLUGIN_MARKET", "MOVIE_RENAME_FORMAT", - "TV_RENAME_FORMAT", "FANART_ENABLE", "DOH_ENABLE", "SEARCH_MULTIPLE_NAME", "META_CACHE_EXPIRE", - "GITHUB_PROXY", "DOH_DOMAINS", "DOH_RESOLVERS", "AUXILIARY_AUTH_ENABLE", "MEDIASERVER_SYNC_INTERVAL", - "PIP_PROXY", "GLOBAL_IMAGE_CACHE" - ] def init_plugin(self, config: dict = None): if not config: @@ -117,560 +109,609 @@ class ConfigCenter(_PluginBase): """ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 """ + default_settings = {} + settings_model = self.get_settings_model() + keys = self.extract_keys(settings_model) + for key in keys: + if hasattr(settings, key): + default_settings[key] = getattr(settings, key) + config = self.get_config(plugin_id="__ConfigCenter__") or {} params_str = config.get("params") or "" params = self.__parse_params(params_str) or {} updated_params = {key: getattr(settings, key) for key in params if hasattr(settings, key)} params_str = "\n".join(f"{key}={value}" for key, value in updated_params.items()) - default_settings = { - "params": params_str - } - for attribute in self.settings_attributes: - default_settings[attribute] = getattr(settings, attribute) + default_settings["params"] = params_str + return [ { "component": "VForm", + "content": settings_model + } + ], default_settings + + def extract_keys(self, components: List[dict]) -> List[str]: + """ + 递归提取所有组件中的model键 + """ + models = [] + for component in components: + # 检查当前组件的props中是否有model + props = component.get("props", {}) + model = props.get("model") + if model: + models.append(model) + + # 如果当前组件有嵌套的content,递归提取 + nested_content = component.get("content", []) + if isinstance(nested_content, list): + models.extend(self.extract_keys(nested_content)) + elif isinstance(nested_content, dict): + models.extend(self.extract_keys([nested_content])) + + return models + + @staticmethod + def get_settings_model() -> List[dict]: + """ + 获取配置项模型 + """ + return [ + { + "component": "VRow", "content": [ { - "component": "VRow", + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, "content": [ { - "component": "VCol", + "component": "VTextField", "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "GITHUB_TOKEN", - "label": "GitHub Token", - "hint": "GitHub Token,提高请求API限流阈值,格式: ghp_****", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "API_TOKEN", - "label": "API密钥", - "hint": "用于Jellyseerr/Overseerr、媒体服务器Webhook等配置以及部分支持API_TOKEN的API请求", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "TMDB_API_DOMAIN", - "label": "TMDB API地址", - "hint": "TMDB API地址,无需修改,或配置为其他中转代理服务地址,确保连通性", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "TMDB_IMAGE_DOMAIN", - "label": "TheMovieDb图片服务器", - "hint": "TheMovieDb图片服务器,无需修改,或修改为其他可用地址如 static-mdb.v.geilijiasu.com", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VSelect", - "props": { - "model": "RECOGNIZE_SOURCE", - "label": "媒体信息识别来源", - "items": [ - { - "title": "TheMovieDb", - "value": "themoviedb" - }, - { - "title": "豆瓣", - "value": "douban" - } - ], - "hint": "媒体信息识别来源", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VSelect", - "props": { - "model": "SCRAP_SOURCE", - "label": "刮削元数据及图片使用的数据源", - "items": [ - { - "title": "TheMovieDb", - "value": "themoviedb" - }, - { - "title": "豆瓣", - "value": "douban" - } - ], - "hint": "刮削元数据及图片使用的数据源", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "META_CACHE_EXPIRE", - "label": "元数据缓存时间(小时)", - "hint": "元数据缓存过期时间,0为系统默认", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VSelect", - "props": { - "model": "WALLPAPER", - "label": "登录首页电影海报", - "items": [ - { - "title": "TheMovieDb电影海报", - "value": "tmdb" - }, - { - "title": "Bing每日壁纸", - "value": "bing" - } - ], - "hint": "登录首页电影海报", - "persistent-hint": True - } - } - ] + "model": "GITHUB_TOKEN", + "label": "GitHub Token", + "hint": "GitHub Token,提高请求API限流阈值,格式: ghp_****", + "persistent-hint": True + } } ] }, { - "component": "VRow", + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, "content": [ { - "component": "VCol", + "component": "VTextField", "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "OCR_HOST", - "label": "验证码识别服务器", - "hint": "验证码识别服务器地址", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "GITHUB_PROXY", - "label": "GitHub加速服务器", - "hint": "GitHub加速服务器,格式: https://mirror.ghproxy.com/", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "PIP_PROXY", - "label": "PIP加速服务器", - "hint": "PIP加速服务器,格式: https://pypi.tuna.tsinghua.edu.cn/simple", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "MEDIASERVER_SYNC_INTERVAL", - "label": "媒体服务器同步间隔(小时)", - "hint": "媒体服务器同步间隔", - "persistent-hint": True - } - } - ] + "model": "API_TOKEN", + "label": "API密钥", + "hint": "用于Jellyseerr/Overseerr、媒体服务器Webhook等配置以及部分支持API_TOKEN的API请求", + "persistent-hint": True + } } ] }, { - "component": "VRow", + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, "content": [ { - "component": "VCol", + "component": "VTextField", "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "DOH_DOMAINS", - "label": "DOH解析的域名", - "hint": "DOH解析的域名列表,多个域名使用逗号分隔", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "DOH_RESOLVERS", - "label": "DOH解析服务器", - "hint": "DOH解析服务器列表,多个服务器使用逗号分隔", - "persistent-hint": True - } - } - ] + "model": "TMDB_API_DOMAIN", + "label": "TMDB API地址", + "hint": "TMDB API地址,无需修改,或配置为其他中转代理服务地址,确保连通性", + "persistent-hint": True + } } ] }, { - "component": "VRow", + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, "content": [ { - "component": "VCol", + "component": "VTextField", "props": { - "cols": 12 - }, - "content": [ - { - "component": "VTextarea", - "props": { - "model": "MOVIE_RENAME_FORMAT", - "label": "电影重命名格式", - "hint": "电影重命名格式,使用Jinja2语法,每行一个配置项,参考:https://jinja.palletsprojects.com/en/3.0.x/templates/", - "persistent-hint": True - } - } - ] + "model": "TMDB_IMAGE_DOMAIN", + "label": "TheMovieDb图片服务器", + "hint": "TheMovieDb图片服务器,无需修改,或修改为其他可用地址如 static-mdb.v.geilijiasu.com", + "persistent-hint": True + } } ] }, { - "component": "VRow", + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, "content": [ { - "component": "VCol", + "component": "VSelect", "props": { - "cols": 12 - }, - "content": [ - { - "component": "VTextarea", - "props": { - "model": "TV_RENAME_FORMAT", - "label": "电视剧重命名格式", - "hint": "电视剧重命名格式,使用Jinja2语法,参考:https://jinja.palletsprojects.com/en/3.0.x/templates/", - "persistent-hint": True + "model": "RECOGNIZE_SOURCE", + "label": "媒体信息识别来源", + "items": [ + { + "title": "TheMovieDb", + "value": "themoviedb" + }, + { + "title": "豆瓣", + "value": "douban" } - } - ] + ], + "hint": "媒体信息识别来源", + "persistent-hint": True + } } ] }, { - "component": "VRow", + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, "content": [ { - "component": "VCol", + "component": "VSelect", "props": { - "cols": 12 - }, - "content": [ - { - "component": "VTextarea", - "props": { - "model": "PLUGIN_MARKET", - "label": "插件市场", - "hint": "插件市场仓库地址,多个地址使用逗号分隔,确保每个地址以/结尾", - "persistent-hint": True + "model": "SCRAP_SOURCE", + "label": "刮削元数据及图片使用的数据源", + "items": [ + { + "title": "TheMovieDb", + "value": "themoviedb" + }, + { + "title": "豆瓣", + "value": "douban" } - } - ] + ], + "hint": "刮削元数据及图片使用的数据源", + "persistent-hint": True + } } ] }, { - "component": "VRow", + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, "content": [ { - "component": "VCol", + "component": "VTextField", "props": { - "cols": 12 - }, - "content": [ - { - "component": "VTextarea", - "props": { - "model": "params", - "label": "自定义配置", - "hint": "自定义配置,每行一个配置项,格式:配置项=值", - "persistent-hint": True - } - } - ] + "model": "META_CACHE_EXPIRE", + "label": "元数据缓存时间(小时)", + "hint": "元数据缓存过期时间,0为系统默认", + "persistent-hint": True + } } ] }, { - "component": "VRow", + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, "content": [ { - "component": "VCol", + "component": "VSelect", "props": { - "cols": 12, - "md": 4 - }, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "DOWNLOAD_SUBTITLE", - "label": "自动下载站点字幕", - "hint": "自动下载站点字幕(如有)", - "persistent-hint": True + "model": "WALLPAPER", + "label": "登录首页电影海报", + "items": [ + { + "title": "TheMovieDb电影海报", + "value": "tmdb" + }, + { + "title": "Bing每日壁纸", + "value": "bing" } - } - ] - }, + ], + "hint": "登录首页电影海报", + "persistent-hint": True + } + } + ] + } + ] + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ { - "component": "VCol", + "component": "VTextField", "props": { - "cols": 12, - "md": 4 - }, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "SCRAP_FOLLOW_TMDB", - "label": "新增入库跟随TMDB信息变化", - "hint": "新增入库媒体是否跟随TMDB信息变化", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 4 - }, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "FANART_ENABLE", - "label": "使用Fanart图片数据源", - "hint": "启用Fanart图片数据源", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 4 - }, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "DOH_ENABLE", - "label": "启用DNS over HTTPS", - "hint": "是否启用DNS over HTTPS,启用后对特定域名使用DOH解析以避免DNS污染", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 4 - }, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "SEARCH_MULTIPLE_NAME", - "label": "资源搜索整合多名称搜索结果", - "hint": "搜索多个名称时是否整合多名称的搜索结果,True/false", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 4 - }, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "AUXILIARY_AUTH_ENABLE", - "label": "启用用户辅助认证", - "hint": "是否启用用户辅助认证,允许通过外部服务进行认证、单点登录以及自动创建用户", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 4 - }, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "GLOBAL_IMAGE_CACHE", - "label": "全局图片缓存", - "hint": "是否启用全局图片缓存,将媒体图片缓存到本地", - "persistent-hint": True - } - } - ] + "model": "OCR_HOST", + "label": "验证码识别服务器", + "hint": "验证码识别服务器地址", + "persistent-hint": True + } } ] }, { - 'component': 'VRow', + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "GITHUB_PROXY", + "label": "GitHub加速服务器", + "hint": "GitHub加速服务器,格式: https://mirror.ghproxy.com/", + "persistent-hint": True + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "PIP_PROXY", + "label": "PIP加速服务器", + "hint": "PIP加速服务器,格式: https://pypi.tuna.tsinghua.edu.cn/simple", + "persistent-hint": True + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "MEDIASERVER_SYNC_INTERVAL", + "label": "媒体服务器同步间隔(小时)", + "hint": "媒体服务器同步间隔", + "persistent-hint": True + } + } + ] + } + ] + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "DOH_DOMAINS", + "label": "DOH解析的域名", + "hint": "DOH解析的域名列表,多个域名使用逗号分隔", + "persistent-hint": True + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "DOH_RESOLVERS", + "label": "DOH解析服务器", + "hint": "DOH解析服务器列表,多个服务器使用逗号分隔", + "persistent-hint": True + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12 + }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "AUTO_DOWNLOAD_USER", + "label": "交互搜索自动下载用户ID", + "hint": "使用,分割,设置为 all 代表所有用户自动择优下载,未设置需要用户手动选择资源或者回复`0`才自动择优下载", + "persistent-hint": True + } + } + ] + } + ] + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": { + "cols": 12 + }, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "MOVIE_RENAME_FORMAT", + "label": "电影重命名格式", + "hint": "电影重命名格式,使用Jinja2语法,每行一个配置项,参考:https://jinja.palletsprojects.com/en/3.0.x/templates/", + "persistent-hint": True + } + } + ] + } + ] + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": { + "cols": 12 + }, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "TV_RENAME_FORMAT", + "label": "电视剧重命名格式", + "hint": "电视剧重命名格式,使用Jinja2语法,参考:https://jinja.palletsprojects.com/en/3.0.x/templates/", + "persistent-hint": True + } + } + ] + } + ] + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": { + "cols": 12 + }, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "PLUGIN_MARKET", + "label": "插件市场", + "hint": "插件市场仓库地址,多个地址使用逗号分隔,确保每个地址以/结尾", + "persistent-hint": True + } + } + ] + } + ] + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": { + "cols": 12 + }, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "params", + "label": "自定义配置", + "hint": "自定义配置,每行一个配置项,格式:配置项=值", + "persistent-hint": True + } + } + ] + } + ] + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": { + "cols": 12, + "md": 4 + }, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "DOWNLOAD_SUBTITLE", + "label": "自动下载站点字幕", + "hint": "自动下载站点字幕(如有)", + "persistent-hint": True + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 4 + }, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "SCRAP_FOLLOW_TMDB", + "label": "新增入库跟随TMDB信息变化", + "hint": "新增入库媒体是否跟随TMDB信息变化", + "persistent-hint": True + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 4 + }, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "FANART_ENABLE", + "label": "使用Fanart图片数据源", + "hint": "启用Fanart图片数据源", + "persistent-hint": True + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 4 + }, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "DOH_ENABLE", + "label": "启用DNS over HTTPS", + "hint": "是否启用DNS over HTTPS,启用后对特定域名使用DOH解析以避免DNS污染", + "persistent-hint": True + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 4 + }, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "SEARCH_MULTIPLE_NAME", + "label": "资源搜索整合多名称搜索结果", + "hint": "搜索多个名称时是否整合多名称的搜索结果,True/false", + "persistent-hint": True + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 4 + }, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "AUXILIARY_AUTH_ENABLE", + "label": "启用用户辅助认证", + "hint": "是否启用用户辅助认证,允许通过外部服务进行认证、单点登录以及自动创建用户", + "persistent-hint": True + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 4 + }, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "GLOBAL_IMAGE_CACHE", + "label": "全局图片缓存", + "hint": "是否启用全局图片缓存,将媒体图片缓存到本地", + "persistent-hint": True + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, 'content': [ { - 'component': 'VCol', + 'component': 'VAlert', 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'warning', - 'variant': 'tonal', - 'text': '注意:部分配置项的更改可能需要重启服务才能生效,为确保配置一致性,已在环境变量中的相关配置项,请手动更新' - } - } - ] + 'type': 'warning', + 'variant': 'tonal', + 'text': '注意:部分配置项的更改可能需要重启服务才能生效,为确保配置一致性,已在环境变量中的相关配置项,请手动更新' + } } ] } ] } - ], default_settings + ] def get_page(self) -> List[dict]: pass From e399aeb6332ffef30a423bbca98aba6892f93a93 Mon Sep 17 00:00:00 2001 From: ramen <1205925392@qq.com> Date: Tue, 22 Oct 2024 11:20:02 +0800 Subject: [PATCH 123/218] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=BF=9C=E7=A8=8B?= =?UTF-8?q?=E5=91=BD=E4=BB=A4/push=5Fqr=EF=BC=8C=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 5 +- plugins/dynamicwechat/__init__.py | 304 +++++++++++++++++++++--------- 2 files changed, 219 insertions(+), 90 deletions(-) diff --git a/package.json b/package.json index cc8d895..b726740 100644 --- a/package.json +++ b/package.json @@ -856,13 +856,14 @@ }, "DynamicWeChat": { "name": "修改企业微信可信IP", - "description": "优先使用cookie,当填写两个第三方token时手机微信可以更新cookie。验证码以?结尾发给企业微信应用。如:110301?", + "description": "优先使用cookie,可本地扫码刷新Cookie,当填写两个第三方token时可手机远程更新cookie。", "labels": "消息通知", - "version": "1.1.5", + "version": "1.2.0", "icon": "Wecom_A.png", "author": "RamenRa", "level": 2, "history": { + "v1.2.0": "远程命令/push_qr,立即推送一次二维码到pushplus。添加<本地扫码刷新cookie>", "v1.1.5": "将chromium运行设置为headless模式", "v1.1.4": "放弃self.post_message()的消息推送,还原成send_pushplus_message()", "v1.1.3": "关闭cookie输入框,延长cookie任务成功时不输出日志,使用设定中的CookieCloud设置" diff --git a/plugins/dynamicwechat/__init__.py b/plugins/dynamicwechat/__init__.py index 7fb076c..ded1a5c 100644 --- a/plugins/dynamicwechat/__init__.py +++ b/plugins/dynamicwechat/__init__.py @@ -2,6 +2,7 @@ import io import random import re import time +import base64 from datetime import datetime, timedelta from typing import Optional from typing import Tuple, List, Dict, Any @@ -25,11 +26,11 @@ class DynamicWeChat(_PluginBase): # 插件名称 plugin_name = "修改企业微信可信IP" # 插件描述 - plugin_desc = "优先使用cookie,当填写两个第三方token时手机微信可以更新cookie。验证码以?结尾发给企业微信应用。如:110301?" + plugin_desc = "优先使用cookie,可本地扫码刷新Cookie,当填写两个第三方token时手机微信可以更新cookie。" # 插件图标 plugin_icon = "Wecom_A.png" # 插件版本 - plugin_version = "1.1.5" + plugin_version = "1.2.0" # 插件作者 plugin_author = "RamenRa" # 作者主页 @@ -49,8 +50,10 @@ class DynamicWeChat(_PluginBase): _ip_changed = False # 强制更改IP _forced_update = False + # CloudCookie服务器 _cc_server = None - _push_qr_now = False + # 本地扫码开关 + _local_scan = False # 匹配ip地址的正则 _ip_pattern = r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b' @@ -62,14 +65,19 @@ class DynamicWeChat(_PluginBase): _wechatUrl = 'https://work.weixin.qq.com/wework_admin/loginpage_wx?from=myhome' # 检测间隔时间,默认10分钟 _refresh_cron = '*/20 * * * *' - # _urls = [] + # 输入的企业应用id _input_id_list = '' + # helloimg的token _helloimg_s_token = "" + # pushplus的token _pushplus_token = "" + # 二维码 _qr_code_image = None text = "" + # 手机验证码 _verification_code = '' - # _app_ids = [] + # 过期时间 + _future_timestamp = 0 # -------cookie add------------ # cookie有效检测 @@ -98,6 +106,7 @@ class DynamicWeChat(_PluginBase): self._forced_update = False # self._cookie_valid = False self._use_cookiecloud = True + self._local_scan = False self._input_id_list = '' self._cookie_header = "" self._cookie_from_CC = "" @@ -114,6 +123,7 @@ class DynamicWeChat(_PluginBase): self._helloimg_s_token = config.get("helloimg_s_token") self._cookie_from_CC = config.get("cookie_from_CC") self._forced_update = config.get("forced_update") + self._local_scan = config.get("local_scan") self._use_cookiecloud = config.get("use_cookiecloud") self._cookie_header = config.get("cookie_header") self._ip_changed = config.get("ip_changed") @@ -144,6 +154,12 @@ class DynamicWeChat(_PluginBase): # 关闭一次性开关 self._onlyonce = False + if self._local_scan: + self._scheduler.add_job(func=self.local_scanning, trigger='date', + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="本地扫码登陆") # 添加任务 + self._local_scan = False + # 固定半小时周期请求一次地址,防止cookie失效 try: self._scheduler.add_job(func=self.refresh_cookie, @@ -162,6 +178,51 @@ class DynamicWeChat(_PluginBase): self._forced_update = False self.__update_config() + @eventmanager.register(EventType.PluginAction) + def local_scanning(self, event: Event = None): + """ + 本地扫码 + """ + if not self._enabled: + logger.error("插件未开启") + return + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "dynamicwechat": + return + + try: + with sync_playwright() as p: + browser = p.chromium.launch(headless=True, args=['--lang=zh-CN']) + context = browser.new_context() + page = context.new_page() + page.goto(self._wechatUrl) + time.sleep(3) # 页面加载等待时间 + + current_time = datetime.now() + future_time = current_time + timedelta(seconds=110) + self._future_timestamp = int(future_time.timestamp()) + + if self.find_qrc(page): + logger.info("请<重新进入!>插件面板扫码!,每20秒检查登录状态,最大尝试5次") + max_attempts = 5 + attempt = 0 + while attempt < max_attempts: + attempt += 1 + # logger.info(f"第 {attempt} 次检查登录状态...") + time.sleep(20) # 每20秒检查一次 + if self.check_login_status(page, task='local_scanning'): + logger.info("登录成功,更新cookie") + self._update_cookie(page, context) # 刷新cookie + break + else: + logger.info("未检测到登录,任务结束") + else: + logger.info("未找到二维码,任务结束") + browser.close() + except Exception as e: + logger.error(f"本地扫码任务: 本地扫码失败: {e}") + @eventmanager.register(EventType.PluginAction) def check(self, event: Event = None): """ @@ -175,10 +236,10 @@ class DynamicWeChat(_PluginBase): event_data = event.event_data if not event_data or event_data.get("action") != "dynamicwechat": return - logger.info("收到命令,开始检测公网IP ...") - self.post_message(channel=event.event_data.get("channel"), - title="开始检测公网IP ...", - userid=event.event_data.get("user")) + # logger.info("收到命令,开始检测公网IP ...") + # self.post_message(channel=event.event_data.get("channel"), + # title="开始检测公网IP ...", + # userid=event.event_data.get("user")) logger.info("开始检测公网IP") if self.CheckIP(): @@ -278,45 +339,6 @@ class DynamicWeChat(_PluginBase): } response = requests.post(pushplus_url, json=pushplus_data) - def remote_push_qr(self): - try: - with sync_playwright() as p: - # 启动 Chromium 浏览器并设置语言为中文 - browser = p.chromium.launch(headless=True, args=['--lang=zh-CN']) - context = browser.new_context() - # ----------cookie addd----------------- - # cookie = self.get_cookie() - # if cookie: - # context.add_cookies(cookie) - # ----------cookie END----------------- - page = context.new_page() - page.goto(self._wechatUrl) - time.sleep(3) - if self.find_qrc(page): - if self._pushplus_token and self._helloimg_s_token: - img_src, refuse_time = self.upload_image(self._qr_code_image) - self.send_pushplus_message(refuse_time, f"企业微信登录二维码
") - # if img_src: - # self.post_message( - # mtype=NotificationType.Plugin, - # title="企业微信登录二维码", - # text=refuse_time, - # image=img_src - # ) - logger.info("二维码已经发送,等待用户 90 秒内扫码登录") - logger.info("如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301?") - time.sleep(90) - login_status = self.check_login_status(page, '') - if login_status: - self._update_cookie(page, context) # 刷新cookie - self.click_app_management_buttons(page) - else: - logger.warning("远程推送任务 未配置pushplus_token 或 helloimg_s_token") - else: - logger.warning("远程推送任务 未找到二维码") - browser.close() - except Exception as e: - logger.error(f"远程推送任务 推送二维码失败: {e}") def ChangeIP(self): logger.info("开始请求企业微信管理更改可信IP") @@ -345,7 +367,7 @@ class DynamicWeChat(_PluginBase): # image=img_src # ) logger.info("二维码已经发送,等待用户 90 秒内扫码登录") - logger.info("如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301?") + # logger.info("如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301?") time.sleep(90) # 等待用户扫码 login_status = self.check_login_status(page, "") if login_status: @@ -483,9 +505,10 @@ class DynamicWeChat(_PluginBase): # 在这里使用更安全的方式来检查元素是否存在 captcha_panel = page.wait_for_selector('.receive_captcha_panel', timeout=5000) # 检查验证码面板 if captcha_panel: # 出现了短信验证界面 + logger.info("等待30秒,请将短信验证码请以'?'结束,发送到<企业微信应用> 如: 110301?") time.sleep(30) # 多等30秒 if self._verification_code: - logger.info("需要短信验证 收到的短信验证码:" + self._verification_code) + logger.info("输入验证码:" + self._verification_code) for digit in self._verification_code: page.keyboard.press(digit) time.sleep(0.3) # 每个数字之间添加少量间隔以确保输入顺利 @@ -501,8 +524,8 @@ class DynamicWeChat(_PluginBase): logger.error("未收到短信验证码") return False except Exception as e: - logger.debug(str(e)) - # try: # 没有登录成功,也没有短信验证码。 查找二维码是否还存在 + # logger.debug(str(e)) # 基于bug运行,请不要将错误输出到日志 + # try: # 没有登录成功,也没有短信验证码 if self.find_qrc(page) and not task == 'refresh_cookie': # 延长任务找到的二维码不会被发送,所以不算用户没有扫码 logger.error(f"用户没有扫描二维码") return False @@ -536,7 +559,6 @@ class DynamicWeChat(_PluginBase): input_area = page.locator('textarea.js_ipConfig_textarea') confirm = page.locator('.js_ipConfig_confirmBtn') input_area.fill(self._current_ip_address) # 填充 IP 地址 - logger.info(f"应用ID: {app_id} 已输入公网IP:" + self._current_ip_address) confirm.click() # 点击确认按钮 time.sleep(3) # 等待处理 self._ip_changed = True @@ -544,7 +566,9 @@ class DynamicWeChat(_PluginBase): logger.error(f"未能找打开{app_url}或点击 '{name}' 按钮异常: {e}") self._ip_changed = False if "disabled" in str(e): - logger.info("该应用已被禁用,可能是没有设置接收api") + logger.info(f"应用{app_id} 已被禁用,可能是没有设置接收api") + if self._ip_changed: + logger.info(f"应用: {app_id} 输入IP:" + self._current_ip_address) return else: logger.error("未找到应用id,修改IP失败") @@ -626,11 +650,11 @@ class DynamicWeChat(_PluginBase): "current_ip_address": self._current_ip_address, "ip_changed": self._ip_changed, "forced_update": self._forced_update, + "local_scan": self._local_scan, "helloimg_s_token": self._helloimg_s_token, "pushplus_token": self._pushplus_token, "input_id_list": self._input_id_list, # "standalone_chrome_address": self._diy_server, - "cookie_from_CC": self._cookie_from_CC, "cookie_header": self._cookie_header, "use_cookiecloud": self._use_cookiecloud, @@ -693,7 +717,7 @@ class DynamicWeChat(_PluginBase): 'component': 'VSwitch', 'props': { 'model': 'forced_update', - 'label': '强制更新', + 'label': '强制更新IP', } } ] @@ -719,6 +743,22 @@ class DynamicWeChat(_PluginBase): } } ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'local_scan', + 'label': '本地扫码刷新Cookie', + } + } + ] } ] }, @@ -744,28 +784,6 @@ class DynamicWeChat(_PluginBase): } ] }, - # { - # 'component': 'VRow', - # 'content': [ - # { - # 'component': 'VCol', - # 'props': { - # 'cols': 12 - # }, - # 'content': [ - # { - # 'component': 'VTextarea', - # 'props': { - # 'model': 'cookie_header', - # 'label': 'COOKIE', - # 'rows': 1, - # 'placeholder': '手动填写cookie' - # } - # } - # ] - # } - # ] - # }, { 'component': 'VRow', 'content': [ @@ -878,35 +896,146 @@ class DynamicWeChat(_PluginBase): "onlyonce": False, "forceUpdate": False, "use_cookiecloud": True, - # "wechatUrl": "", + "use_local_qr": False, # 默认关闭本地扫码 "cookie_header": "", "pushplus_token": "", "helloimg_token": "", "input_id_list": "", } + def get_page(self) -> List[dict]: - pass + # 获取当前时间戳 + current_time = datetime.now().timestamp() + + # 判断二维码是否过期 + if current_time > self._future_timestamp: + vaild_text = "二维码已过期" + color = "#ff0000" + else: + # 二维码有效,格式化过期时间为 年-月-日 时:分:秒 + expiration_time = datetime.fromtimestamp(self._future_timestamp).strftime('%Y-%m-%d %H:%M:%S') + vaild_text = f"二维码有效,过期时间: {expiration_time}" + color = "#32CD32" + + # 如果self._qr_code_image为None,返回提示信息 + if self._qr_code_image is None: + img_component = { + "component": "div", + "text": "本地扫码刷新cookie任务未运行", + "props": { + "style": { + "fontSize": "22px", + "color": "#ff0000", + "textAlign": "center", + "margin": "20px" + } + } + } + else: + # 获取二维码图片数据 + qr_image_data = self._qr_code_image.getvalue() + # 将图片数据转为 base64 编码 + base64_image = base64.b64encode(qr_image_data).decode('utf-8') + img_src = f"data:image/png;base64,{base64_image}" + + # 生成图片组件 + img_component = { + "component": "img", + "props": { + "src": img_src, + "style": { + "width": "auto", + "height": "auto", + "maxWidth": "100%", + "maxHeight": "100%", + "display": "block", + "margin": "0 auto" + } + } + } + + # 页面内容,显示二维码状态信息和二维码图片或提示信息 + base_content = [ + { + "component": "div", + "props": { + "style": { + "textAlign": "center" + } + }, + "content": [ + { + "component": "div", + "text": vaild_text, + "props": { + "style": { + "fontSize": "22px", + "fontWeight": "bold", + "color": "#ffffff", + "backgroundColor": color, + "padding": "8px", + "borderRadius": "5px", + "display": "inline-block", + "textAlign": "center", + "marginBottom": "40px" + } + } + } + ] + }, + img_component # 添加二维码图片或提示信息 + ] + + return base_content @eventmanager.register(EventType.PluginAction) - def push_qr(self, event: Event = None): + def push_qr_code(self, event: Event = None): """ - 发送二维码 + 立即发送二维码 """ + if not self._enabled: + return if event: event_data = event.event_data if not event_data or event_data.get("action") != "push_qrcode": return - logger.info("远程命令开始推送二维码") - self.remote_push_qr() + try: + with sync_playwright() as p: + # 启动 Chromium 浏览器并设置语言为中文 + browser = p.chromium.launch(headless=True, args=['--lang=zh-CN']) + context = browser.new_context() + page = context.new_page() + page.goto(self._wechatUrl) + time.sleep(3) + if self.find_qrc(page): + if self._pushplus_token and self._helloimg_s_token: + img_src, refuse_time = self.upload_image(self._qr_code_image) + self.send_pushplus_message(refuse_time, f"企业微信登录二维码
") + logger.info("远程推送任务: 二维码已经发送,等待用户 90 秒内扫码登录") + # logger.info("远程推送任务: 如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301?") + time.sleep(90) + login_status = self.check_login_status(page, 'push_qr_code') + if login_status: + if self._use_cookiecloud and self._cc_server: + self._update_cookie(page, context) # 刷新cookie + else: + logger.info("远程推送任务: 没有可用的CookieCloud服务器,只修改可信IP") + self.click_app_management_buttons(page) + else: + logger.warning("远程推送任务: 未配置pushplus_token和helloimg_s_token") + else: + logger.warning("远程推送任务: 未找到二维码") + except Exception as e: + logger.error(f"远程推送任务: 推送二维码失败: {e}") @staticmethod def get_command() -> List[Dict[str, Any]]: return [ { - "cmd": "/push_qr_code", + "cmd": "/push_qr", "event": EventType.PluginAction, - "desc": "立即推送登录二维码到微信", + "desc": "立即推送登录二维码到pushplus", "category": "", "data": { "action": "push_qrcode" @@ -963,5 +1092,4 @@ class DynamicWeChat(_PluginBase): self._scheduler.shutdown() self._scheduler = None except Exception as e: - logger.error(str(e)) - + logger.error(str(e)) \ No newline at end of file From 33b6f048d44fb97e9500b51036af69de244edb15 Mon Sep 17 00:00:00 2001 From: Aqr-K <95741669+Aqr-K@users.noreply.github.com> Date: Tue, 22 Oct 2024 21:16:00 +0800 Subject: [PATCH 124/218] style:(ConfigCenter): v3.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 优化显示,按功能区分 --- package.v2.json | 3 +- plugins.v2/configcenter/__init__.py | 1295 ++++++++++++++++----------- 2 files changed, 771 insertions(+), 527 deletions(-) diff --git a/package.v2.json b/package.v2.json index cd33676..35c9333 100644 --- a/package.v2.json +++ b/package.v2.json @@ -180,11 +180,12 @@ "name": "配置中心", "description": "快速调整部分系统设定。", "labels": "系统设置", - "version": "3.1.1", + "version": "3.2", "icon": "setting.png", "author": "jxxghp", "level": 1, "history": { + "v3.2": "优化显示,按功能区分", "v3.1": "重构配置更新逻辑,从而与主程序保持一致", "v3.0": "兼容MoviePilot V2 版本", "v2.6": "支持DOH相关配置项", diff --git a/plugins.v2/configcenter/__init__.py b/plugins.v2/configcenter/__init__.py index 62bd3fe..62f266a 100644 --- a/plugins.v2/configcenter/__init__.py +++ b/plugins.v2/configcenter/__init__.py @@ -14,7 +14,7 @@ class ConfigCenter(_PluginBase): # 插件图标 plugin_icon = "setting.png" # 插件版本 - plugin_version = "3.1.1" + plugin_version = "3.2" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -38,6 +38,8 @@ class ConfigCenter(_PluginBase): self._params = config.pop("params", "") if "undefined" in config: del config["undefined"] + if "_tabs" in config: + del config["_tabs"] self.update_config(config={}) # 将自定义配置存储到 __ConfigCenter__ @@ -164,553 +166,794 @@ class ConfigCenter(_PluginBase): "component": "VCol", "props": { "cols": 12, - "md": 6 }, "content": [ { - "component": "VTextField", + "component": "VAlert", "props": { - "model": "GITHUB_TOKEN", - "label": "GitHub Token", - "hint": "GitHub Token,提高请求API限流阈值,格式: ghp_****", - "persistent-hint": True + "type": "warning", + "variant": "tonal", + "text": "注意:部分配置项的更改可能需要重启服务才能生效,为确保配置一致性,已在环境变量中的相关配置项,请手动更新" } } ] + } + ] + }, + { + "component": "VTabs", + "props": { + "model": "_tabs", + "height": 72, + "fixed-tabs": True, + "style": { + "margin-top": "8px", + "margin-bottom": "10px", + } + }, + "content": [ + { + "component": "VTab", + "props": { + "value": "basic_tab", + "style": { + "padding-top": "10px", + "padding-bottom": "10px", + "font-size": "16px" + }, + }, + "text": "基础设置" }, { - "component": "VCol", + "component": "VTab", "props": { - "cols": 12, - "md": 6 + "value": "network_tab", + "style": { + "padding-top": "10px", + "padding-bottom": "10px", + "font-size": "16px" + }, + }, + "text": "网络设置" + }, + { + "component": "VTab", + "props": { + "value": "media_and_download_tab", + "style": { + "padding-top": "10px", + "padding-bottom": "10px", + "font-size": "16px" + }, + }, + "text": "媒体与下载" + }, + { + "component": "VTab", + "props": { + "value": "search_and_transfer_tab", + "style": { + "padding-top": "10px", + "padding-bottom": "10px", + "font-size": "16px" + }, + }, + "text": "搜索与整理" + }, + { + "component": "VTab", + "props": { + "value": "params_tab", + "style": { + "padding-top": "10px", + "padding-bottom": "10px", + "font-size": "16px" + }, + }, + "text": "自定义配置" + }, + ] + }, + { + "component": "VWindow", + "props": { + "model": "_tabs", + }, + "content": [ + # 备份分类块 + # { + # "component": "VWindowItem", + # "props": { + # "value": "client_setting", + # "style": { + # "padding-top": "20px", + # "padding-bottom": "20px" + # }, + # }, + # "content": [ + # { + # "component": "VRow", + # "props": { + # "align": "center" + # }, + # "content": [] + # } + # ] + # }, + # 基础 + { + "component": "VWindowItem", + "props": { + "value": "basic_tab", + "style": { + "padding-top": "20px", + "padding-bottom": "20px" + }, }, "content": [ { - "component": "VTextField", + "component": "VRow", "props": { - "model": "API_TOKEN", - "label": "API密钥", - "hint": "用于Jellyseerr/Overseerr、媒体服务器Webhook等配置以及部分支持API_TOKEN的API请求", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "TMDB_API_DOMAIN", - "label": "TMDB API地址", - "hint": "TMDB API地址,无需修改,或配置为其他中转代理服务地址,确保连通性", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "TMDB_IMAGE_DOMAIN", - "label": "TheMovieDb图片服务器", - "hint": "TheMovieDb图片服务器,无需修改,或修改为其他可用地址如 static-mdb.v.geilijiasu.com", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VSelect", - "props": { - "model": "RECOGNIZE_SOURCE", - "label": "媒体信息识别来源", - "items": [ - { - "title": "TheMovieDb", - "value": "themoviedb" + "align": "center" + }, + "content": [ + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6, }, - { - "title": "豆瓣", - "value": "douban" - } - ], - "hint": "媒体信息识别来源", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VSelect", - "props": { - "model": "SCRAP_SOURCE", - "label": "刮削元数据及图片使用的数据源", - "items": [ - { - "title": "TheMovieDb", - "value": "themoviedb" + "content": [ + { + "component": "VSwitch", + "props": { + "model": "AUXILIARY_AUTH_ENABLE", + "label": "启用用户辅助认证", + "hint": "启用后允许通过外部服务进行认证、单点登录以及自动创建用户", + "persistent-hint": True + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6, }, - { - "title": "豆瓣", - "value": "douban" - } - ], - "hint": "刮削元数据及图片使用的数据源", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "META_CACHE_EXPIRE", - "label": "元数据缓存时间(小时)", - "hint": "元数据缓存过期时间,0为系统默认", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VSelect", - "props": { - "model": "WALLPAPER", - "label": "登录首页电影海报", - "items": [ - { - "title": "TheMovieDb电影海报", - "value": "tmdb" + "content": [ + { + "component": "VSwitch", + "props": { + "model": "GLOBAL_IMAGE_CACHE", + "label": "全局图片缓存", + "hint": "是否启用全局图片缓存,将媒体图片缓存到本地", + "persistent-hint": True + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6. }, - { - "title": "Bing每日壁纸", - "value": "bing" - } - ], - "hint": "登录首页电影海报", - "persistent-hint": True - } + "content": [ + { + "component": "VSelect", + "props": { + "model": "WALLPAPER", + "label": "登录首页电影海报", + "items": [ + { + "title": "TheMovieDb电影海报", + "value": "tmdb" + }, + { + "title": "Bing每日壁纸", + "value": "bing" + } + ], + "hint": "登录首页电影海报", + "persistent-hint": True, + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6, + }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "API_TOKEN", + "label": "API密钥", + "hint": "用于Jellyseerr/Overseerr、媒体服务器Webhook等配置以及部分支持API_TOKEN的API请求", + "persistent-hint": True, + "clearable": True, + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12 + }, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "PLUGIN_MARKET", + "label": "插件市场", + "hint": "插件市场仓库地址,多个地址使用逗号分隔,确保每个地址以/结尾", + "persistent-hint": True, + "clearable": True, + } + } + ] + }, + + ] } ] + }, + # 网络 + { + "component": "VWindowItem", + "props": { + "value": "network_tab", + "style": { + "padding-top": "20px", + "padding-bottom": "20px" + }, + }, + "content": [ + # DOH + { + "component": "VRow", + "props": { + "align": "center", + }, + "content": [ + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "DOH_ENABLE", + "label": "启用DNS over HTTPS", + "hint": "启用后对特定域名使用DOH解析以避免DNS污染", + "persistent-hint": True + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6, + }, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "style": "white-space: pre-line;", + "text": "如果已经配置好 'PROXY_HOST' ,建议关闭 'DOH' ", + }, + }, + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "DOH_DOMAINS", + "label": "DOH解析的域名", + "hint": "DOH解析的域名列表,多个域名使用逗号分隔", + "persistent-hint": True, + "clearable": True, + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "DOH_RESOLVERS", + "label": "DOH解析服务器", + "hint": "DOH解析服务器列表,多个服务器使用逗号分隔", + "persistent-hint": True, + "clearable": True, + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "GITHUB_TOKEN", + "label": "GitHub Token", + "placeholder": "格式: ghp_**** 或 github_pat_****", + "hint": "GitHub Token,提高请求API限流阈值", + "persistent-hint": True, + "clearable": True, + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "OCR_HOST", + "label": "验证码识别服务器", + "hint": "验证码识别服务器地址", + "persistent-hint": True, + "clearable": True, + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "GITHUB_PROXY", + "label": "GitHub加速服务器", + "placeholder": "格式: https://mirror.ghproxy.com/", + "hint": "留空则不使用GitHub加速服务器,(注意末尾需要带/)", + "persistent-hint": True, + "clearable": True, + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "PIP_PROXY", + "label": "PIP加速服务器", + "hint": "留空则不使用PIP加速服务器", + "placeholder": "格式: https://pypi.tuna.tsinghua.edu.cn/simple", + "persistent-hint": True, + "clearable": True, + } + } + ] + }, + ] + }, + # Tmdb相关 + { + "component": "VRow", + "props": { + "align": "center" + }, + "content": [ + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "TMDB_API_DOMAIN", + "label": "TMDB API地址", + "hint": "访问正常时无需更改;无法访问时替换为其他中转服务地址,确保连通性", + "persistent-hint": True, + "clearable": True, + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "TMDB_IMAGE_DOMAIN", + "label": "TheMovieDb图片服务器", + "placeholder": "例如:static-mdb.v.geilijiasu.com", + "hint": "访问正常时无需更改;无法访问时可替换为其他可用地址,确保连通性", + "persistent-hint": True, + "clearable": True, + } + } + ] + }, + ] + }, + ] + }, + # 媒体与下载 + { + "component": "VWindowItem", + "props": { + "value": "media_and_download_tab", + "style": { + "padding-top": "20px", + "padding-bottom": "20px" + }, + }, + "content": [ + { + "component": "VRow", + "props": { + "align": "center", + }, + "content": [ + { + "component": "VCol", + "props": { + "cols": 12, + "md": 9, + }, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "DOWNLOAD_SUBTITLE", + "label": "自动下载站点字幕", + "hint": "自动下载站点字幕(如有)", + "persistent-hint": True + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 3, + }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "MEDIASERVER_SYNC_INTERVAL", + "label": "媒体服务器同步间隔", + "hint": "媒体服务器同步间隔", + "persistent-hint": True, + "prefix": "每", + "suffix": "小时", + "type": "number", + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 12, + }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "AUTO_DOWNLOAD_USER", + "label": "交互搜索自动下载用户ID", + "hint": "使用,分割,设置为 all 代表所有用户自动择优下载,未设置需要用户手动选择资源或者回复`0`才自动择优下载", + "persistent-hint": True, + "clearable": True, + } + } + ] + }, + ] + }, + ] + }, + # 搜索与整理 + { + "component": "VWindowItem", + "props": { + "value": "search_and_transfer_tab", + "style": { + "padding-top": "20px", + "padding-bottom": "20px" + }, + }, + "content": [ + { + "component": "VRow", + "props": { + "align": "center" + }, + "content": [ + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6, + }, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "SEARCH_MULTIPLE_NAME", + "label": "资源搜索整合多名称结果", + "hint": "搜索多个名称时是整合多名称的结果", + "persistent-hint": True + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 3, + }, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "FANART_ENABLE", + "label": "使用Fanart图片数据源", + "hint": "启用Fanart图片数据源", + "persistent-hint": True + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 3, + }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "META_CACHE_EXPIRE", + "label": "元数据缓存时间", + "hint": "0或负值时,使用系统默认缓存时间", + "persistent-hint": True, + "prefix": "每", + "suffix": "小时", + "type": "number", + } + } + ] + }, + ] + }, + { + "component": "VRow", + "props": { + "align": "center" + }, + "content": [ + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VSelect", + "props": { + "model": "RECOGNIZE_SOURCE", + "label": "媒体信息识别来源", + "items": [ + { + "title": "TheMovieDb", + "value": "themoviedb" + }, + { + "title": "豆瓣", + "value": "douban" + } + ], + "hint": "媒体信息识别来源", + "persistent-hint": True + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 6 + }, + "content": [ + { + "component": "VSelect", + "props": { + "model": "SCRAP_SOURCE", + "label": "刮削元数据及图片使用的数据源", + "items": [ + { + "title": "TheMovieDb", + "value": "themoviedb" + }, + { + "title": "豆瓣", + "value": "douban" + } + ], + "hint": "刮削元数据及图片使用的数据源", + "persistent-hint": True + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12 + }, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "MOVIE_RENAME_FORMAT", + "label": "电影重命名格式", + "hint": "电影重命名格式,使用Jinja2语法,每行一个配置项,参考:https://jinja.palletsprojects.com/en/3.0.x/templates/", + "persistent-hint": True + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12 + }, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "TV_RENAME_FORMAT", + "label": "电视剧重命名格式", + "hint": "电视剧重命名格式,使用Jinja2语法", + "persistent-hint": True + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 12, + }, + "content": [ + { + "component": "VAlert", + "props": { + "type": "warning", + "variant": "tonal", + "style": "white-space: pre-line;", + "text": "Jinja2语法参考:" + }, + "content": [ + { + "component": "a", + "props": { + "href": "https://jinja.palletsprojects.com/en/3.0.x/templates/", + "target": "_blank" + }, + "content": [ + { + "component": "u", + "text": "https://jinja.palletsprojects.com/en/3.0.x/templates/" + } + ] + } + ] + }, + ] + } + ] + } + ] + }, + # 自定义 + { + "component": "VWindowItem", + "props": { + "value": "params_tab", + "style": { + "padding-top": "20px", + "padding-bottom": "20px" + }, + }, + "content": [ + { + "component": "VRow", + "props": { + "align": "center", + }, + "content": [ + { + "component": "VCol", + "props": { + "cols": 12, + }, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "params", + "label": "自定义配置", + "hint": "自定义配置,每行一个配置项,格式:配置项=值", + "persistent-hint": True + } + } + ] + } + ] + }, + ] } ] }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "OCR_HOST", - "label": "验证码识别服务器", - "hint": "验证码识别服务器地址", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "GITHUB_PROXY", - "label": "GitHub加速服务器", - "hint": "GitHub加速服务器,格式: https://mirror.ghproxy.com/", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "PIP_PROXY", - "label": "PIP加速服务器", - "hint": "PIP加速服务器,格式: https://pypi.tuna.tsinghua.edu.cn/simple", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "MEDIASERVER_SYNC_INTERVAL", - "label": "媒体服务器同步间隔(小时)", - "hint": "媒体服务器同步间隔", - "persistent-hint": True - } - } - ] - } - ] - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "DOH_DOMAINS", - "label": "DOH解析的域名", - "hint": "DOH解析的域名列表,多个域名使用逗号分隔", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "DOH_RESOLVERS", - "label": "DOH解析服务器", - "hint": "DOH解析服务器列表,多个服务器使用逗号分隔", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12 - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "AUTO_DOWNLOAD_USER", - "label": "交互搜索自动下载用户ID", - "hint": "使用,分割,设置为 all 代表所有用户自动择优下载,未设置需要用户手动选择资源或者回复`0`才自动择优下载", - "persistent-hint": True - } - } - ] - } - ] - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": { - "cols": 12 - }, - "content": [ - { - "component": "VTextarea", - "props": { - "model": "MOVIE_RENAME_FORMAT", - "label": "电影重命名格式", - "hint": "电影重命名格式,使用Jinja2语法,每行一个配置项,参考:https://jinja.palletsprojects.com/en/3.0.x/templates/", - "persistent-hint": True - } - } - ] - } - ] - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": { - "cols": 12 - }, - "content": [ - { - "component": "VTextarea", - "props": { - "model": "TV_RENAME_FORMAT", - "label": "电视剧重命名格式", - "hint": "电视剧重命名格式,使用Jinja2语法,参考:https://jinja.palletsprojects.com/en/3.0.x/templates/", - "persistent-hint": True - } - } - ] - } - ] - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": { - "cols": 12 - }, - "content": [ - { - "component": "VTextarea", - "props": { - "model": "PLUGIN_MARKET", - "label": "插件市场", - "hint": "插件市场仓库地址,多个地址使用逗号分隔,确保每个地址以/结尾", - "persistent-hint": True - } - } - ] - } - ] - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": { - "cols": 12 - }, - "content": [ - { - "component": "VTextarea", - "props": { - "model": "params", - "label": "自定义配置", - "hint": "自定义配置,每行一个配置项,格式:配置项=值", - "persistent-hint": True - } - } - ] - } - ] - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": { - "cols": 12, - "md": 4 - }, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "DOWNLOAD_SUBTITLE", - "label": "自动下载站点字幕", - "hint": "自动下载站点字幕(如有)", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 4 - }, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "SCRAP_FOLLOW_TMDB", - "label": "新增入库跟随TMDB信息变化", - "hint": "新增入库媒体是否跟随TMDB信息变化", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 4 - }, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "FANART_ENABLE", - "label": "使用Fanart图片数据源", - "hint": "启用Fanart图片数据源", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 4 - }, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "DOH_ENABLE", - "label": "启用DNS over HTTPS", - "hint": "是否启用DNS over HTTPS,启用后对特定域名使用DOH解析以避免DNS污染", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 4 - }, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "SEARCH_MULTIPLE_NAME", - "label": "资源搜索整合多名称搜索结果", - "hint": "搜索多个名称时是否整合多名称的搜索结果,True/false", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 4 - }, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "AUXILIARY_AUTH_ENABLE", - "label": "启用用户辅助认证", - "hint": "是否启用用户辅助认证,允许通过外部服务进行认证、单点登录以及自动创建用户", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 4 - }, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "GLOBAL_IMAGE_CACHE", - "label": "全局图片缓存", - "hint": "是否启用全局图片缓存,将媒体图片缓存到本地", - "persistent-hint": True - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'warning', - 'variant': 'tonal', - 'text': '注意:部分配置项的更改可能需要重启服务才能生效,为确保配置一致性,已在环境变量中的相关配置项,请手动更新' - } - } - ] - } - ] - } ] def get_page(self) -> List[dict]: From ef2f91f22291d5d5b06e66516c6729c899394a04 Mon Sep 17 00:00:00 2001 From: Attente <19653207+wikrin@users.noreply.github.com> Date: Wed, 23 Oct 2024 07:08:03 +0800 Subject: [PATCH 125/218] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=88=A0=E9=99=A4=E7=AB=99=E7=82=B9,=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=88=A0=E9=99=A4=E7=AB=99=E7=82=B9=E5=88=97=E8=A1=A8?= =?UTF-8?q?,=20=E4=B8=8D=E4=BC=9A=E5=86=8D=E7=95=99=E4=B8=8B=E7=BC=96?= =?UTF-8?q?=E5=8F=B7=20fix:=20=E4=BF=AE=E5=A4=8D=E5=9B=A0=E6=9C=AA?= =?UTF-8?q?=E4=BC=A0=E9=80=92=E5=8F=82=E6=95=B0=E5=AF=BC=E8=87=B4=E7=9A=84?= =?UTF-8?q?=E5=AD=A3=E5=BA=A6=E8=AF=86=E5=88=AB=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 ++- plugins/bangumicoll/__init__.py | 25 +++++++++++++++++++++---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 82e637b..cf47843 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,13 @@ "name": "Bangumi收藏订阅", "description": "Bangumi用户收藏添加到订阅", "labels": "订阅", - "version": "1.5", + "version": "1.5.1", "icon": "https://raw.githubusercontent.com/wikrin/MoviePilot-Plugins/main/icons/bangumi_b.png", "author": "Attente", "level": 1, "v2": true, "history": { + "v1.5.1": "修复季度信息未传递的问题. 新增站点列表同步删除", "v1.5": "修复总集数会同步TMDB变动的问题,增加开关选项", "v1.4": "结构优化", "v1.3.1": "修复因修改季号导致未下载剧集而完成订阅的问题" diff --git a/plugins/bangumicoll/__init__.py b/plugins/bangumicoll/__init__.py index 192d236..e101103 100644 --- a/plugins/bangumicoll/__init__.py +++ b/plugins/bangumicoll/__init__.py @@ -14,6 +14,7 @@ from sqlalchemy.orm import Session from app.chain.subscribe import SubscribeChain, Subscribe from app.core.config import settings from app.core.context import MediaInfo +from app.core.event import eventmanager, Event from app.core.meta import MetaBase from app.core.metainfo import MetaInfo from app.db.models.subscribehistory import SubscribeHistory @@ -23,7 +24,7 @@ from app.db import db_query from app.helper.subscribe import SubscribeHelper from app.log import logger from app.plugins import _PluginBase -from app.schemas.types import NotificationType +from app.schemas.types import EventType, NotificationType from app.utils.http import RequestUtils @@ -35,7 +36,7 @@ class BangumiColl(_PluginBase): # 插件图标 plugin_icon = "https://raw.githubusercontent.com/wikrin/MoviePilot-Plugins/main/icons/bangumi_b.png" # 插件版本 - plugin_version = "1.5" + plugin_version = "1.5.1" # 插件作者 plugin_author = "Attente" # 作者主页 @@ -47,7 +48,7 @@ class BangumiColl(_PluginBase): # 可使用的用户级别 auth_level = 1 - # 私有变量 + # 私有属性 _scheduler: Optional[BackgroundScheduler] = None siteoper: SiteOper = None subscribehelper: SubscribeHelper = None @@ -95,6 +96,12 @@ class BangumiColl(_PluginBase): "sites", ): setattr(self, f"_{key}", config.get(key, getattr(self, f"_{key}"))) + # 获得所有站点 + site_ids = {site.id for site in self.siteoper.list_order_by_pri()} + # 过滤已删除的站点 + self._sites = [site_id for site_id in self._sites if site_id in site_ids] + # 更新配置 + self.__update_config() def schedule_once(self): """调度一次性任务""" @@ -138,7 +145,6 @@ class BangumiColl(_PluginBase): {"title": site.name, "value": site.id} for site in self.siteoper.list_order_by_pri() ] - return form(sites_options) def get_service(self) -> List[Dict[str, Any]]: @@ -167,6 +173,16 @@ class BangumiColl(_PluginBase): except Exception as e: logger.error(f"退出插件失败:{str(e)}") + @eventmanager.register(EventType.SiteDeleted) + def site_deleted(self, event: Event): + """ + 删除对应站点 + """ + site_id = event.event_data.get("site_id") + if site_id in self._sites: + self._sites.remove(site_id) + self.__update_config() + def get_api(self): pass @@ -276,6 +292,7 @@ class BangumiColl(_PluginBase): sid, msg = self.subscribechain.add( title=mediainfo.title, year=mediainfo.year, + season=mediainfo.number_of_seasons, bangumiid=self._subid, exist_ok=True, username="Bangumi订阅", From ad4f71e997eab5f7b0bad9a70aa31ff2ad4e2819 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Wed, 23 Oct 2024 12:08:05 +0800 Subject: [PATCH 126/218] =?UTF-8?q?fix=20=E7=AB=99=E7=82=B9=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 3 ++- plugins.v2/sitestatistic/__init__.py | 23 +++++++++-------------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/package.v2.json b/package.v2.json index 35c9333..9f7df6f 100644 --- a/package.v2.json +++ b/package.v2.json @@ -3,11 +3,12 @@ "name": "站点数据统计", "description": "站点统计数据图表。", "labels": "站点,仪表板", - "version": "1.0.2", + "version": "1.0.3", "icon": "statistic.png", "author": "lightolly,jxxghp", "level": 2, "history": { + "v1.0.3": "修复增量数据统计问题", "v1.0.2": "MoviePilot V2 版本站点数据统计插件" } }, diff --git a/plugins.v2/sitestatistic/__init__.py b/plugins.v2/sitestatistic/__init__.py index 0e29e93..86fbc5c 100644 --- a/plugins.v2/sitestatistic/__init__.py +++ b/plugins.v2/sitestatistic/__init__.py @@ -32,7 +32,7 @@ class SiteStatistic(_PluginBase): # 插件图标 plugin_icon = "statistic.png" # 插件版本 - plugin_version = "1.0.2" + plugin_version = "1.0.3" # 插件作者 plugin_author = "lightolly,jxxghp" # 作者主页 @@ -198,10 +198,6 @@ class SiteStatistic(_PluginBase): """ 获取今天的日期、今天的站点数据、昨天的站点数据 """ - # 最近一天的数据 - stattistic_data: List[SiteUserData] = [] - # 昨天数据 - yesterday_sites_data: List[SiteUserData] = [] # 获取最近所有数据 data_list: List[SiteUserData] = self.siteoper.get_userdata() if not data_list: @@ -210,17 +206,16 @@ class SiteStatistic(_PluginBase): data_list = list({f"{data.updated_day}_{data.name}": data for data in data_list}.values()) # 按日期倒序排序 data_list.sort(key=lambda x: x.updated_day, reverse=True) - # 今天的日期 - today = time.strftime('%Y-%m-%d', time.localtime()) - if len(data_list) > 0: - today = data_list[0].updated_day - stattistic_data = [data for data in data_list if data.updated_day == today] - if len(data_list) > 1: - yestoday = data_list[1].updated_day - yesterday_sites_data = [data for data in data_list if data.updated_day == yestoday] - + # 获取今天的日期 + today = data_list[0].updated_day + # 获取昨天的日期 + yestoday = (datetime.strptime(today, "%Y-%m-%d") - timedelta(days=1)).strftime("%Y-%m-%d") + # 今天的数据 + stattistic_data = [data for data in data_list if data.updated_day == today] # 今日数据按数据量降序排序 stattistic_data.sort(key=lambda x: x.upload, reverse=True) + # 昨天的数据 + yesterday_sites_data = [data for data in data_list if data.updated_day == yestoday] return today, stattistic_data, yesterday_sites_data From 4502f73dad9e40dcb40fe42798bd283bf8828a39 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Thu, 24 Oct 2024 13:55:59 +0800 Subject: [PATCH 127/218] =?UTF-8?q?fix=20=E7=AB=99=E7=82=B9=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 6 +++--- plugins.v2/sitestatistic/__init__.py | 22 +++++++++++++++++----- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/package.v2.json b/package.v2.json index 9f7df6f..f311cd1 100644 --- a/package.v2.json +++ b/package.v2.json @@ -3,13 +3,13 @@ "name": "站点数据统计", "description": "站点统计数据图表。", "labels": "站点,仪表板", - "version": "1.0.3", + "version": "1.1", "icon": "statistic.png", "author": "lightolly,jxxghp", "level": 2, "history": { - "v1.0.3": "修复增量数据统计问题", - "v1.0.2": "MoviePilot V2 版本站点数据统计插件" + "v1.1": "修复增量数据统计问题", + "v1.0": "MoviePilot V2 版本站点数据统计插件" } }, "BrushFlow": { diff --git a/plugins.v2/sitestatistic/__init__.py b/plugins.v2/sitestatistic/__init__.py index 86fbc5c..b2bd3a2 100644 --- a/plugins.v2/sitestatistic/__init__.py +++ b/plugins.v2/sitestatistic/__init__.py @@ -32,7 +32,7 @@ class SiteStatistic(_PluginBase): # 插件图标 plugin_icon = "statistic.png" # 插件版本 - plugin_version = "1.0.3" + plugin_version = "1.1" # 插件作者 plugin_author = "lightolly,jxxghp" # 作者主页 @@ -234,6 +234,18 @@ class SiteStatistic(_PluginBase): return 0 return round(float(value) / 1024 / 1024 / 1024, 1) + def __is_digit(value: any) -> bool: + """ + 判断是否为数字 + """ + if value is None: + return False + if isinstance(value, float) or isinstance(value, int): + return True + if isinstance(value, str): + return value.isdigit() + return False + def __sub_data(d1: dict, d2: dict) -> dict: """ 计算两个字典相同Key值的差值(如果值为数字),返回新字典 @@ -242,8 +254,8 @@ class SiteStatistic(_PluginBase): return {} if not d2: return d1 - d = {k: int(d1.get(k)) - int(d2.get(k)) for k in d1 - if k in d2 and str(d1.get(k)).isdigit() and str(d2.get(k)).isdigit()} + d = {k: d1.get(k) - d2.get(k) for k in d1 + if k in d2 and __is_digit(d1.get(k)) and __is_digit(d2.get(k))} # 把小于0的数据变成0 for k, v in d.items(): if str(v).isdigit() and int(v) < 0: @@ -554,7 +566,7 @@ class SiteStatistic(_PluginBase): if inc: inc_data[data.name] = inc # 今日上传 - uploads = {k: v for k, v in inc_data.items() if v.get("upload")} + uploads = {k: v for k, v in inc_data.items() if v.get("upload") if v.get("upload") > 0} # 今日上传站点 upload_sites = [site for site in uploads.keys()] # 今日上传数据 @@ -562,7 +574,7 @@ class SiteStatistic(_PluginBase): # 今日上传总量 today_upload = round(sum(upload_datas), 2) # 今日下载 - downloads = {k: v for k, v in inc_data.items() if v.get("download")} + downloads = {k: v for k, v in inc_data.items() if v.get("download") if v.get("download") > 0} # 今日下载站点 download_sites = [site for site in downloads.keys()] # 今日下载数据 From 2e8a1cfbab1b533cc169276e5c9cb0fa35e9a8cb Mon Sep 17 00:00:00 2001 From: thsrite Date: Fri, 25 Oct 2024 12:47:33 +0800 Subject: [PATCH 128/218] fix TorrentRemover v2 --- package.v2.json | 3 ++- plugins.v2/torrentremover/__init__.py | 11 +++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/package.v2.json b/package.v2.json index f311cd1..d1ded01 100644 --- a/package.v2.json +++ b/package.v2.json @@ -197,11 +197,12 @@ "name": "自动删种", "description": "自动删除下载器中的下载任务。", "labels": "做种", - "version": "2.0", + "version": "2.1", "icon": "delete.jpg", "author": "jxxghp", "level": 2, "history": { + "v2.1": "修复兼容MoviePilot V2 版本", "v2.0": "兼容MoviePilot V2 版本" } }, diff --git a/plugins.v2/torrentremover/__init__.py b/plugins.v2/torrentremover/__init__.py index 8a87421..6aa67ee 100644 --- a/plugins.v2/torrentremover/__init__.py +++ b/plugins.v2/torrentremover/__init__.py @@ -26,7 +26,7 @@ class TorrentRemover(_PluginBase): # 插件图标 plugin_icon = "delete.jpg" # 插件版本 - plugin_version = "2.0" + plugin_version = "2.1" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -617,6 +617,12 @@ class TorrentRemover(_PluginBase): """ return self.service_infos.get(name).instance + def __get_downloader_config(self, name: str): + """ + 根据类型返回下载器实例配置 + """ + return self.service_infos.get(name).config + def delete_torrents(self): """ 定时删除下载器中的下载任务 @@ -779,6 +785,7 @@ class TorrentRemover(_PluginBase): remove_torrents = [] # 下载器对象 downloader_obj = self.__get_downloader(downloader) + downloader_config = self.__get_downloader_config(downloader) # 标题 if self._labels: tags = self._labels.split(',') @@ -792,7 +799,7 @@ class TorrentRemover(_PluginBase): return [] # 处理种子 for torrent in torrents: - if downloader == "qbittorrent": + if downloader_config.type == "qbittorrent": item = self.__get_qb_torrent(torrent) else: item = self.__get_tr_torrent(torrent) From 04bef9a690141dd94e09833498b47538610a846c Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sat, 26 Oct 2024 15:29:35 +0800 Subject: [PATCH 129/218] style: keep consistent code style and formatting --- plugins.v2/downloadsitetag/__init__.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/plugins.v2/downloadsitetag/__init__.py b/plugins.v2/downloadsitetag/__init__.py index 74d16ea..26b8275 100644 --- a/plugins.v2/downloadsitetag/__init__.py +++ b/plugins.v2/downloadsitetag/__init__.py @@ -214,21 +214,22 @@ class DownloadSiteTag(_PluginBase): "agsvpt.trackers.work": "agsvpt.com", "tracker.cinefiles.info": "audiences.me", } - for name, service in self.service_infos.items(): - logger.info(f"{self.LOG_TAG}开始扫描下载器 {name} ...") - # 获取下载器中的种子 + for service in self.service_infos.values(): + downloader = service.name downloader_obj = service.instance + logger.info(f"{self.LOG_TAG}开始扫描下载器 {downloader} ...") if not downloader_obj: - logger.error(f"{self.LOG_TAG} 获取下载器失败 {name}") + logger.error(f"{self.LOG_TAG} 获取下载器失败 {downloader}") continue + # 获取下载器中的种子 torrents, error = downloader_obj.get_torrents() # 如果下载器获取种子发生错误 或 没有种子 则跳过 if error or not torrents: continue - logger.info(f"{self.LOG_TAG}按时间重新排序 {name} 种子数:{len(torrents)}") + logger.info(f"{self.LOG_TAG}按时间重新排序 {downloader} 种子数:{len(torrents)}") # 按添加时间进行排序, 时间靠前的按大小和名称加入处理历史, 判定为原始种子, 其他为辅种 torrents = self._torrents_sort(torrents=torrents, dl_type=service.type) - logger.info(f"{self.LOG_TAG}下载器 {name} 分析种子信息中 ...") + logger.info(f"{self.LOG_TAG}下载器 {downloader} 分析种子信息中 ...") for torrent in torrents: try: if self._event.is_set(): @@ -855,4 +856,4 @@ class DownloadSiteTag(_PluginBase): self._event.clear() self._scheduler = None except Exception as e: - print(str(e)) + print(str(e)) \ No newline at end of file From 52dea6a8c0d3b63778fd80ce2a14a39794ef6c40 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sat, 26 Oct 2024 15:29:45 +0800 Subject: [PATCH 130/218] feat(IYUUAutoSeed): add support for v2 plugin --- plugins.v2/iyuuautoseed/__init__.py | 179 +++++++++++++++------------- 1 file changed, 94 insertions(+), 85 deletions(-) diff --git a/plugins.v2/iyuuautoseed/__init__.py b/plugins.v2/iyuuautoseed/__init__.py index 3e2567b..7026ef3 100644 --- a/plugins.v2/iyuuautoseed/__init__.py +++ b/plugins.v2/iyuuautoseed/__init__.py @@ -2,7 +2,7 @@ import os import re from datetime import datetime, timedelta from threading import Event -from typing import Any, List, Dict, Tuple, Optional +from typing import Any, Dict, List, Optional, Tuple import pytz from apscheduler.schedulers.background import BackgroundScheduler @@ -47,11 +47,11 @@ class IYUUAutoSeed(_PluginBase): # 私有属性 _scheduler = None - iyuuhelper = None - sites = None - siteoper = None - torrent = None + iyuu_helper = None downloader_helper = None + sites_helper = None + site_oper = None + torrent_helper = None # 开关 _enabled = False _cron = None @@ -95,9 +95,9 @@ class IYUUAutoSeed(_PluginBase): cached = 0 def init_plugin(self, config: dict = None): - self.sites = SitesHelper() - self.siteoper = SiteOper() - self.torrent = TorrentHelper() + self.sites_helper = SitesHelper() + self.site_oper = SiteOper() + self.torrent_helper = TorrentHelper() self.downloader_helper = DownloaderHelper() # 读取配置 if config: @@ -121,8 +121,8 @@ class IYUUAutoSeed(_PluginBase): self._success_caches = [] if self._clearcache else config.get("success_caches") or [] # 过滤掉已删除的站点 - all_sites = [site.id for site in self.siteoper.list_order_by_pri()] + [site.get("id") for site in - self.__custom_sites()] + all_sites = [site.id for site in self.site_oper.list_order_by_pri()] + [site.get("id") for site in + self.__custom_sites()] self._sites = [site_id for site_id in all_sites if site_id in self._sites] self.__update_config() @@ -131,7 +131,7 @@ class IYUUAutoSeed(_PluginBase): # 启动定时任务 & 立即运行一次 if self.get_state() or self._onlyonce: - self.iyuuhelper = IyuuHelper(token=self._token) + self.iyuu_helper = IyuuHelper(token=self._token) self._scheduler = BackgroundScheduler(timezone=settings.TZ) if self._onlyonce: @@ -153,10 +153,35 @@ class IYUUAutoSeed(_PluginBase): if self._clearcache: # 关闭清除缓存开关 self._clearcache = False + # 保存配置 + self.__update_config() - if self._clearcache or self._onlyonce: - # 保存配置 - self.__update_config() + @property + def service_infos(self) -> Optional[Dict[str, ServiceInfo]]: + """ + 服务信息 + """ + if not self._downloaders: + logger.warning("尚未配置下载器,请检查配置") + return None + + services = self.downloader_helper.get_services(name_filters=self._downloaders) + if not services: + logger.warning("获取下载器实例失败,请检查配置") + return None + + active_services = {} + for service_name, service_info in services.items(): + if service_info.instance.is_inactive(): + logger.warning(f"下载器 {service_name} 未连接,请检查配置") + else: + active_services[service_name] = service_info + + if not active_services: + logger.warning("没有已连接的下载器,请检查配置") + return None + + return active_services def get_state(self) -> bool: return True if self._enabled and self._cron and self._token and self._downloaders else False @@ -198,7 +223,7 @@ class IYUUAutoSeed(_PluginBase): # 站点的可选项 site_options = ([{"title": site.name, "value": site.id} - for site in self.siteoper.list_order_by_pri()] + for site in self.site_oper.list_order_by_pri()] + [{"title": site.get("name"), "value": site.get("id")} for site in customSites]) return [ @@ -302,14 +327,15 @@ class IYUUAutoSeed(_PluginBase): { 'component': 'VCol', 'props': { - 'cols': 12 + 'cols': 12, + 'md': 6 }, 'content': [ { 'component': 'VSelect', 'props': { - 'multiple': True, 'chips': True, + 'multiple': True, 'clearable': True, 'model': 'downloaders', 'label': '下载器', @@ -318,6 +344,23 @@ class IYUUAutoSeed(_PluginBase): } } ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'size', + 'label': '辅种体积大于(GB)', + 'placeholder': '只有大于该值的才辅种' + } + } + ] } ] }, @@ -493,33 +536,6 @@ class IYUUAutoSeed(_PluginBase): def get_page(self) -> List[dict]: pass - @property - def service_infos(self) -> Optional[Dict[str, ServiceInfo]]: - """ - 服务信息 - """ - if not self._downloaders: - logger.warning("尚未配置下载器,请检查配置") - return None - - services = self.downloader_helper.get_services(name_filters=self._downloaders) - if not services: - logger.warning("获取下载器实例失败,请检查配置") - return None - - active_services = {} - for service_name, service_info in services.items(): - if service_info.instance.is_inactive(): - logger.warning(f"下载器 {service_name} 未连接,请检查配置") - else: - active_services[service_name] = service_info - - if not active_services: - logger.warning("没有已连接的下载器,请检查配置") - return None - - return active_services - def __update_config(self): self.update_config({ "enabled": self._enabled, @@ -542,17 +558,11 @@ class IYUUAutoSeed(_PluginBase): "permanent_error_caches": self._permanent_error_caches }) - def __get_downloader(self, name: str): - """ - 根据类型返回下载器实例 - """ - return self.service_infos.get(name).instance - def auto_seed(self): """ 开始辅种 """ - if not self.iyuuhelper: + if not self.iyuu_helper or not self.service_infos: return logger.info("开始辅种任务 ...") @@ -564,9 +574,10 @@ class IYUUAutoSeed(_PluginBase): self.fail = 0 self.cached = 0 # 扫描下载器辅种 - for downloader in self._downloaders: + for service in self.service_infos.values(): + downloader = service.name + downloader_obj = service.instance logger.info(f"开始扫描下载器 {downloader} ...") - downloader_obj = self.__get_downloader(downloader) # 获取下载器中已完成的种子 torrents = downloader_obj.get_completed_torrents() if torrents: @@ -580,11 +591,11 @@ class IYUUAutoSeed(_PluginBase): logger.info(f"辅种服务停止") return # 获取种子hash - hash_str = self.__get_hash(torrent, downloader) + hash_str = self.__get_hash(torrent=torrent, dl_type=service.type) if hash_str in self._error_caches or hash_str in self._permanent_error_caches: logger.info(f"种子 {hash_str} 辅种失败且已缓存,跳过 ...") continue - save_path = self.__get_save_path(torrent, downloader) + save_path = self.__get_save_path(torrent=torrent, dl_type=service.type) if self._nopaths and save_path: # 过滤不需要转移的路径 @@ -598,7 +609,7 @@ class IYUUAutoSeed(_PluginBase): continue # 获取种子标签 - torrent_labels = self.__get_label(torrent, downloader) + torrent_labels = self.__get_label(torrent=torrent, dl_type=service.type) if torrent_labels and self._nolabels: is_skip = False for label in self._nolabels.split(','): @@ -610,7 +621,7 @@ class IYUUAutoSeed(_PluginBase): continue # 体积排除辅种 - torrent_size = self.__get_torrent_size(torrent, downloader) / 1024 / 1024 / 1024 + torrent_size = self.__get_torrent_size(torrent=torrent, dl_type=service.type) / 1024 / 1024 / 1024 if self._size and torrent_size < self._size: logger.info(f"种子 {hash_str} 大小:{torrent_size:.2f}GB,小于设定 {self._size}GB,跳过 ...") continue @@ -628,7 +639,7 @@ class IYUUAutoSeed(_PluginBase): chunk = hash_strs[i:i + chunk_size] # 处理分组 self.__seed_torrents(hash_strs=chunk, - downloader=downloader) + service=service) # 触发校验检查 self.check_recheck() else: @@ -654,19 +665,21 @@ class IYUUAutoSeed(_PluginBase): """ 定时检查下载器中种子是否校验完成,校验完成且完整的自动开始辅种 """ + if not self.service_infos: + return if not self._recheck_torrents: return if self._is_recheck_running: return self._is_recheck_running = True - for downloader in self._downloaders: + for service in self.service_infos.values(): # 需要检查的种子 + downloader = service.name + downloader_obj = service.instance recheck_torrents = self._recheck_torrents.get(downloader) or [] if not recheck_torrents: continue logger.info(f"开始检查下载器 {downloader} 的校验任务 ...") - # 下载器 - downloader_obj = self.__get_downloader(downloader) # 获取下载器中的种子状态 torrents, _ = downloader_obj.get_torrents(ids=recheck_torrents) if torrents: @@ -691,13 +704,13 @@ class IYUUAutoSeed(_PluginBase): self._recheck_torrents[downloader] = [] self._is_recheck_running = False - def __seed_torrents(self, hash_strs: list, downloader: str): + def __seed_torrents(self, hash_strs: list, service: ServiceInfo): """ 执行一批种子的辅种 """ if not hash_strs: return - logger.info(f"下载器 {downloader} 开始查询辅种,数量:{len(hash_strs)} ...") + logger.info(f"下载器 {service.name} 开始查询辅种,数量:{len(hash_strs)} ...") # 下载器中的Hashs hashs = [item.get("hash") for item in hash_strs] # 每个Hash的保存目录 @@ -705,7 +718,7 @@ class IYUUAutoSeed(_PluginBase): for item in hash_strs: save_paths[item.get("hash")] = item.get("save_path") # 查询可辅种数据 - seed_list, msg = self.iyuuhelper.get_seed_info(hashs) + seed_list, msg = self.iyuu_helper.get_seed_info(hashs) if not isinstance(seed_list, dict): # 判断辅种异常是否是由于Token未认证导致的,由于没有解决接口,只能从返回值来判断 if self._token and msg == '请求缺少token': @@ -744,7 +757,7 @@ class IYUUAutoSeed(_PluginBase): continue # 添加任务 success = self.__download_torrent(seed=seed, - downloader=downloader, + service=service, save_path=save_paths.get(current_hash)) if success: success_torrents.append(seed.get("info_hash")) @@ -752,10 +765,10 @@ class IYUUAutoSeed(_PluginBase): # 辅种成功的去重放入历史 if len(success_torrents) > 0: self.__save_history(current_hash=current_hash, - downloader=downloader, + downloader=service.name, success_torrents=success_torrents) - logger.info(f"下载器 {downloader} 辅种完成") + logger.info(f"下载器 {service.name} 辅种完成") def __save_history(self, current_hash: str, downloader: str, success_torrents: []): """ @@ -809,7 +822,7 @@ class IYUUAutoSeed(_PluginBase): except Exception as e: print(str(e)) - def __download(self, downloader: str, content: bytes, + def __download(self, service: ServiceInfo, content: bytes, save_path: str, site_name: str) -> Optional[str]: torrent_tags = self._labelsafterseed.split(',') @@ -821,10 +834,6 @@ class IYUUAutoSeed(_PluginBase): """ 添加下载任务 """ - service = self.service_infos.get(downloader) - if not service: - logger.error(f"下载器 {downloader} 未连接,添加下载任务失败!") - return None if service.type == "qbittorrent": # 生成随机Tag tag = StringUtils.generate_random_str(10) @@ -843,7 +852,7 @@ class IYUUAutoSeed(_PluginBase): # 获取种子Hash torrent_hash = service.instance.get_torrent_id_by_tag(tags=tag) if not torrent_hash: - logger.error(f"{downloader} 下载任务添加成功,但获取任务信息失败!") + logger.error(f"{service.name} 下载任务添加成功,但获取任务信息失败!") return None return torrent_hash elif service.type == "transmission": @@ -857,10 +866,10 @@ class IYUUAutoSeed(_PluginBase): else: return torrent.hashString - logger.error(f"不支持的下载器:{downloader}") + logger.error(f"不支持的下载器:{service.type}") return None - def __download_torrent(self, seed: dict, downloader: str, save_path: str): + def __download_torrent(self, seed: dict, service: ServiceInfo, save_path: str): """ 下载种子 torrent: { @@ -880,7 +889,7 @@ class IYUUAutoSeed(_PluginBase): self.total += 1 # 获取种子站点及下载地址模板 - site_url, download_page = self.iyuuhelper.get_torrent_url(seed.get("sid")) + site_url, download_page = self.iyuu_helper.get_torrent_url(seed.get("sid")) if not site_url or not download_page: # 加入缓存 self._error_caches.append(seed.get("info_hash")) @@ -890,7 +899,7 @@ class IYUUAutoSeed(_PluginBase): # 查询站点 site_domain = StringUtils.get_url_domain(site_url) # 站点信息 - site_info = self.sites.get_indexer(site_domain) + site_info = self.sites_helper.get_indexer(site_domain) if not site_info or not site_info.get('url'): logger.debug(f"没有维护种子对应的站点:{site_url}") return False @@ -899,14 +908,14 @@ class IYUUAutoSeed(_PluginBase): return False self.realtotal += 1 # 查询hash值是否已经在下载器中 - downloader_obj = self.__get_downloader(downloader) + downloader_obj = service.instance torrent_info, _ = downloader_obj.get_torrents(ids=[seed.get("info_hash")]) if torrent_info: logger.info(f"{seed.get('info_hash')} 已在下载器中,跳过 ...") self.exist += 1 return False # 站点流控 - check, checkmsg = self.sites.check(site_domain) + check, checkmsg = self.sites_helper.check(site_domain) if check: logger.warn(checkmsg) self.fail += 1 @@ -928,7 +937,7 @@ class IYUUAutoSeed(_PluginBase): else: torrent_url += "?https=1" # 下载种子文件 - _, content, _, _, error_msg = self.torrent.download_torrent( + _, content, _, _, error_msg = self.torrent_helper.download_torrent( url=torrent_url, cookie=site_info.get("cookie"), ua=site_info.get("ua") or settings.USER_AGENT, @@ -946,7 +955,7 @@ class IYUUAutoSeed(_PluginBase): return False # 添加下载,辅种任务默认暂停 logger.info(f"添加下载任务:{torrent_url} ...") - download_id = self.__download(downloader=downloader, + download_id = self.__download(service=service, content=content, save_path=save_path, site_name=site_info.get("name")) @@ -968,11 +977,11 @@ class IYUUAutoSeed(_PluginBase): else: # 追加校验任务 logger.info(f"添加校验检查任务:{download_id} ...") - if not self._recheck_torrents.get(downloader): - self._recheck_torrents[downloader] = [] - self._recheck_torrents[downloader].append(download_id) + if not self._recheck_torrents.get(service.name): + self._recheck_torrents[service.name] = [] + self._recheck_torrents[service.name].append(download_id) # TR会自动校验 - if downloader == "qbittorrent": + if service.type == "qbittorrent": # 开始校验种子 downloader_obj.recheck_torrents(ids=[download_id]) # 下载成功 From 9603ad1ec6ffdc4eec6bf74ede4915eb2cdb6459 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sat, 26 Oct 2024 15:30:29 +0800 Subject: [PATCH 131/218] feat(IYUUAutoSeed): v2.0.1 --- package.v2.json | 2 +- plugins.v2/iyuuautoseed/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.v2.json b/package.v2.json index d1ded01..e0f3e6b 100644 --- a/package.v2.json +++ b/package.v2.json @@ -210,7 +210,7 @@ "name": "IYUU自动辅种", "description": "基于IYUU官方Api实现自动辅种。", "labels": "做种,IYUU", - "version": "2.0", + "version": "2.0.1", "icon": "IYUU.png", "author": "jxxghp", "level": 2, diff --git a/plugins.v2/iyuuautoseed/__init__.py b/plugins.v2/iyuuautoseed/__init__.py index 7026ef3..9e7df67 100644 --- a/plugins.v2/iyuuautoseed/__init__.py +++ b/plugins.v2/iyuuautoseed/__init__.py @@ -33,7 +33,7 @@ class IYUUAutoSeed(_PluginBase): # 插件图标 plugin_icon = "IYUU.png" # 插件版本 - plugin_version = "2.0" + plugin_version = "2.0.1" # 插件作者 plugin_author = "jxxghp" # 作者主页 From 88887d208b29e9bebfc33687592c41980a9fc13d Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sat, 26 Oct 2024 15:44:58 +0800 Subject: [PATCH 132/218] fix(IYUUAutoSeed): resolve recheck --- plugins.v2/iyuuautoseed/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins.v2/iyuuautoseed/__init__.py b/plugins.v2/iyuuautoseed/__init__.py index 9e7df67..dfba16c 100644 --- a/plugins.v2/iyuuautoseed/__init__.py +++ b/plugins.v2/iyuuautoseed/__init__.py @@ -686,8 +686,8 @@ class IYUUAutoSeed(_PluginBase): can_seeding_torrents = [] for torrent in torrents: # 获取种子hash - hash_str = self.__get_hash(torrent, downloader) - if self.__can_seeding(torrent, downloader): + hash_str = self.__get_hash(torrent=torrent, dl_type=service.type) + if self.__can_seeding(torrent=torrent, dl_type=service.type): can_seeding_torrents.append(hash_str) if can_seeding_torrents: logger.info(f"共 {len(can_seeding_torrents)} 个任务校验完成,开始辅种 ...") From a89bda961bead140942ffda0b5c12e30c3a10607 Mon Sep 17 00:00:00 2001 From: Doubly <1286398734@qq.com> Date: Sat, 26 Oct 2024 20:27:48 +0800 Subject: [PATCH 133/218] =?UTF-8?q?fix=EF=BC=9A=E4=BF=AE=E5=A4=8D=E5=A4=9A?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E5=99=A8=E6=97=B6=EF=BC=8C=E6=AF=94=E4=BE=8B?= =?UTF-8?q?=E5=88=86=E9=85=8D=E9=94=99=E8=AF=AF=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 ++- plugins/speedlimiter/__init__.py | 11 ++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index b726740..f5b36a6 100644 --- a/package.json +++ b/package.json @@ -182,11 +182,12 @@ "name": "播放限速", "description": "外网播放媒体库视频时,自动对下载器进行限速。", "labels": "网络", - "version": "1.2", + "version": "1.2.1", "icon": "Librespeed_A.png", "author": "Shurelol", "level": 1, "history": { + "v1.2.1": "修复多下载器时限速比例计算错误问题", "v1.2": "增加不限速路径配置,以应对网盘直链播放的情况" } }, diff --git a/plugins/speedlimiter/__init__.py b/plugins/speedlimiter/__init__.py index 517f683..b59d291 100644 --- a/plugins/speedlimiter/__init__.py +++ b/plugins/speedlimiter/__init__.py @@ -23,7 +23,7 @@ class SpeedLimiter(_PluginBase): # 插件图标 plugin_icon = "Librespeed_A.png" # 插件版本 - plugin_version = "1.2" + plugin_version = "1.2.1" # 插件作者 plugin_author = "Shurelol" # 作者主页 @@ -555,23 +555,26 @@ class SpeedLimiter(_PluginBase): try: cnt = 0 for download in self._downloader: + upload_limit_final = 0 if self._auto_limit and limit_type == "播放": # 开启了播放智能限速 if len(self._downloader) == 1: # 只有一个下载器 - upload_limit = int(upload_limit) + upload_limit_final = int(upload_limit) else: # 多个下载器 if not self._allocation_ratio: # 平均 - upload_limit = int(upload_limit / len(self._downloader)) + upload_limit_final = int(upload_limit / len(self._downloader)) else: # 按比例 allocation_count = sum([int(i) for i in self._allocation_ratio.split(":")]) upload_limit = int(upload_limit * int(self._allocation_ratio.split(":")[cnt]) / allocation_count) + upload_limit_final = int(upload_limit * int(self._allocation_ratio.split(":")[cnt]) / allocation_count) cnt += 1 if upload_limit: text = f"上传:{upload_limit} KB/s" + if upload_limit_final: else: text = f"上传:未限速" if download_limit: @@ -580,7 +583,6 @@ class SpeedLimiter(_PluginBase): text = f"{text}\n下载:未限速" if str(download) == 'qbittorrent': if self._qb: - self._qb.set_speed_limit(download_limit=download_limit, upload_limit=upload_limit) # 发送通知 if self._notify: title = "【播放限速】" @@ -599,7 +601,6 @@ class SpeedLimiter(_PluginBase): ) else: if self._tr: - self._tr.set_speed_limit(download_limit=download_limit, upload_limit=upload_limit) # 发送通知 if self._notify: title = "【播放限速】" From 3a71a046bc17107344e55ad5808d173ca9330bc9 Mon Sep 17 00:00:00 2001 From: Doubly <1286398734@qq.com> Date: Sat, 26 Oct 2024 21:35:09 +0800 Subject: [PATCH 134/218] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=A4=9A=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E5=99=A8=E4=B8=8B=E7=9A=84=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/speedlimiter/__init__.py | 71 ++++++++++++++++---------------- 1 file changed, 35 insertions(+), 36 deletions(-) diff --git a/plugins/speedlimiter/__init__.py b/plugins/speedlimiter/__init__.py index b59d291..3fff6f9 100644 --- a/plugins/speedlimiter/__init__.py +++ b/plugins/speedlimiter/__init__.py @@ -452,6 +452,8 @@ class SpeedLimiter(_PluginBase): # 未设置不限速范围,则默认不限速内网ip elif not IpUtils.is_private_ip(session.get("RemoteEndPoint")) \ and session.get("NowPlayingItem", {}).get("MediaType") == "Video": + logger.debug(f"当前播放内容:{session.get('NowPlayingItem').get('FileName')}," + f"比特率:{int(session.get('NowPlayingItem', {}).get('Bitrate') or 0)}") total_bit_rate += int(session.get("NowPlayingItem", {}).get("Bitrate") or 0) elif media_server == "jellyfin": req_url = "[HOST]Sessions?api_key=[APIKEY]" @@ -505,6 +507,7 @@ class SpeedLimiter(_PluginBase): total_bit_rate += int(session.get("bitrate") or 0) if total_bit_rate: + logger.debug(f"比特率总计:{total_bit_rate}") # 开启智能限速计算上传限速 if self._auto_limit: play_up_speed = self.__calc_limit(total_bit_rate) @@ -512,6 +515,7 @@ class SpeedLimiter(_PluginBase): play_up_speed = self._play_up_speed # 当前正在播放,开始限速 + logger.debug(f"上传限速:{play_up_speed} KB/s") self.__set_limiter(limit_type="播放", upload_limit=play_up_speed, download_limit=self._play_down_speed) else: @@ -554,7 +558,11 @@ class SpeedLimiter(_PluginBase): try: cnt = 0 + text = "" for download in self._downloader: + if cnt != 0: + text = f"{text}\n====================" + text = f"{text}\n下载器:{download}" upload_limit_final = 0 if self._auto_limit and limit_type == "播放": # 开启了播放智能限速 @@ -569,57 +577,48 @@ class SpeedLimiter(_PluginBase): else: # 按比例 allocation_count = sum([int(i) for i in self._allocation_ratio.split(":")]) - upload_limit = int(upload_limit * int(self._allocation_ratio.split(":")[cnt]) / allocation_count) upload_limit_final = int(upload_limit * int(self._allocation_ratio.split(":")[cnt]) / allocation_count) + logger.debug(f"下载器:{download} 分配比例:{self._allocation_ratio.split(':')[cnt]}/{allocation_count} 分配上传限速:{upload_limit_final} KB/s") cnt += 1 - if upload_limit: - text = f"上传:{upload_limit} KB/s" if upload_limit_final: + text = f"{text}\n上传:{upload_limit_final} KB/s" else: - text = f"上传:未限速" + text = f"{text}\n上传:未限速" if download_limit: text = f"{text}\n下载:{download_limit} KB/s" else: text = f"{text}\n下载:未限速" if str(download) == 'qbittorrent': if self._qb: - # 发送通知 - if self._notify: - title = "【播放限速】" - if upload_limit or download_limit: - subtitle = f"Qbittorrent 开始{limit_type}限速" - self.post_message( - mtype=NotificationType.MediaServer, - title=title, - text=f"{subtitle}\n{text}" - ) - else: - self.post_message( - mtype=NotificationType.MediaServer, - title=title, - text=f"Qbittorrent 已取消限速" - ) + self._qb.set_speed_limit(download_limit=download_limit, upload_limit=upload_limit_final) else: if self._tr: - # 发送通知 - if self._notify: - title = "【播放限速】" - if upload_limit or download_limit: - subtitle = f"Transmission 开始{limit_type}限速" - self.post_message( - mtype=NotificationType.MediaServer, - title=title, - text=f"{subtitle}\n{text}" - ) - else: - self.post_message( - mtype=NotificationType.MediaServer, - title=title, - text=f"Transmission 已取消限速" - ) + self._tr.set_speed_limit(download_limit=download_limit, upload_limit=upload_limit_final) + # 发送通知 + self._notify_message(text, bool(upload_limit or download_limit), limit_type) except Exception as e: logger.error(f"设置限速失败:{str(e)}") + def _notify_message(self, text: str, is_limit: bool, limit_type: str): + """ + 发送通知 + """ + if self._notify: + title = "【播放限速】" + if is_limit: + subtitle = f"{limit_type},开始限速" + self.post_message( + mtype=NotificationType.MediaServer, + title=title, + text=f"{subtitle}\n{text}" + ) + else: + self.post_message( + mtype=NotificationType.MediaServer, + title=title, + text=f"{limit_type},取消限速" + ) + @staticmethod def __allow_access(allow_ips: dict, ip: str) -> bool: """ From dd7a9955eb0d588c77e2dfb6f84fe14ebfb04197 Mon Sep 17 00:00:00 2001 From: Doubly <1286398734@qq.com> Date: Sat, 26 Oct 2024 21:35:22 +0800 Subject: [PATCH 135/218] =?UTF-8?q?fix=EF=BC=9A=E4=BF=AE=E5=A4=8D-?= =?UTF-8?q?=E6=AF=94=E7=89=B9=E7=8E=87=E4=B8=BA=E8=B4=9F=E6=95=B0=E6=97=B6?= =?UTF-8?q?=EF=BC=8C=E9=99=90=E9=80=9F=E5=A4=B1=E8=B4=A5=E7=9A=84=E6=83=85?= =?UTF-8?q?=E5=86=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/speedlimiter/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/speedlimiter/__init__.py b/plugins/speedlimiter/__init__.py index 3fff6f9..dd1d829 100644 --- a/plugins/speedlimiter/__init__.py +++ b/plugins/speedlimiter/__init__.py @@ -539,7 +539,8 @@ class SpeedLimiter(_PluginBase): """ 计算智能上传限速 """ - if not self._bandwidth: + # 当前总比特率大于总带宽,则设置为最低限速 + if not self._bandwidth or total_bit_rate > self._bandwidth: return 10 return round((self._bandwidth - total_bit_rate) / 8 / 1024, 2) From cc5bc7a100fa125231b2e3aece74fa4d381ebbfd Mon Sep 17 00:00:00 2001 From: Doubly <1286398734@qq.com> Date: Sat, 26 Oct 2024 21:37:40 +0800 Subject: [PATCH 136/218] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E9=A2=84=E7=95=99?= =?UTF-8?q?=E5=B8=A6=E5=AE=BD=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 ++- plugins/speedlimiter/__init__.py | 24 +++++++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index f5b36a6..cc2c1f0 100644 --- a/package.json +++ b/package.json @@ -182,11 +182,12 @@ "name": "播放限速", "description": "外网播放媒体库视频时,自动对下载器进行限速。", "labels": "网络", - "version": "1.2.1", + "version": "1.3", "icon": "Librespeed_A.png", "author": "Shurelol", "level": 1, "history": { + "v1.3": "修复bug;增加预留带宽设置", "v1.2.1": "修复多下载器时限速比例计算错误问题", "v1.2": "增加不限速路径配置,以应对网盘直链播放的情况" } diff --git a/plugins/speedlimiter/__init__.py b/plugins/speedlimiter/__init__.py index dd1d829..62db177 100644 --- a/plugins/speedlimiter/__init__.py +++ b/plugins/speedlimiter/__init__.py @@ -23,7 +23,7 @@ class SpeedLimiter(_PluginBase): # 插件图标 plugin_icon = "Librespeed_A.png" # 插件版本 - plugin_version = "1.2.1" + plugin_version = "1.3" # 插件作者 plugin_author = "Shurelol" # 作者主页 @@ -48,6 +48,7 @@ class SpeedLimiter(_PluginBase): _noplay_up_speed: float = 0 _noplay_down_speed: float = 0 _bandwidth: float = 0 + _reserved_bandwidth: float = 0 _allocation_ratio: str = "" _auto_limit: bool = False _limit_enabled: bool = False @@ -72,6 +73,10 @@ class SpeedLimiter(_PluginBase): try: # 总带宽 self._bandwidth = int(float(config.get("bandwidth") or 0)) * 1000000 + self._reserved_bandwidth = int(float(config.get("reserved_bandwidth") or 0)) * 1000000 + # 减去预留带宽 + if self._reserved_bandwidth: + self._bandwidth -= self._reserved_bandwidth # 自动限速开关 if self._bandwidth > 0: self._auto_limit = True @@ -319,6 +324,23 @@ class SpeedLimiter(_PluginBase): } } ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'reserved_bandwidth', + 'label': '预留带宽(应对突发流量和额外开销)', + 'placeholder': 'Mbps' + } + } + ] } ] }, From c825867ec31a0a130965854122dcbc1cf57cb234 Mon Sep 17 00:00:00 2001 From: chenrn Date: Sun, 27 Oct 2024 15:00:47 +0800 Subject: [PATCH 137/218] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E9=99=90=E9=80=9F=E5=9C=A8=E9=9D=9E=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E9=99=90=E9=80=9F=E6=A8=A1=E5=BC=8F=E4=B8=8B=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E9=99=90=E9=80=9F=E8=AE=BE=E7=BD=AE=E6=97=A0=E6=95=88=E7=9A=84?= =?UTF-8?q?bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/speedlimiter/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/speedlimiter/__init__.py b/plugins/speedlimiter/__init__.py index 62db177..9ad8dbb 100644 --- a/plugins/speedlimiter/__init__.py +++ b/plugins/speedlimiter/__init__.py @@ -586,7 +586,7 @@ class SpeedLimiter(_PluginBase): if cnt != 0: text = f"{text}\n====================" text = f"{text}\n下载器:{download}" - upload_limit_final = 0 + upload_limit_final = upload_limit if self._auto_limit and limit_type == "播放": # 开启了播放智能限速 if len(self._downloader) == 1: From aec044073db906312a49a3f9882ac5acd0a38d83 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sun, 27 Oct 2024 17:25:21 +0800 Subject: [PATCH 138/218] fix(PersonMeta): v2.0.1 --- package.v2.json | 2 +- plugins.v2/personmeta/__init__.py | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/package.v2.json b/package.v2.json index e0f3e6b..5af0cce 100644 --- a/package.v2.json +++ b/package.v2.json @@ -128,7 +128,7 @@ "name": "媒体库刮削", "description": "定时对媒体库进行刮削,补齐缺失元数据和图片。", "labels": "刮削", - "version": "2.0", + "version": "2.0.1", "icon": "scraper.png", "author": "jxxghp", "level": 1, diff --git a/plugins.v2/personmeta/__init__.py b/plugins.v2/personmeta/__init__.py index ea2fca5..f1c5049 100644 --- a/plugins.v2/personmeta/__init__.py +++ b/plugins.v2/personmeta/__init__.py @@ -38,7 +38,7 @@ class PersonMeta(_PluginBase): # 插件图标 plugin_icon = "actor.png" # 插件版本 - plugin_version = "2.0" + plugin_version = "2.0.1" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -109,7 +109,8 @@ class PersonMeta(_PluginBase): "cron": self._cron, "type": self._type, "delay": self._delay, - "remove_nozh": self._remove_nozh + "remove_nozh": self._remove_nozh, + "mediaservers": self._mediaservers }) def get_state(self) -> bool: @@ -367,9 +368,10 @@ class PersonMeta(_PluginBase): 扫描整个媒体库,刮削演员信息 """ # 所有媒体服务器 - if not settings.MEDIASERVER: + service_infos = self.service_infos() + if not service_infos: return - for server in settings.MEDIASERVER.split(","): + for server, service in service_infos.items(): # 扫描所有媒体库 logger.info(f"开始刮削服务器 {server} 的演员信息 ...") for library in self.mschain.librarys(server): @@ -387,7 +389,7 @@ class PersonMeta(_PluginBase): return # 处理条目 logger.info(f"开始刮削 {item.title} 的演员信息 ...") - self.__update_item(server=server, item=item) + self.__update_item(server=server, item=item, server_type=service.type) logger.info(f"{item.title} 的演员信息刮削完成") logger.info(f"媒体库 {library.name} 的演员信息刮削完成") logger.info(f"服务器 {server} 的演员信息刮削完成") @@ -662,7 +664,7 @@ class PersonMeta(_PluginBase): # 更新人物图片 if profile_path: logger.debug(f"更新人物 {people.get('Name')} 的图片:{profile_path}") - self.set_item_image(server=server, itemid=people.get("Id"), imageurl=profile_path) + self.set_item_image(server=server, server_type=server_type, itemid=people.get("Id"), imageurl=profile_path) # 锁定人物信息 if updated_name: From 9f283fb3f9e2ea8fd312936bae76af2b548ee22e Mon Sep 17 00:00:00 2001 From: ramen <1205925392@qq.com> Date: Sun, 27 Oct 2024 23:26:21 +0800 Subject: [PATCH 139/218] =?UTF-8?q?=E5=85=BC=E5=AE=B9v2=EF=BC=8C=E6=93=8D?= =?UTF-8?q?=E4=BD=9Ccookie=E5=89=8D=E6=A3=80=E6=9F=A5=E4=B8=80=E6=AC=A1Coo?= =?UTF-8?q?kieCloud?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 4 +- plugins/dynamicwechat/__init__.py | 94 +++++++++++++++------------- plugins/dynamicwechat/update_help.py | 39 ++++++++++-- 3 files changed, 85 insertions(+), 52 deletions(-) diff --git a/package.json b/package.json index cc2c1f0..af3d821 100644 --- a/package.json +++ b/package.json @@ -860,11 +860,13 @@ "name": "修改企业微信可信IP", "description": "优先使用cookie,可本地扫码刷新Cookie,当填写两个第三方token时可手机远程更新cookie。", "labels": "消息通知", - "version": "1.2.0", + "version": "1.3.0", "icon": "Wecom_A.png", "author": "RamenRa", "level": 2, + "v2": true, "history": { + "v1.3.0": "兼容v2,操作cookie前检查一次CookieCloud", "v1.2.0": "远程命令/push_qr,立即推送一次二维码到pushplus。添加<本地扫码刷新cookie>", "v1.1.5": "将chromium运行设置为headless模式", "v1.1.4": "放弃self.post_message()的消息推送,还原成send_pushplus_message()", diff --git a/plugins/dynamicwechat/__init__.py b/plugins/dynamicwechat/__init__.py index ded1a5c..89b9863 100644 --- a/plugins/dynamicwechat/__init__.py +++ b/plugins/dynamicwechat/__init__.py @@ -30,7 +30,7 @@ class DynamicWeChat(_PluginBase): # 插件图标 plugin_icon = "Wecom_A.png" # 插件版本 - plugin_version = "1.2.0" + plugin_version = "1.3.0" # 插件作者 plugin_author = "RamenRa" # 作者主页 @@ -96,23 +96,18 @@ class DynamicWeChat(_PluginBase): _scheduler: Optional[BackgroundScheduler] = None def init_plugin(self, config: dict = None): - self._server = f'http://localhost:{settings.NGINX_PORT}/cookiecloud' # 清空配置 - # self._wechatUrl = 'https://work.weixin.qq.com/wework_admin/loginpage_wx?from=myhome' - # self._urls = [] + self._server = f'http://localhost:{settings.NGINX_PORT}/cookiecloud' self._helloimg_s_token = '' self._pushplus_token = '' self._ip_changed = True self._forced_update = False - # self._cookie_valid = False self._use_cookiecloud = True self._local_scan = False self._input_id_list = '' self._cookie_header = "" self._cookie_from_CC = "" self._current_ip_address = self.get_ip_from_url(self._ip_urls[0]) - # logger.info(f"当前公网 IP: {self._current_ip_address}") - # logger.info(f"server host: {self._server} _uuid: {settings.COOKIECLOUD_KEY} _password: {settings.COOKIECLOUD_PASSWORD}") if config: self._enabled = config.get("enabled") self._cron = config.get("cron") @@ -127,22 +122,11 @@ class DynamicWeChat(_PluginBase): self._use_cookiecloud = config.get("use_cookiecloud") self._cookie_header = config.get("cookie_header") self._ip_changed = config.get("ip_changed") - if self._use_cookiecloud: - if settings.COOKIECLOUD_ENABLE_LOCAL: - self._cc_server = PyCookieCloud(url=self._server, uuid=settings.COOKIECLOUD_KEY, - password=settings.COOKIECLOUD_PASSWORD) - logger.info("使用内建CookieCloud服务器") - else: # 使用设置里的cookieCloud - self._cc_server = PyCookieCloud(url=settings.COOKIECLOUD_HOST, uuid=settings.COOKIECLOUD_KEY, - password=settings.COOKIECLOUD_PASSWORD) - logger.info("使用自定义CookieCloud服务器") - if not self._cc_server.check_connection(): - self._cc_server = None - logger.error("没有可用的CookieCloud服务器") + self.try_connect_cc() # 停止现有任务 self.stop_service() - if self._enabled or self._onlyonce: + if self._enabled or self._onlyonce and self._input_id_list: # 定时服务 self._scheduler = BackgroundScheduler(timezone=settings.TZ) # 运行一次定时服务 @@ -199,12 +183,11 @@ class DynamicWeChat(_PluginBase): page.goto(self._wechatUrl) time.sleep(3) # 页面加载等待时间 - current_time = datetime.now() - future_time = current_time + timedelta(seconds=110) - self._future_timestamp = int(future_time.timestamp()) - if self.find_qrc(page): - logger.info("请<重新进入!>插件面板扫码!,每20秒检查登录状态,最大尝试5次") + current_time = datetime.now() + future_time = current_time + timedelta(seconds=110) + self._future_timestamp = int(future_time.timestamp()) + logger.info("请重新进入插件面板扫码!,每20秒检查登录状态,最大尝试5次") max_attempts = 5 attempt = 0 while attempt < max_attempts: @@ -212,13 +195,14 @@ class DynamicWeChat(_PluginBase): # logger.info(f"第 {attempt} 次检查登录状态...") time.sleep(20) # 每20秒检查一次 if self.check_login_status(page, task='local_scanning'): - logger.info("登录成功,更新cookie") self._update_cookie(page, context) # 刷新cookie + self.click_app_management_buttons(page) break else: logger.info("未检测到登录,任务结束") else: logger.info("未找到二维码,任务结束") + logger.info("----------------------本次任务结束----------------------") browser.close() except Exception as e: logger.error(f"本地扫码任务: 本地扫码失败: {e}") @@ -248,10 +232,10 @@ class DynamicWeChat(_PluginBase): # logger.info("检测公网IP完毕") logger.info("----------------------本次任务结束----------------------") - if event: - self.post_message(channel=event.event_data.get("channel"), - title="检测公网IP完毕", - userid=event.event_data.get("user")) + # if event: + # self.post_message(channel=event.event_data.get("channel"), + # title="检测公网IP完毕", + # userid=event.event_data.get("user")) def CheckIP(self): retry_urls = random.sample(self._ip_urls, len(self._ip_urls)) @@ -286,6 +270,20 @@ class DynamicWeChat(_PluginBase): else: return False + def try_connect_cc(self): + if self._use_cookiecloud: + if settings.COOKIECLOUD_ENABLE_LOCAL: + self._cc_server = PyCookieCloud(url=self._server, uuid=settings.COOKIECLOUD_KEY, + password=settings.COOKIECLOUD_PASSWORD) + logger.info("使用内建CookieCloud服务器") + else: # 使用设置里的cookieCloud + self._cc_server = PyCookieCloud(url=settings.COOKIECLOUD_HOST, uuid=settings.COOKIECLOUD_KEY, + password=settings.COOKIECLOUD_PASSWORD) + logger.info("使用自定义CookieCloud服务器") + if not self._cc_server.check_connection(): + self._cc_server = None + logger.error("没有可用的CookieCloud服务器") + def get_ip_from_url(self, url): try: # 发送 GET 请求 @@ -301,8 +299,10 @@ class DynamicWeChat(_PluginBase): else: return "获取IP失败" except Exception as e: - logger.warning(f"{url}获取IP失败,Error: {e}") - # return "获取IP失败" + if "104" in str(e): + pass + else: + logger.warning(f"{url} 获取IP失败,Error: {e}") def find_qrc(self, page): # 查找 iframe 元素并切换到它 @@ -395,6 +395,12 @@ class DynamicWeChat(_PluginBase): pass def _update_cookie(self, page, context): + self._future_timestamp = 0 # 标记二维码失效 + if not self._cc_server.check_connection: # 连接失败返回 False + self.try_connect_cc() # 再尝试一次连接 + if self._cc_server is None: + return + if self._use_cookiecloud and self._cc_server: logger.info("使用二维码登录成功,开始刷新cookie") try: @@ -416,14 +422,12 @@ class DynamicWeChat(_PluginBase): else: logger.error("更新CookieCloud失败") else: - logger.error("连接CookieCloud失败", self._server, settings.COOKIECLOUD_KEY, - settings.COOKIECLOUD_PASSWORD) + logger.error("连接CookieCloud失败", self._server) except Exception as e: logger.error(f"更新cookie发生错误: {e}") else: logger.error("CookieCloud配置错误, 不刷新cookie") - # ----------cookie addd----------------- def get_cookie(self): # 只有从CookieCloud获取cookie成功才返回True try: cookie_header = '' @@ -508,7 +512,7 @@ class DynamicWeChat(_PluginBase): logger.info("等待30秒,请将短信验证码请以'?'结束,发送到<企业微信应用> 如: 110301?") time.sleep(30) # 多等30秒 if self._verification_code: - logger.info("输入验证码:" + self._verification_code) + # logger.info("输入验证码:" + self._verification_code) for digit in self._verification_code: page.keyboard.press(digit) time.sleep(0.3) # 每个数字之间添加少量间隔以确保输入顺利 @@ -654,7 +658,6 @@ class DynamicWeChat(_PluginBase): "helloimg_s_token": self._helloimg_s_token, "pushplus_token": self._pushplus_token, "input_id_list": self._input_id_list, - # "standalone_chrome_address": self._diy_server, "cookie_from_CC": self._cookie_from_CC, "cookie_header": self._cookie_header, "use_cookiecloud": self._use_cookiecloud, @@ -755,7 +758,7 @@ class DynamicWeChat(_PluginBase): 'component': 'VSwitch', 'props': { 'model': 'local_scan', - 'label': '本地扫码刷新Cookie', + 'label': '扫码刷新Cookie和改IP', } } ] @@ -912,6 +915,7 @@ class DynamicWeChat(_PluginBase): if current_time > self._future_timestamp: vaild_text = "二维码已过期" color = "#ff0000" + self._qr_code_image = None else: # 二维码有效,格式化过期时间为 年-月-日 时:分:秒 expiration_time = datetime.fromtimestamp(self._future_timestamp).strftime('%Y-%m-%d %H:%M:%S') @@ -922,7 +926,7 @@ class DynamicWeChat(_PluginBase): if self._qr_code_image is None: img_component = { "component": "div", - "text": "本地扫码刷新cookie任务未运行", + "text": "所有的登录二维码都会在此展示,有效时间仅对应‘本地扫码功能’", "props": { "style": { "fontSize": "22px", @@ -984,7 +988,7 @@ class DynamicWeChat(_PluginBase): } ] }, - img_component # 添加二维码图片或提示信息 + img_component # 二维码图片 ] return base_content @@ -1017,10 +1021,8 @@ class DynamicWeChat(_PluginBase): time.sleep(90) login_status = self.check_login_status(page, 'push_qr_code') if login_status: - if self._use_cookiecloud and self._cc_server: - self._update_cookie(page, context) # 刷新cookie - else: - logger.info("远程推送任务: 没有可用的CookieCloud服务器,只修改可信IP") + self._update_cookie(page, context) # 刷新cookie + logger.info("远程推送任务: 没有可用的CookieCloud服务器,只修改可信IP") self.click_app_management_buttons(page) else: logger.warning("远程推送任务: 未配置pushplus_token和helloimg_s_token") @@ -1092,4 +1094,6 @@ class DynamicWeChat(_PluginBase): self._scheduler.shutdown() self._scheduler = None except Exception as e: - logger.error(str(e)) \ No newline at end of file + logger.error(str(e)) + + diff --git a/plugins/dynamicwechat/update_help.py b/plugins/dynamicwechat/update_help.py index d1018e7..69ac109 100644 --- a/plugins/dynamicwechat/update_help.py +++ b/plugins/dynamicwechat/update_help.py @@ -2,9 +2,39 @@ import hashlib from typing import Dict, Any import json import requests -from app.utils.common import encrypt +import base64 +from hashlib import md5 +from Crypto import Random +from Crypto.Cipher import AES +def bytes_to_key(data: bytes, salt: bytes, output=48) -> bytes: + # 兼容v2 将bytes_to_key和encrypt导入 + assert len(salt) == 8, len(salt) + data += salt + key = md5(data).digest() + final_key = key + while len(final_key) < output: + key = md5(key + data).digest() + final_key += key + return final_key[:output] + + +def encrypt(message: bytes, passphrase: bytes) -> bytes: + """ + CryptoJS 加密原文 + + This is a modified copy of https://stackoverflow.com/questions/36762098/how-to-decrypt-password-from-javascript-cryptojs-aes-encryptpassword-passphras + """ + salt = Random.new().read(8) + key_iv = bytes_to_key(passphrase, salt, 32 + 16) + key = key_iv[:32] + iv = key_iv[32:] + aes = AES.new(key, AES.MODE_CBC, iv) + length = 16 - (len(message) % 16) + data = message + (chr(length) * length).encode() + return base64.b64encode(b"Salted__" + salt + aes.encrypt(data)) + class PyCookieCloud: def __init__(self, url: str, uuid: str, password: str): self.url: str = url @@ -18,11 +48,8 @@ class PyCookieCloud: :return: True if the connection is successful, False otherwise. """ try: - resp = requests.get(self.url) - if resp.status_code == 200: - return True - else: - return False + resp = requests.get(self.url, timeout=3) # 设置超时为3秒 + return resp.status_code == 200 except Exception as e: print(str(e)) return False From 44192f5e672629f1dab3bad1f1641ac868008197 Mon Sep 17 00:00:00 2001 From: ramen <1205925392@qq.com> Date: Mon, 28 Oct 2024 01:45:44 +0800 Subject: [PATCH 140/218] =?UTF-8?q?=E5=85=BC=E5=AE=B9v2=EF=BC=8C=E6=93=8D?= =?UTF-8?q?=E4=BD=9Ccookie=E5=89=8D=E6=A3=80=E6=9F=A5CC=E7=9A=84key?= =?UTF-8?q?=E7=AD=89=E4=BF=A1=E6=81=AF=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/dynamicwechat/__init__.py | 56 ++++++++++++++++------------ plugins/dynamicwechat/update_help.py | 8 ++-- 2 files changed, 36 insertions(+), 28 deletions(-) diff --git a/plugins/dynamicwechat/__init__.py b/plugins/dynamicwechat/__init__.py index 89b9863..960cf32 100644 --- a/plugins/dynamicwechat/__init__.py +++ b/plugins/dynamicwechat/__init__.py @@ -79,7 +79,6 @@ class DynamicWeChat(_PluginBase): # 过期时间 _future_timestamp = 0 - # -------cookie add------------ # cookie有效检测 # _cookie_valid = False # 使用CookieCloud开关 @@ -89,7 +88,6 @@ class DynamicWeChat(_PluginBase): # 登录cookie _cookie_header = "" _server = f'http://localhost:{settings.NGINX_PORT}/cookiecloud' - # -------cookie END------------ _cookiecloud = CookieCloudHelper() # 定时器 @@ -272,17 +270,21 @@ class DynamicWeChat(_PluginBase): def try_connect_cc(self): if self._use_cookiecloud: - if settings.COOKIECLOUD_ENABLE_LOCAL: - self._cc_server = PyCookieCloud(url=self._server, uuid=settings.COOKIECLOUD_KEY, - password=settings.COOKIECLOUD_PASSWORD) - logger.info("使用内建CookieCloud服务器") - else: # 使用设置里的cookieCloud - self._cc_server = PyCookieCloud(url=settings.COOKIECLOUD_HOST, uuid=settings.COOKIECLOUD_KEY, - password=settings.COOKIECLOUD_PASSWORD) - logger.info("使用自定义CookieCloud服务器") - if not self._cc_server.check_connection(): + if settings.COOKIECLOUD_KEY and settings.COOKIECLOUD_PASSWORD: # 使用设置里的cookieCloud + if settings.COOKIECLOUD_ENABLE_LOCAL: + self._cc_server = PyCookieCloud(url=self._server, uuid=settings.COOKIECLOUD_KEY, + password=settings.COOKIECLOUD_PASSWORD) + logger.info("使用内建CookieCloud服务器") + else: # 使用设置里的cookieCloud + self._cc_server = PyCookieCloud(url=settings.COOKIECLOUD_HOST, uuid=settings.COOKIECLOUD_KEY, + password=settings.COOKIECLOUD_PASSWORD) + logger.info("使用自定义CookieCloud服务器") + if not self._cc_server.check_connection(): + self._cc_server = None + logger.error("没有可用的CookieCloud服务器") + else: # 未设置cookieCloud self._cc_server = None - logger.error("没有可用的CookieCloud服务器") + logger.error("没有配置CookieCloud的用户KEY和PASSWORD") def get_ip_from_url(self, url): try: @@ -404,29 +406,34 @@ class DynamicWeChat(_PluginBase): if self._use_cookiecloud and self._cc_server: logger.info("使用二维码登录成功,开始刷新cookie") try: - # logger.info("debug 开始连接CookieCloud") if self._cc_server.check_connection(): - # logger.info("成功连接CookieCloud") current_url = page.url current_cookies = context.cookies(current_url) # 通过 context 获取 cookies - # logger.info("原始 cookies:", current_cookies) + if current_cookies is None: + logger.error("无法获取当前 cookies") + return + formatted_cookies = {} for cookie in current_cookies: - domain = cookie['domain'] + domain = cookie.get('domain') # 使用 get() 方法避免 KeyError + if domain is None: + continue # 跳过没有 domain 的 cookie + if domain not in formatted_cookies: formatted_cookies[domain] = [] formatted_cookies[domain].append(cookie) flag = self._cc_server.update_cookie({'cookie_data': formatted_cookies}) if flag: - logger.info("更新CookieCloud成功") + logger.info("更新 CookieCloud 成功") else: - logger.error("更新CookieCloud失败") + logger.error("更新 CookieCloud 失败") else: - logger.error("连接CookieCloud失败", self._server) + logger.error("连接 CookieCloud 失败", self._server) except Exception as e: - logger.error(f"更新cookie发生错误: {e}") + logger.error( + f"更新 cookie 发生错误: {e}") else: - logger.error("CookieCloud配置错误, 不刷新cookie") + logger.error("CookieCloud 配置错误, 不刷新 cookie") def get_cookie(self): # 只有从CookieCloud获取cookie成功才返回True try: @@ -509,8 +516,11 @@ class DynamicWeChat(_PluginBase): # 在这里使用更安全的方式来检查元素是否存在 captcha_panel = page.wait_for_selector('.receive_captcha_panel', timeout=5000) # 检查验证码面板 if captcha_panel: # 出现了短信验证界面 - logger.info("等待30秒,请将短信验证码请以'?'结束,发送到<企业微信应用> 如: 110301?") - time.sleep(30) # 多等30秒 + if task == 'local_scanning': + time.sleep(6) + else: + logger.info("等待30秒,请将短信验证码请以'?'结束,发送到<企业微信应用> 如: 110301?") + time.sleep(30) # 多等30秒 if self._verification_code: # logger.info("输入验证码:" + self._verification_code) for digit in self._verification_code: diff --git a/plugins/dynamicwechat/update_help.py b/plugins/dynamicwechat/update_help.py index 69ac109..504833e 100644 --- a/plugins/dynamicwechat/update_help.py +++ b/plugins/dynamicwechat/update_help.py @@ -1,9 +1,8 @@ -import hashlib from typing import Dict, Any import json import requests import base64 -from hashlib import md5 +import hashlib from Crypto import Random from Crypto.Cipher import AES @@ -12,10 +11,10 @@ def bytes_to_key(data: bytes, salt: bytes, output=48) -> bytes: # 兼容v2 将bytes_to_key和encrypt导入 assert len(salt) == 8, len(salt) data += salt - key = md5(data).digest() + key = hashlib.md5(data).digest() final_key = key while len(final_key) < output: - key = md5(key + data).digest() + key = hashlib.md5(key + data).digest() final_key += key return final_key[:output] @@ -51,7 +50,6 @@ class PyCookieCloud: resp = requests.get(self.url, timeout=3) # 设置超时为3秒 return resp.status_code == 200 except Exception as e: - print(str(e)) return False def update_cookie(self, cookie: Dict[str, Any]) -> bool: From 90853ececc5dc471f0181720cd174f866435e7e0 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Mon, 28 Oct 2024 11:05:43 +0800 Subject: [PATCH 141/218] fix package.v2.json --- package.v2.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.v2.json b/package.v2.json index 5af0cce..fdd7c96 100644 --- a/package.v2.json +++ b/package.v2.json @@ -128,7 +128,7 @@ "name": "媒体库刮削", "description": "定时对媒体库进行刮削,补齐缺失元数据和图片。", "labels": "刮削", - "version": "2.0.1", + "version": "2.0", "icon": "scraper.png", "author": "jxxghp", "level": 1, @@ -142,7 +142,7 @@ "name": "演职人员刮削", "description": "刮削演职人员图片以及中文名称。", "labels": "媒体库,刮削", - "version": "2.0", + "version": "2.0.1", "icon": "actor.png", "author": "jxxghp", "level": 1, From 331a19be2316b814e8884273f73fd1c8a8fc6900 Mon Sep 17 00:00:00 2001 From: Doubly <1286398734@qq.com> Date: Mon, 28 Oct 2024 18:22:25 +0800 Subject: [PATCH 142/218] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E5=89=A7=E9=9B=86=E8=BE=85=E7=A7=8D=E5=A4=B1=E8=B4=A5=E6=8A=A5?= =?UTF-8?q?=E9=94=99=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 ++- plugins/mediasyncdel/__init__.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index af3d821..2ba4e87 100644 --- a/package.json +++ b/package.json @@ -154,11 +154,12 @@ "name": "媒体文件同步删除", "description": "同步删除历史记录、源文件和下载任务。", "labels": "文件整理", - "version": "1.7", + "version": "1.7.1", "icon": "mediasyncdel.png", "author": "thsrite", "level": 1, "history": { + "v1.7.1": "修复删除剧集辅种失败报错问题", "v1.7": "修复重新整理被一并删除问题", "v1.6": "修复删除辅种", "v1.5": "支持手动删除订阅历史记录(本次更新之后的历史)" diff --git a/plugins/mediasyncdel/__init__.py b/plugins/mediasyncdel/__init__.py index 41cb858..1bd42e6 100644 --- a/plugins/mediasyncdel/__init__.py +++ b/plugins/mediasyncdel/__init__.py @@ -29,7 +29,7 @@ class MediaSyncDel(_PluginBase): # 插件图标 plugin_icon = "mediasyncdel.png" # 插件版本 - plugin_version = "1.7" + plugin_version = "1.7.1" # 插件作者 plugin_author = "thsrite" # 作者主页 @@ -1324,7 +1324,7 @@ class MediaSyncDel(_PluginBase): downloader=downloader) # 暂停辅种 else: - self.chain.stop_torrents(hashs=torrent, download=downloader) + self.chain.stop_torrents(hashs=torrent, downloader=downloader) logger.info(f"辅种:{downloader} - {torrent} 暂停") # 处理辅种的辅种 From ea2d6a4853e0f9507ed8037a5288e357bff8c344 Mon Sep 17 00:00:00 2001 From: thsrite Date: Wed, 30 Oct 2024 15:39:40 +0800 Subject: [PATCH 143/218] =?UTF-8?q?fix=20SpeedLimiter=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=A1=A8=E5=8D=95=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 3 ++- plugins.v2/speedlimiter/__init__.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/package.v2.json b/package.v2.json index fdd7c96..0f5e317 100644 --- a/package.v2.json +++ b/package.v2.json @@ -156,11 +156,12 @@ "name": "播放限速", "description": "外网播放媒体库视频时,自动对下载器进行限速。", "labels": "网络", - "version": "2.0", + "version": "2.1", "icon": "Librespeed_A.png", "author": "Shurelol", "level": 1, "history": { + "v2.1": "修复表单参数", "v2.0": "兼容MoviePilot V2 版本", "v1.2": "增加不限速路径配置,以应对网盘直链播放的情况" } diff --git a/plugins.v2/speedlimiter/__init__.py b/plugins.v2/speedlimiter/__init__.py index 5dcfc1b..3f01708 100644 --- a/plugins.v2/speedlimiter/__init__.py +++ b/plugins.v2/speedlimiter/__init__.py @@ -19,7 +19,7 @@ class SpeedLimiter(_PluginBase): # 插件图标 plugin_icon = "Librespeed_A.png" # 插件版本 - plugin_version = "2.0" + plugin_version = "2.1" # 插件作者 plugin_author = "Shurelol" # 作者主页 @@ -180,7 +180,7 @@ class SpeedLimiter(_PluginBase): 'multiple': True, 'chips': True, 'clearable': True, - 'model': 'downloaders', + 'model': 'downloader', 'label': '下载器', 'items': [{"title": config.name, "value": config.name} for config in self.downloader_helper.get_configs().values()] From f71e21113ea4c12d01b28eb5fa7213ce9bb540df Mon Sep 17 00:00:00 2001 From: honue Date: Thu, 31 Oct 2024 10:48:57 +0800 Subject: [PATCH 144/218] fix: TrackerEditor v2 --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index af3d821..cbbcfea 100644 --- a/package.json +++ b/package.json @@ -705,7 +705,8 @@ "version": "1.5", "icon": "trackereditor_A.png", "author": "honue", - "level": 1 + "level": 1, + "v2": true }, "ContractCheck": { "name": "契约检查", From f0bc8c101f0a6e6cc523192b50b3aa2044e911d2 Mon Sep 17 00:00:00 2001 From: ramen <1205925392@qq.com> Date: Fri, 1 Nov 2024 03:36:47 +0800 Subject: [PATCH 145/218] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E4=B8=80=E4=BA=9B?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 ++- plugins/dynamicwechat/__init__.py | 34 +++++++++++++++++-------------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index 792a89b..4a40be9 100644 --- a/package.json +++ b/package.json @@ -862,12 +862,13 @@ "name": "修改企业微信可信IP", "description": "优先使用cookie,可本地扫码刷新Cookie,当填写两个第三方token时可手机远程更新cookie。", "labels": "消息通知", - "version": "1.3.0", + "version": "1.3.1", "icon": "Wecom_A.png", "author": "RamenRa", "level": 2, "v2": true, "history": { + "v1.3.1": "修正一些逻辑判断,修改ip成功会通知一次", "v1.3.0": "兼容v2,操作cookie前检查一次CookieCloud", "v1.2.0": "远程命令/push_qr,立即推送一次二维码到pushplus。添加<本地扫码刷新cookie>", "v1.1.5": "将chromium运行设置为headless模式", diff --git a/plugins/dynamicwechat/__init__.py b/plugins/dynamicwechat/__init__.py index 960cf32..6cc8bbf 100644 --- a/plugins/dynamicwechat/__init__.py +++ b/plugins/dynamicwechat/__init__.py @@ -19,7 +19,7 @@ from app.helper.cookiecloud import CookieCloudHelper from app.log import logger from app.plugins import _PluginBase from app.plugins.dynamicwechat.update_help import PyCookieCloud -from app.schemas.types import EventType +from app.schemas.types import EventType, NotificationType class DynamicWeChat(_PluginBase): @@ -30,7 +30,7 @@ class DynamicWeChat(_PluginBase): # 插件图标 plugin_icon = "Wecom_A.png" # 插件版本 - plugin_version = "1.3.0" + plugin_version = "1.3.1" # 插件作者 plugin_author = "RamenRa" # 作者主页 @@ -124,7 +124,7 @@ class DynamicWeChat(_PluginBase): # 停止现有任务 self.stop_service() - if self._enabled or self._onlyonce and self._input_id_list: + if (self._enabled or self._onlyonce) and self._input_id_list: # 定时服务 self._scheduler = BackgroundScheduler(timezone=settings.TZ) # 运行一次定时服务 @@ -398,12 +398,11 @@ class DynamicWeChat(_PluginBase): def _update_cookie(self, page, context): self._future_timestamp = 0 # 标记二维码失效 - if not self._cc_server.check_connection: # 连接失败返回 False - self.try_connect_cc() # 再尝试一次连接 - if self._cc_server is None: - return - - if self._use_cookiecloud and self._cc_server: + if self._use_cookiecloud: + if not self._cc_server: # 连接失败返回 False + self.try_connect_cc() # 再尝试一次连接 + if self._cc_server is None: + return logger.info("使用二维码登录成功,开始刷新cookie") try: if self._cc_server.check_connection(): @@ -433,15 +432,12 @@ class DynamicWeChat(_PluginBase): logger.error( f"更新 cookie 发生错误: {e}") else: - logger.error("CookieCloud 配置错误, 不刷新 cookie") + logger.error("CookieCloud没有启用或配置错误, 不刷新cookie") def get_cookie(self): # 只有从CookieCloud获取cookie成功才返回True try: cookie_header = '' if self._use_cookiecloud: - # if self._cookie_valid: # 如果无效 - # return self._cookie_from_CC - # return True cookies, msg = self._cookiecloud.download() if not cookies: # CookieCloud获取cookie失败 logger.error(f"CookieCloud获取cookie失败,失败原因:{msg}") @@ -583,6 +579,14 @@ class DynamicWeChat(_PluginBase): logger.info(f"应用{app_id} 已被禁用,可能是没有设置接收api") if self._ip_changed: logger.info(f"应用: {app_id} 输入IP:" + self._current_ip_address) + ip_parts = self._current_ip_address.split('.') + masked_ip = f"{ip_parts[0]}.{len(ip_parts[1]) * '*'}.{len(ip_parts[2]) * '*'}.{ip_parts[3]}" + self.post_message( + mtype=NotificationType.Plugin, + title="更新可信IP成功", + text='应用: ' + app_id + ' 输入IP:' + masked_ip, + # image=img_src + ) return else: logger.error("未找到应用id,修改IP失败") @@ -936,7 +940,7 @@ class DynamicWeChat(_PluginBase): if self._qr_code_image is None: img_component = { "component": "div", - "text": "所有的登录二维码都会在此展示,有效时间仅对应‘本地扫码功能’", + "text": "登录二维码都会在此展示,二维码有6秒延时,过期时间仅对应‘本地扫码功能’", "props": { "style": { "fontSize": "22px", @@ -1032,7 +1036,7 @@ class DynamicWeChat(_PluginBase): login_status = self.check_login_status(page, 'push_qr_code') if login_status: self._update_cookie(page, context) # 刷新cookie - logger.info("远程推送任务: 没有可用的CookieCloud服务器,只修改可信IP") + # logger.info("远程推送任务: 没有可用的CookieCloud服务器,只修改可信IP") self.click_app_management_buttons(page) else: logger.warning("远程推送任务: 未配置pushplus_token和helloimg_s_token") From 8d257ab8e0d237197e4f70114200c239d0c3d938 Mon Sep 17 00:00:00 2001 From: FeranyDev <43532576+FeranyDev@users.noreply.github.com> Date: Mon, 4 Nov 2024 11:01:40 +0800 Subject: [PATCH 146/218] Update iyuu_helper.py --- plugins/iyuuautoseed/iyuu_helper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/iyuuautoseed/iyuu_helper.py b/plugins/iyuuautoseed/iyuu_helper.py index 7fd1a70..d322385 100644 --- a/plugins/iyuuautoseed/iyuu_helper.py +++ b/plugins/iyuuautoseed/iyuu_helper.py @@ -11,7 +11,7 @@ class IyuuHelper(object): 适配新版本IYUU开发版 """ _version = "8.2.0" - _api_base = "https://dev.iyuu.cn" + _api_base = "https://2025.iyuu.cn" _sites = {} _token = None _sid_sha1 = None From 1d2d6343d8054dc43c9a1b0364c931fd3906b45e Mon Sep 17 00:00:00 2001 From: FeranyDev <43532576+FeranyDev@users.noreply.github.com> Date: Mon, 4 Nov 2024 11:01:56 +0800 Subject: [PATCH 147/218] Update __init__.py --- plugins/iyuuautoseed/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/iyuuautoseed/__init__.py b/plugins/iyuuautoseed/__init__.py index 64e4f2f..d1d528e 100644 --- a/plugins/iyuuautoseed/__init__.py +++ b/plugins/iyuuautoseed/__init__.py @@ -34,7 +34,7 @@ class IYUUAutoSeed(_PluginBase): # 插件图标 plugin_icon = "IYUU.png" # 插件版本 - plugin_version = "1.9.5" + plugin_version = "1.9.6" # 插件作者 plugin_author = "jxxghp" # 作者主页 From a3937f52ab384947a81022fa825e6547b3a4fb7e Mon Sep 17 00:00:00 2001 From: FeranyDev <43532576+FeranyDev@users.noreply.github.com> Date: Mon, 4 Nov 2024 11:09:21 +0800 Subject: [PATCH 148/218] Update __init__.py --- plugins/iyuuautoseed/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/iyuuautoseed/__init__.py b/plugins/iyuuautoseed/__init__.py index d1d528e..64e4f2f 100644 --- a/plugins/iyuuautoseed/__init__.py +++ b/plugins/iyuuautoseed/__init__.py @@ -34,7 +34,7 @@ class IYUUAutoSeed(_PluginBase): # 插件图标 plugin_icon = "IYUU.png" # 插件版本 - plugin_version = "1.9.6" + plugin_version = "1.9.5" # 插件作者 plugin_author = "jxxghp" # 作者主页 From 2014084521d85f586ec5eb8232f0c09bfaa222e6 Mon Sep 17 00:00:00 2001 From: FeranyDev <43532576+FeranyDev@users.noreply.github.com> Date: Mon, 4 Nov 2024 11:11:35 +0800 Subject: [PATCH 149/218] Update __init__.py --- plugins/iyuuautoseed/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/iyuuautoseed/__init__.py b/plugins/iyuuautoseed/__init__.py index 64e4f2f..d1d528e 100644 --- a/plugins/iyuuautoseed/__init__.py +++ b/plugins/iyuuautoseed/__init__.py @@ -34,7 +34,7 @@ class IYUUAutoSeed(_PluginBase): # 插件图标 plugin_icon = "IYUU.png" # 插件版本 - plugin_version = "1.9.5" + plugin_version = "1.9.6" # 插件作者 plugin_author = "jxxghp" # 作者主页 From 59634c035714b9bc2b90633f7195c543e19cfc2a Mon Sep 17 00:00:00 2001 From: jxxghp Date: Mon, 4 Nov 2024 11:42:25 +0800 Subject: [PATCH 150/218] fix iyuu --- package.v2.json | 3 ++- plugins.v2/iyuuautoseed/__init__.py | 2 +- plugins.v2/iyuuautoseed/iyuu_helper.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/package.v2.json b/package.v2.json index 0f5e317..45ec900 100644 --- a/package.v2.json +++ b/package.v2.json @@ -211,11 +211,12 @@ "name": "IYUU自动辅种", "description": "基于IYUU官方Api实现自动辅种。", "labels": "做种,IYUU", - "version": "2.0.1", + "version": "2.1", "icon": "IYUU.png", "author": "jxxghp", "level": 2, "history": { + "v2.1": "调整IYUU最新域名", "v2.0": "兼容MoviePilot V2 版本" } } diff --git a/plugins.v2/iyuuautoseed/__init__.py b/plugins.v2/iyuuautoseed/__init__.py index dfba16c..5d1ffec 100644 --- a/plugins.v2/iyuuautoseed/__init__.py +++ b/plugins.v2/iyuuautoseed/__init__.py @@ -33,7 +33,7 @@ class IYUUAutoSeed(_PluginBase): # 插件图标 plugin_icon = "IYUU.png" # 插件版本 - plugin_version = "2.0.1" + plugin_version = "2.1" # 插件作者 plugin_author = "jxxghp" # 作者主页 diff --git a/plugins.v2/iyuuautoseed/iyuu_helper.py b/plugins.v2/iyuuautoseed/iyuu_helper.py index 7fd1a70..d322385 100644 --- a/plugins.v2/iyuuautoseed/iyuu_helper.py +++ b/plugins.v2/iyuuautoseed/iyuu_helper.py @@ -11,7 +11,7 @@ class IyuuHelper(object): 适配新版本IYUU开发版 """ _version = "8.2.0" - _api_base = "https://dev.iyuu.cn" + _api_base = "https://2025.iyuu.cn" _sites = {} _token = None _sid_sha1 = None From e58169a4183980667da0ff4ac2a98e3c75f718b4 Mon Sep 17 00:00:00 2001 From: FeranyDev <43532576+FeranyDev@users.noreply.github.com> Date: Mon, 4 Nov 2024 12:19:55 +0800 Subject: [PATCH 151/218] Update package.json --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 4a40be9..b0e5f9a 100644 --- a/package.json +++ b/package.json @@ -296,11 +296,12 @@ "name": "IYUU自动辅种", "description": "基于IYUU官方Api实现自动辅种。", "labels": "做种,IYUU", - "version": "1.9.5", + "version": "1.9.6", "icon": "IYUU.png", "author": "jxxghp", "level": 2, "history": { + "v1.9.6": "调整IYUU最新域名", "v1.9.5": "Revert qBittorrent跳检之后自动开始", "v1.9.4": "修复qBittorrent辅种后不会自动开始做种", "v1.9.3": "修复Monika因缺少rsskey,种子下载失败的问题", From 07542afaa866acbc19d582895393987a9e36f9be Mon Sep 17 00:00:00 2001 From: jxxghp Date: Tue, 5 Nov 2024 16:48:54 +0800 Subject: [PATCH 152/218] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20package.v2.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/package.v2.json b/package.v2.json index 45ec900..c369fb7 100644 --- a/package.v2.json +++ b/package.v2.json @@ -178,22 +178,6 @@ "v2.0": "兼容MoviePilot V2 版本" } }, - "ConfigCenter": { - "name": "配置中心", - "description": "快速调整部分系统设定。", - "labels": "系统设置", - "version": "3.2", - "icon": "setting.png", - "author": "jxxghp", - "level": 1, - "history": { - "v3.2": "优化显示,按功能区分", - "v3.1": "重构配置更新逻辑,从而与主程序保持一致", - "v3.0": "兼容MoviePilot V2 版本", - "v2.6": "支持DOH相关配置项", - "v2.5": "增加Github加速服务器设置项" - } - }, "TorrentRemover": { "name": "自动删种", "description": "自动删除下载器中的下载任务。", From 1d8bec5db2ae6dc34a4b97aa8ddd5c2f69d3f2ba Mon Sep 17 00:00:00 2001 From: jxxghp Date: Tue, 5 Nov 2024 16:50:17 +0800 Subject: [PATCH 153/218] Delete plugins.v2/configcenter directory --- plugins.v2/configcenter/__init__.py | 966 ---------------------------- 1 file changed, 966 deletions(-) delete mode 100644 plugins.v2/configcenter/__init__.py diff --git a/plugins.v2/configcenter/__init__.py b/plugins.v2/configcenter/__init__.py deleted file mode 100644 index 62f266a..0000000 --- a/plugins.v2/configcenter/__init__.py +++ /dev/null @@ -1,966 +0,0 @@ -from typing import Any, List, Dict, Tuple - -from app.core.config import settings -from app.core.module import ModuleManager -from app.log import logger -from app.plugins import _PluginBase - - -class ConfigCenter(_PluginBase): - # 插件名称 - plugin_name = "配置中心" - # 插件描述 - plugin_desc = "快速调整部分系统设定。" - # 插件图标 - plugin_icon = "setting.png" - # 插件版本 - plugin_version = "3.2" - # 插件作者 - plugin_author = "jxxghp" - # 作者主页 - author_url = "https://github.com/jxxghp" - # 插件配置项ID前缀 - plugin_config_prefix = "configcenter_" - # 加载顺序 - plugin_order = 0 - # 可使用的用户级别 - auth_level = 1 - - # 私有属性 - _enabled = False - _params = "" - - def init_plugin(self, config: dict = None): - if not config: - return - - # 清理插件配置,从而实现默认使用.env中的数据源 - self._params = config.pop("params", "") - if "undefined" in config: - del config["undefined"] - if "_tabs" in config: - del config["_tabs"] - self.update_config(config={}) - - # 将自定义配置存储到 __ConfigCenter__ - self.update_config(plugin_id="__ConfigCenter__", config={"params": self._params}) - - logger.info(f"正在应用配置中心配置:{config}") - - # 追加自定义配置中的内容 - params = self.__parse_params(self._params) or {} - config.update(**params) - - # 批量更新配置,并获取更新结果 - update_results = settings.update_settings(config) - - # 遍历更新结果 - for key, (success, message) in update_results.items(): - if not success: - self.__log_and_notify_error(f"配置项 '{key}' 更新失败:{message}") - elif message: - self.__log_and_notify_error(f"配置项 '{key}' 更新时出现警告:{message}") - - # 重新加载模块 - ModuleManager().reload() - - def __log_and_notify_error(self, message): - """ - 记录错误日志并发送系统通知 - """ - logger.error(message) - self.systemmessage.put(message, title=self.plugin_name) - - @staticmethod - def __parse_params(param_str: str) -> dict: - """ - 解析自定义配置 - """ - if not param_str: - return {} - result = {} - params = param_str.split("\n") - for param in params: - if not param: - continue - if str(param).strip().startswith("#"): - continue - parts = param.split("=", 1) - if len(parts) != 2: - continue - key = parts[0].strip() - value = parts[1].strip() - if not key: - continue - if not value: - continue - result[key] = value - return result - - def get_state(self) -> bool: - return True - - @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、数据结构 - """ - default_settings = {} - settings_model = self.get_settings_model() - keys = self.extract_keys(settings_model) - for key in keys: - if hasattr(settings, key): - default_settings[key] = getattr(settings, key) - - config = self.get_config(plugin_id="__ConfigCenter__") or {} - params_str = config.get("params") or "" - params = self.__parse_params(params_str) or {} - updated_params = {key: getattr(settings, key) for key in params if hasattr(settings, key)} - params_str = "\n".join(f"{key}={value}" for key, value in updated_params.items()) - default_settings["params"] = params_str - - return [ - { - "component": "VForm", - "content": settings_model - } - ], default_settings - - def extract_keys(self, components: List[dict]) -> List[str]: - """ - 递归提取所有组件中的model键 - """ - models = [] - for component in components: - # 检查当前组件的props中是否有model - props = component.get("props", {}) - model = props.get("model") - if model: - models.append(model) - - # 如果当前组件有嵌套的content,递归提取 - nested_content = component.get("content", []) - if isinstance(nested_content, list): - models.extend(self.extract_keys(nested_content)) - elif isinstance(nested_content, dict): - models.extend(self.extract_keys([nested_content])) - - return models - - @staticmethod - def get_settings_model() -> List[dict]: - """ - 获取配置项模型 - """ - return [ - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": { - "cols": 12, - }, - "content": [ - { - "component": "VAlert", - "props": { - "type": "warning", - "variant": "tonal", - "text": "注意:部分配置项的更改可能需要重启服务才能生效,为确保配置一致性,已在环境变量中的相关配置项,请手动更新" - } - } - ] - } - ] - }, - { - "component": "VTabs", - "props": { - "model": "_tabs", - "height": 72, - "fixed-tabs": True, - "style": { - "margin-top": "8px", - "margin-bottom": "10px", - } - }, - "content": [ - { - "component": "VTab", - "props": { - "value": "basic_tab", - "style": { - "padding-top": "10px", - "padding-bottom": "10px", - "font-size": "16px" - }, - }, - "text": "基础设置" - }, - { - "component": "VTab", - "props": { - "value": "network_tab", - "style": { - "padding-top": "10px", - "padding-bottom": "10px", - "font-size": "16px" - }, - }, - "text": "网络设置" - }, - { - "component": "VTab", - "props": { - "value": "media_and_download_tab", - "style": { - "padding-top": "10px", - "padding-bottom": "10px", - "font-size": "16px" - }, - }, - "text": "媒体与下载" - }, - { - "component": "VTab", - "props": { - "value": "search_and_transfer_tab", - "style": { - "padding-top": "10px", - "padding-bottom": "10px", - "font-size": "16px" - }, - }, - "text": "搜索与整理" - }, - { - "component": "VTab", - "props": { - "value": "params_tab", - "style": { - "padding-top": "10px", - "padding-bottom": "10px", - "font-size": "16px" - }, - }, - "text": "自定义配置" - }, - ] - }, - { - "component": "VWindow", - "props": { - "model": "_tabs", - }, - "content": [ - # 备份分类块 - # { - # "component": "VWindowItem", - # "props": { - # "value": "client_setting", - # "style": { - # "padding-top": "20px", - # "padding-bottom": "20px" - # }, - # }, - # "content": [ - # { - # "component": "VRow", - # "props": { - # "align": "center" - # }, - # "content": [] - # } - # ] - # }, - # 基础 - { - "component": "VWindowItem", - "props": { - "value": "basic_tab", - "style": { - "padding-top": "20px", - "padding-bottom": "20px" - }, - }, - "content": [ - { - "component": "VRow", - "props": { - "align": "center" - }, - "content": [ - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6, - }, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "AUXILIARY_AUTH_ENABLE", - "label": "启用用户辅助认证", - "hint": "启用后允许通过外部服务进行认证、单点登录以及自动创建用户", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6, - }, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "GLOBAL_IMAGE_CACHE", - "label": "全局图片缓存", - "hint": "是否启用全局图片缓存,将媒体图片缓存到本地", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6. - }, - "content": [ - { - "component": "VSelect", - "props": { - "model": "WALLPAPER", - "label": "登录首页电影海报", - "items": [ - { - "title": "TheMovieDb电影海报", - "value": "tmdb" - }, - { - "title": "Bing每日壁纸", - "value": "bing" - } - ], - "hint": "登录首页电影海报", - "persistent-hint": True, - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6, - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "API_TOKEN", - "label": "API密钥", - "hint": "用于Jellyseerr/Overseerr、媒体服务器Webhook等配置以及部分支持API_TOKEN的API请求", - "persistent-hint": True, - "clearable": True, - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12 - }, - "content": [ - { - "component": "VTextarea", - "props": { - "model": "PLUGIN_MARKET", - "label": "插件市场", - "hint": "插件市场仓库地址,多个地址使用逗号分隔,确保每个地址以/结尾", - "persistent-hint": True, - "clearable": True, - } - } - ] - }, - - ] - } - ] - }, - # 网络 - { - "component": "VWindowItem", - "props": { - "value": "network_tab", - "style": { - "padding-top": "20px", - "padding-bottom": "20px" - }, - }, - "content": [ - # DOH - { - "component": "VRow", - "props": { - "align": "center", - }, - "content": [ - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "DOH_ENABLE", - "label": "启用DNS over HTTPS", - "hint": "启用后对特定域名使用DOH解析以避免DNS污染", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6, - }, - "content": [ - { - "component": "VAlert", - "props": { - "type": "info", - "variant": "tonal", - "style": "white-space: pre-line;", - "text": "如果已经配置好 'PROXY_HOST' ,建议关闭 'DOH' ", - }, - }, - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "DOH_DOMAINS", - "label": "DOH解析的域名", - "hint": "DOH解析的域名列表,多个域名使用逗号分隔", - "persistent-hint": True, - "clearable": True, - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "DOH_RESOLVERS", - "label": "DOH解析服务器", - "hint": "DOH解析服务器列表,多个服务器使用逗号分隔", - "persistent-hint": True, - "clearable": True, - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "GITHUB_TOKEN", - "label": "GitHub Token", - "placeholder": "格式: ghp_**** 或 github_pat_****", - "hint": "GitHub Token,提高请求API限流阈值", - "persistent-hint": True, - "clearable": True, - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "OCR_HOST", - "label": "验证码识别服务器", - "hint": "验证码识别服务器地址", - "persistent-hint": True, - "clearable": True, - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "GITHUB_PROXY", - "label": "GitHub加速服务器", - "placeholder": "格式: https://mirror.ghproxy.com/", - "hint": "留空则不使用GitHub加速服务器,(注意末尾需要带/)", - "persistent-hint": True, - "clearable": True, - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "PIP_PROXY", - "label": "PIP加速服务器", - "hint": "留空则不使用PIP加速服务器", - "placeholder": "格式: https://pypi.tuna.tsinghua.edu.cn/simple", - "persistent-hint": True, - "clearable": True, - } - } - ] - }, - ] - }, - # Tmdb相关 - { - "component": "VRow", - "props": { - "align": "center" - }, - "content": [ - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "TMDB_API_DOMAIN", - "label": "TMDB API地址", - "hint": "访问正常时无需更改;无法访问时替换为其他中转服务地址,确保连通性", - "persistent-hint": True, - "clearable": True, - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "TMDB_IMAGE_DOMAIN", - "label": "TheMovieDb图片服务器", - "placeholder": "例如:static-mdb.v.geilijiasu.com", - "hint": "访问正常时无需更改;无法访问时可替换为其他可用地址,确保连通性", - "persistent-hint": True, - "clearable": True, - } - } - ] - }, - ] - }, - ] - }, - # 媒体与下载 - { - "component": "VWindowItem", - "props": { - "value": "media_and_download_tab", - "style": { - "padding-top": "20px", - "padding-bottom": "20px" - }, - }, - "content": [ - { - "component": "VRow", - "props": { - "align": "center", - }, - "content": [ - { - "component": "VCol", - "props": { - "cols": 12, - "md": 9, - }, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "DOWNLOAD_SUBTITLE", - "label": "自动下载站点字幕", - "hint": "自动下载站点字幕(如有)", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 3, - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "MEDIASERVER_SYNC_INTERVAL", - "label": "媒体服务器同步间隔", - "hint": "媒体服务器同步间隔", - "persistent-hint": True, - "prefix": "每", - "suffix": "小时", - "type": "number", - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 12, - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "AUTO_DOWNLOAD_USER", - "label": "交互搜索自动下载用户ID", - "hint": "使用,分割,设置为 all 代表所有用户自动择优下载,未设置需要用户手动选择资源或者回复`0`才自动择优下载", - "persistent-hint": True, - "clearable": True, - } - } - ] - }, - ] - }, - ] - }, - # 搜索与整理 - { - "component": "VWindowItem", - "props": { - "value": "search_and_transfer_tab", - "style": { - "padding-top": "20px", - "padding-bottom": "20px" - }, - }, - "content": [ - { - "component": "VRow", - "props": { - "align": "center" - }, - "content": [ - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6, - }, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "SEARCH_MULTIPLE_NAME", - "label": "资源搜索整合多名称结果", - "hint": "搜索多个名称时是整合多名称的结果", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 3, - }, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "FANART_ENABLE", - "label": "使用Fanart图片数据源", - "hint": "启用Fanart图片数据源", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 3, - }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "META_CACHE_EXPIRE", - "label": "元数据缓存时间", - "hint": "0或负值时,使用系统默认缓存时间", - "persistent-hint": True, - "prefix": "每", - "suffix": "小时", - "type": "number", - } - } - ] - }, - ] - }, - { - "component": "VRow", - "props": { - "align": "center" - }, - "content": [ - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VSelect", - "props": { - "model": "RECOGNIZE_SOURCE", - "label": "媒体信息识别来源", - "items": [ - { - "title": "TheMovieDb", - "value": "themoviedb" - }, - { - "title": "豆瓣", - "value": "douban" - } - ], - "hint": "媒体信息识别来源", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 6 - }, - "content": [ - { - "component": "VSelect", - "props": { - "model": "SCRAP_SOURCE", - "label": "刮削元数据及图片使用的数据源", - "items": [ - { - "title": "TheMovieDb", - "value": "themoviedb" - }, - { - "title": "豆瓣", - "value": "douban" - } - ], - "hint": "刮削元数据及图片使用的数据源", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12 - }, - "content": [ - { - "component": "VTextarea", - "props": { - "model": "MOVIE_RENAME_FORMAT", - "label": "电影重命名格式", - "hint": "电影重命名格式,使用Jinja2语法,每行一个配置项,参考:https://jinja.palletsprojects.com/en/3.0.x/templates/", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12 - }, - "content": [ - { - "component": "VTextarea", - "props": { - "model": "TV_RENAME_FORMAT", - "label": "电视剧重命名格式", - "hint": "电视剧重命名格式,使用Jinja2语法", - "persistent-hint": True - } - } - ] - }, - { - "component": "VCol", - "props": { - "cols": 12, - "md": 12, - }, - "content": [ - { - "component": "VAlert", - "props": { - "type": "warning", - "variant": "tonal", - "style": "white-space: pre-line;", - "text": "Jinja2语法参考:" - }, - "content": [ - { - "component": "a", - "props": { - "href": "https://jinja.palletsprojects.com/en/3.0.x/templates/", - "target": "_blank" - }, - "content": [ - { - "component": "u", - "text": "https://jinja.palletsprojects.com/en/3.0.x/templates/" - } - ] - } - ] - }, - ] - } - ] - } - ] - }, - # 自定义 - { - "component": "VWindowItem", - "props": { - "value": "params_tab", - "style": { - "padding-top": "20px", - "padding-bottom": "20px" - }, - }, - "content": [ - { - "component": "VRow", - "props": { - "align": "center", - }, - "content": [ - { - "component": "VCol", - "props": { - "cols": 12, - }, - "content": [ - { - "component": "VTextarea", - "props": { - "model": "params", - "label": "自定义配置", - "hint": "自定义配置,每行一个配置项,格式:配置项=值", - "persistent-hint": True - } - } - ] - } - ] - }, - ] - } - ] - }, - ] - - def get_page(self) -> List[dict]: - pass - - def stop_service(self): - """ - 退出插件 - """ - pass From a8de2183462c7551495a273cc9bc35c41a871a7b Mon Sep 17 00:00:00 2001 From: ramen <1205925392@qq.com> Date: Wed, 6 Nov 2024 17:06:27 +0800 Subject: [PATCH 154/218] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BC=BA=E5=88=B6?= =?UTF-8?q?=E6=9B=B4=E6=94=B9IP=E6=97=B6=E9=85=8D=E7=BD=AE=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E5=BB=B6=E6=97=B6=E8=BF=87=E9=95=BF=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 +- plugins/dynamicwechat/__init__.py | 112 +++++++++++++++------ plugins/dynamicwechat/src/debug.py | 144 --------------------------- plugins/dynamicwechat/update_help.py | 31 +++++- 4 files changed, 113 insertions(+), 177 deletions(-) delete mode 100644 plugins/dynamicwechat/src/debug.py diff --git a/package.json b/package.json index b0e5f9a..a573177 100644 --- a/package.json +++ b/package.json @@ -863,12 +863,13 @@ "name": "修改企业微信可信IP", "description": "优先使用cookie,可本地扫码刷新Cookie,当填写两个第三方token时可手机远程更新cookie。", "labels": "消息通知", - "version": "1.3.1", + "version": "1.4.0", "icon": "Wecom_A.png", "author": "RamenRa", "level": 2, "v2": true, "history": { + "v1.4.0": "修复强制更改IP时配置面板延时过长的问题。庆祝v2进入正式版,显示了一个没用的参数", "v1.3.1": "修正一些逻辑判断,修改ip成功会通知一次", "v1.3.0": "兼容v2,操作cookie前检查一次CookieCloud", "v1.2.0": "远程命令/push_qr,立即推送一次二维码到pushplus。添加<本地扫码刷新cookie>", diff --git a/plugins/dynamicwechat/__init__.py b/plugins/dynamicwechat/__init__.py index 6cc8bbf..5e654ca 100644 --- a/plugins/dynamicwechat/__init__.py +++ b/plugins/dynamicwechat/__init__.py @@ -30,7 +30,7 @@ class DynamicWeChat(_PluginBase): # 插件图标 plugin_icon = "Wecom_A.png" # 插件版本 - plugin_version = "1.3.1" + plugin_version = "1.4.0" # 插件作者 plugin_author = "RamenRa" # 作者主页 @@ -80,11 +80,11 @@ class DynamicWeChat(_PluginBase): _future_timestamp = 0 # cookie有效检测 - # _cookie_valid = False + _cookie_valid = True + # cookie存活时间 + _cookie_lifetime = 0 # 使用CookieCloud开关 _use_cookiecloud = True - # 从CookieCloud获取的cookie - _cookie_from_CC = "" # 登录cookie _cookie_header = "" _server = f'http://localhost:{settings.NGINX_PORT}/cookiecloud' @@ -95,7 +95,6 @@ class DynamicWeChat(_PluginBase): def init_plugin(self, config: dict = None): # 清空配置 - self._server = f'http://localhost:{settings.NGINX_PORT}/cookiecloud' self._helloimg_s_token = '' self._pushplus_token = '' self._ip_changed = True @@ -104,8 +103,8 @@ class DynamicWeChat(_PluginBase): self._local_scan = False self._input_id_list = '' self._cookie_header = "" - self._cookie_from_CC = "" self._current_ip_address = self.get_ip_from_url(self._ip_urls[0]) + self._cookie_lifetime = PyCookieCloud.load_cookie_lifetime() if config: self._enabled = config.get("enabled") self._cron = config.get("cron") @@ -114,13 +113,11 @@ class DynamicWeChat(_PluginBase): self._current_ip_address = config.get("current_ip_address") self._pushplus_token = config.get("pushplus_token") self._helloimg_s_token = config.get("helloimg_s_token") - self._cookie_from_CC = config.get("cookie_from_CC") self._forced_update = config.get("forced_update") self._local_scan = config.get("local_scan") self._use_cookiecloud = config.get("use_cookiecloud") self._cookie_header = config.get("cookie_header") self._ip_changed = config.get("ip_changed") - self.try_connect_cc() # 停止现有任务 self.stop_service() @@ -128,7 +125,7 @@ class DynamicWeChat(_PluginBase): # 定时服务 self._scheduler = BackgroundScheduler(timezone=settings.TZ) # 运行一次定时服务 - if self._onlyonce or self._forced_update: + if self._onlyonce: logger.info("立即检测公网IP") self._scheduler.add_job(func=self.check, trigger='date', run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), @@ -136,6 +133,12 @@ class DynamicWeChat(_PluginBase): # 关闭一次性开关 self._onlyonce = False + if self._forced_update: + self._scheduler.add_job(func=self.forced_change, trigger='date', + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="强制更新公网IP") # 添加任务 + self._forced_update = False + if self._local_scan: self._scheduler.add_job(func=self.local_scanning, trigger='date', run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), @@ -155,11 +158,24 @@ class DynamicWeChat(_PluginBase): if self._scheduler.get_jobs(): self._scheduler.print_jobs() self._scheduler.start() - if self._forced_update: - time.sleep(4) - self._forced_update = False self.__update_config() + @eventmanager.register(EventType.PluginAction) + def forced_change(self, event: Event = None): + """ + 强制修改IP + """ + if not self._enabled: + logger.error("插件未开启") + return + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "dynamicwechat": + return + self.ChangeIP() + self.__update_config() + logger.info("----------------------本次任务结束----------------------") + @eventmanager.register(EventType.PluginAction) def local_scanning(self, event: Event = None): """ @@ -185,7 +201,7 @@ class DynamicWeChat(_PluginBase): current_time = datetime.now() future_time = current_time + timedelta(seconds=110) self._future_timestamp = int(future_time.timestamp()) - logger.info("请重新进入插件面板扫码!,每20秒检查登录状态,最大尝试5次") + logger.info("请重新进入插件面板扫码! 每20秒检查登录状态,最大尝试5次") max_attempts = 5 attempt = 0 while attempt < max_attempts: @@ -197,9 +213,9 @@ class DynamicWeChat(_PluginBase): self.click_app_management_buttons(page) break else: - logger.info("未检测到登录,任务结束") + logger.info("用户可能没有扫码或登录失败") else: - logger.info("未找到二维码,任务结束") + logger.error("未找到二维码,任务结束") logger.info("----------------------本次任务结束----------------------") browser.close() except Exception as e: @@ -218,10 +234,6 @@ class DynamicWeChat(_PluginBase): event_data = event.event_data if not event_data or event_data.get("action") != "dynamicwechat": return - # logger.info("收到命令,开始检测公网IP ...") - # self.post_message(channel=event.event_data.get("channel"), - # title="开始检测公网IP ...", - # userid=event.event_data.get("user")) logger.info("开始检测公网IP") if self.CheckIP(): @@ -398,6 +410,7 @@ class DynamicWeChat(_PluginBase): def _update_cookie(self, page, context): self._future_timestamp = 0 # 标记二维码失效 + PyCookieCloud.save_cookie_lifetime(0) # 重置cookie存活时间 if self._use_cookiecloud: if not self._cc_server: # 连接失败返回 False self.try_connect_cc() # 再尝试一次连接 @@ -453,7 +466,6 @@ class DynamicWeChat(_PluginBase): else: # 不使用CookieCloud return cookie = self.parse_cookie_header(cookie_header) - self._cookie_from_CC = cookie return cookie except Exception as e: logger.error(f"从CookieCloud获取cookie错误,错误原因:{e}") @@ -485,7 +497,11 @@ class DynamicWeChat(_PluginBase): page.goto(self._wechatUrl) time.sleep(3) if not self.check_login_status(page, task='refresh_cookie'): + self._cookie_valid = False logger.info("cookie已失效,下次IP变动推送二维码") + else: + PyCookieCloud.increase_cookie_lifetime(1200) + self._cookie_lifetime = PyCookieCloud.load_cookie_lifetime() browser.close() except Exception as e: logger.error(f"cookie校验失败:{e}") @@ -536,8 +552,8 @@ class DynamicWeChat(_PluginBase): except Exception as e: # logger.debug(str(e)) # 基于bug运行,请不要将错误输出到日志 # try: # 没有登录成功,也没有短信验证码 - if self.find_qrc(page) and not task == 'refresh_cookie': # 延长任务找到的二维码不会被发送,所以不算用户没有扫码 - logger.error(f"用户没有扫描二维码") + if self.find_qrc(page) and not task == 'refresh_cookie' and not task == 'local_scanning': # 延长任务找到的二维码不会被发送,所以不算用户没有扫码 + logger.warning(f"用户没有扫描二维码") return False def click_app_management_buttons(self, page): @@ -672,7 +688,6 @@ class DynamicWeChat(_PluginBase): "helloimg_s_token": self._helloimg_s_token, "pushplus_token": self._pushplus_token, "input_id_list": self._input_id_list, - "cookie_from_CC": self._cookie_from_CC, "cookie_header": self._cookie_header, "use_cookiecloud": self._use_cookiecloud, }) @@ -772,7 +787,7 @@ class DynamicWeChat(_PluginBase): 'component': 'VSwitch', 'props': { 'model': 'local_scan', - 'label': '扫码刷新Cookie和改IP', + 'label': '本地扫码修改IP', } } ] @@ -878,7 +893,7 @@ class DynamicWeChat(_PluginBase): 'props': { 'type': 'info', 'variant': 'tonal', - 'text': '使用内建CookieCloud 或 自定义 或 填写两个token 至少三选一,否则无法正常使用' + 'text': '使用内建CookieCloud 或 自定义 或 填写两个token 至少三选一。任何扫码操作都会更新Cookie!' } } ] @@ -920,7 +935,6 @@ class DynamicWeChat(_PluginBase): "input_id_list": "", } - def get_page(self) -> List[dict]: # 获取当前时间戳 current_time = datetime.now().timestamp() @@ -973,6 +987,34 @@ class DynamicWeChat(_PluginBase): } } + # 计算 cookie_lifetime 的天数、小时数和分钟数 + cookie_lifetime_days = self._cookie_lifetime // 86400 # 一天的秒数为 86400 + cookie_lifetime_hours = (self._cookie_lifetime % 86400) // 3600 # 计算小时数 + cookie_lifetime_minutes = (self._cookie_lifetime % 3600) // 60 # 计算分钟数 + if self._cookie_valid: + bg_color = "#40bb45" + else: + bg_color = "#ff0000" + cookie_lifetime_text = ( + f"Cookie 已使用: {cookie_lifetime_days}天{cookie_lifetime_hours}小时{cookie_lifetime_minutes}分钟" + ) + cookie_lifetime_component = { + "component": "div", + "text": cookie_lifetime_text, + "props": { + "style": { + "fontSize": "18px", + "color": "#ffffff", # 白色字体 + "backgroundColor": bg_color, + "padding": "10px", + "borderRadius": "5px", + "textAlign": "center", + "marginTop": "10px", + "display": "inline-block" + } + } + } + # 页面内容,显示二维码状态信息和二维码图片或提示信息 base_content = [ { @@ -996,13 +1038,23 @@ class DynamicWeChat(_PluginBase): "borderRadius": "5px", "display": "inline-block", "textAlign": "center", - "marginBottom": "40px" + "marginBottom": "10px" } } - } + }, + { + "component": "div", + "content": [cookie_lifetime_component], + "props": { + "style": { + "textAlign": "center", + "marginBottom": "10px" + } + } + }, + img_component # 二维码图片或提示信息 ] - }, - img_component # 二维码图片 + } ] return base_content diff --git a/plugins/dynamicwechat/src/debug.py b/plugins/dynamicwechat/src/debug.py deleted file mode 100644 index c6499d7..0000000 --- a/plugins/dynamicwechat/src/debug.py +++ /dev/null @@ -1,144 +0,0 @@ - -from Cryptodome import Random -from Cryptodome.Cipher import AES -import base64 -import json -import hashlib -import requests -from playwright.sync_api import sync_playwright -from typing import Dict, Any - - -class PyCookieCloud: - def __init__(self, url: str, uuid: str, password: str): - self.url: str = url - self.uuid: str = uuid - self.password: str = password - self.BLOCK_SIZE = 16 - - def check_connection(self) -> bool: - """ - Test the connection to the CookieCloud server. - - :return: True if the connection is successful, False otherwise. - """ - try: - resp = requests.get(self.url) - # print(self.url) - if resp.status_code == 200: - return True - else: - return False - except Exception as e: - print(str(e)) - return False - - def update_cookie(self, cookie: Dict[str, Any]) -> bool: - """ - Update cookie data to CookieCloud. - - :param cookie: cookie value to update, if this cookie does not contain 'cookie_data' key, it will be added into 'cookie_data'. - :return: if update success, return True, else return False. - """ - # 确保 cookie 是完整的结构,并直接放入 cookie_data 中 - # cookie_data = { - # "cookie_data": cookie # 直接将 cookie 数据放入 cookie_data - # } - raw_data = json.dumps(cookie) - encrypted_data = self.encrypt(raw_data.encode('utf-8'), self.get_the_key().encode('utf-8')).decode('utf-8') - - request_data = {'uuid': self.uuid, 'encrypted': encrypted_data} - print("请求数据:", request_data) # 打印请求数据 - # headers = {'Content-Type': 'application/json'} # 设置请求头为 JSON - cookie_cloud_request = requests.post(self.url + '/update', json=request_data) - print(cookie_cloud_request) # 打印响应对象 - - if cookie_cloud_request.status_code != 200: - print("错误信息:", cookie_cloud_request.text) # 打印错误信息 - - if cookie_cloud_request.status_code == 200: - if cookie_cloud_request.json().get('action') == 'done': - return True - return False - - def get_the_key(self) -> str: - """ - Get the key used to encrypt and decrypt data. - - :return: the key. - """ - md5 = hashlib.md5() - md5.update((self.uuid + '-' + self.password).encode('utf-8')) - return md5.hexdigest()[:16] - - @staticmethod - def bytes_to_key(data, salt, output=48): - # extended from https://gist.github.com/gsakkis/4546068 - assert len(salt) == 8, len(salt) - data += salt - key = hashlib.md5(data).digest() - final_key = key - while len(final_key) < output: - key = hashlib.md5(key + data).digest() - final_key += key - return final_key[:output] - - def pad(self, data): - length = self.BLOCK_SIZE - (len(data) % self.BLOCK_SIZE) - return data + (chr(length) * length).encode() - - def encrypt(self, message: bytes, passphrase: bytes) -> bytes: - # 请替换为实际的加密实现,以下是示例 - # 使用 AES 或其他算法进行加密 - # 这里只是一个占位符,实际实现请根据需要修改 - # def encrypt(message, passphrase): - salt = Random.new().read(8) - key_iv = self.bytes_to_key(passphrase, salt, 32 + 16) - key = key_iv[:32] - iv = key_iv[32:] - aes = AES.new(key, AES.MODE_CBC, iv) - return base64.b64encode(b"Salted__" + salt + aes.encrypt(self.pad(message))) - # return message # 示例:返回原始消息 - - -def main(server: str, url: str, uuid: str, password: str): - with sync_playwright() as p: - browser = p.chromium.launch(headless=False) - page = browser.new_page() - - # 打开指定的 URL - page.goto(url) - - # 等待 60 秒用户登录 - print("请在30秒内完成登录...") - page.wait_for_timeout(30000) # 等待60秒 - - # 获取 cookies - cookies = page.context.cookies() - - # 关闭浏览器 - browser.close() - - # 创建 PyCookieCloud 实例并上传 cookies - py_cookie_cloud = PyCookieCloud(url=server, uuid=uuid, password=password) - cookie_data = {cookie['name']: cookie['value'] for cookie in cookies} # 转换为字典形式 - if py_cookie_cloud.check_connection(): - print("连接成功,请稍等片刻...") - result = py_cookie_cloud.update_cookie(cookie_data) - else: - print("连接失败,请检查网络连接") - result = False - - if result: - print("Cookies 上传成功!") - else: - print("Cookies 上传失败!") - - -if __name__ == "__main__": - # 设置参数 - server = "http://172.16.8.110:43000/cookiecloud" - target_url = "https://work.weixin.qq.com/wework_admin/loginpage_wx?from=myhome" # 请替换为实际的目标 URL - uuid = "hFQrymvqMBX11d14TTmKb6" # 替换为实际的 UUID - password = "2Bfr3LmzVy3t3bsQ5FLAbZ" # 替换为实际的密码 - main(server, target_url, uuid, password) diff --git a/plugins/dynamicwechat/update_help.py b/plugins/dynamicwechat/update_help.py index 504833e..dc0fdeb 100644 --- a/plugins/dynamicwechat/update_help.py +++ b/plugins/dynamicwechat/update_help.py @@ -1,11 +1,15 @@ -from typing import Dict, Any +import os import json import requests import base64 import hashlib +from typing import Dict, Any from Crypto import Random from Crypto.Cipher import AES +script_dir = os.path.dirname(os.path.abspath(__file__)) +settings_file = os.path.join(script_dir, 'settings.json') + def bytes_to_key(data: bytes, salt: bytes, output=48) -> bytes: # 兼容v2 将bytes_to_key和encrypt导入 @@ -34,6 +38,7 @@ def encrypt(message: bytes, passphrase: bytes) -> bytes: data = message + (chr(length) * length).encode() return base64.b64encode(b"Salted__" + salt + aes.encrypt(data)) + class PyCookieCloud: def __init__(self, url: str, uuid: str, password: str): self.url: str = url @@ -63,7 +68,8 @@ class PyCookieCloud: cookie = {'cookie_data': cookie} raw_data = json.dumps(cookie) encrypted_data = encrypt(raw_data.encode('utf-8'), self.get_the_key().encode('utf-8')).decode('utf-8') - cookie_cloud_request = requests.post(self.url + '/update', json={'uuid': self.uuid, 'encrypted': encrypted_data}) + cookie_cloud_request = requests.post(self.url + '/update', + json={'uuid': self.uuid, 'encrypted': encrypted_data}) if cookie_cloud_request.status_code == 200: if cookie_cloud_request.json()['action'] == 'done': return True @@ -78,3 +84,24 @@ class PyCookieCloud: md5 = hashlib.md5() md5.update((self.uuid + '-' + self.password).encode('utf-8')) return md5.hexdigest()[:16] + + @staticmethod + def load_cookie_lifetime(): # 返回时间戳 单位秒 + if os.path.exists(settings_file): + with open(settings_file, 'r') as file: + settings = json.load(file) + return settings.get('_cookie_lifetime', 0) + else: + return 0 + + @staticmethod + def save_cookie_lifetime(cookie_lifetime): # 传入时间戳 单位秒 + with open(settings_file, 'w') as file: + json.dump({'_cookie_lifetime': cookie_lifetime}, file) + + @staticmethod + def increase_cookie_lifetime(seconds: int): + current_lifetime = PyCookieCloud.load_cookie_lifetime() + new_lifetime = current_lifetime + seconds + # 保存新的 _cookie_lifetime + PyCookieCloud.save_cookie_lifetime(new_lifetime) From c2220a7288d8792614b81cf589c2fd374fee7b61 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Thu, 7 Nov 2024 13:21:07 +0800 Subject: [PATCH 155/218] =?UTF-8?q?=E4=B8=8B=E6=9E=B6IYUU=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E6=8E=A8=E9=80=81=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/package.json b/package.json index a573177..183169a 100644 --- a/package.json +++ b/package.json @@ -488,16 +488,6 @@ "level": 1, "v2": true }, - "IyuuMsg": { - "name": "IYUU消息推送", - "description": "支持使用IYUU发送消息通知。", - "labels": "消息通知,IYUU", - "version": "1.2", - "icon": "Iyuu_A.png", - "author": "jxxghp", - "level": 1, - "v2": true - }, "PushDeerMsg": { "name": "PushDeer消息推送", "description": "支持使用PushDeer发送消息通知。", From 188c541261ef8da8d76b7e27ee45a20f8583b773 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Thu, 7 Nov 2024 19:02:59 +0800 Subject: [PATCH 156/218] fix https://github.com/jxxghp/MoviePilot-Plugins/issues/540 --- package.json | 3 ++- plugins/doubansync/__init__.py | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 183169a..e71ce20 100644 --- a/package.json +++ b/package.json @@ -74,12 +74,13 @@ "name": "豆瓣想看", "description": "同步豆瓣想看数据,自动添加订阅。", "labels": "订阅", - "version": "1.8", + "version": "1.9", "icon": "douban.png", "author": "jxxghp", "level": 2, "v2": true, "history": { + "v1.9": "请求豆瓣RSS时增加请求头", "v1.8": "不同步在看条目", "v1.7": "增强API安全性", "v1.6": "同步历史记录支持手动删除,需要主程序升级至v1.8.4+版本", diff --git a/plugins/doubansync/__init__.py b/plugins/doubansync/__init__.py index 173367d..ffb6570 100644 --- a/plugins/doubansync/__init__.py +++ b/plugins/doubansync/__init__.py @@ -34,7 +34,7 @@ class DoubanSync(_PluginBase): # 插件图标 plugin_icon = "douban.png" # 插件版本 - plugin_version = "1.8" + plugin_version = "1.9" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -509,7 +509,9 @@ class DoubanSync(_PluginBase): continue logger.info(f"开始同步用户 {user_id} 的豆瓣想看数据 ...") url = self._interests_url % user_id - results = self.rsshelper.parse(url) + results = self.rsshelper.parse(url, headers={ + "User-Agent": settings.USER_AGENT + }) if not results: logger.warn(f"未获取到用户 {user_id} 豆瓣RSS数据:{url}") continue From b0e1d73e0fafeb7b7d42874a7096617f8e39cfa7 Mon Sep 17 00:00:00 2001 From: ramen <1205925392@qq.com> Date: Thu, 7 Nov 2024 19:45:31 +0800 Subject: [PATCH 157/218] =?UTF-8?q?=E5=AE=8C=E5=96=84=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=EF=BC=8C=E4=B8=BA=E4=BA=86=E5=92=8C=E5=8E=9F?= =?UTF-8?q?=E4=BB=93=E5=BA=93=E5=81=9A=E5=87=BA=E6=98=8E=E6=98=BE=E5=8C=BA?= =?UTF-8?q?=E5=88=AB=E5=9C=A8=E6=8F=92=E4=BB=B6=E5=90=8D=E7=A7=B0=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E4=BA=86=E7=89=B9=E6=AE=8A=E5=AD=97=E7=AC=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 7 +- plugins/dynamicwechat/__init__.py | 139 ++++++++++++++------------- plugins/dynamicwechat/update_help.py | 22 ++++- 3 files changed, 92 insertions(+), 76 deletions(-) diff --git a/package.json b/package.json index e71ce20..68f421f 100644 --- a/package.json +++ b/package.json @@ -851,15 +851,16 @@ "v2": true }, "DynamicWeChat": { - "name": "修改企业微信可信IP", - "description": "优先使用cookie,可本地扫码刷新Cookie,当填写两个第三方token时可手机远程更新cookie。", + "name": "假的企业微信可信IP", + "description": "优先使用cookie,可本地扫码刷新Cookie,验证码以?结尾发送到企业微信应用", "labels": "消息通知", - "version": "1.4.0", + "version": "1.4.1", "icon": "Wecom_A.png", "author": "RamenRa", "level": 2, "v2": true, "history": { + "v1.4.1": "完善面板说明,为了和原仓库做出明显区别在插件名称添加了特殊字符", "v1.4.0": "修复强制更改IP时配置面板延时过长的问题。庆祝v2进入正式版,显示了一个没用的参数", "v1.3.1": "修正一些逻辑判断,修改ip成功会通知一次", "v1.3.0": "兼容v2,操作cookie前检查一次CookieCloud", diff --git a/plugins/dynamicwechat/__init__.py b/plugins/dynamicwechat/__init__.py index 5e654ca..d4d0f19 100644 --- a/plugins/dynamicwechat/__init__.py +++ b/plugins/dynamicwechat/__init__.py @@ -24,13 +24,13 @@ from app.schemas.types import EventType, NotificationType class DynamicWeChat(_PluginBase): # 插件名称 - plugin_name = "修改企业微信可信IP" + plugin_name = "假的企业微信可信IP" # 插件描述 - plugin_desc = "优先使用cookie,可本地扫码刷新Cookie,当填写两个第三方token时手机微信可以更新cookie。" + plugin_desc = "优先使用cookie,可本地扫码刷新Cookie,验证码以?结尾发送到企业微信应用" # 插件图标 plugin_icon = "Wecom_A.png" # 插件版本 - plugin_version = "1.4.0" + plugin_version = "1.4.1" # 插件作者 plugin_author = "RamenRa" # 作者主页 @@ -54,6 +54,8 @@ class DynamicWeChat(_PluginBase): _cc_server = None # 本地扫码开关 _local_scan = False + # 类初始化时添加标记变量 + _is_special_upload = False # 匹配ip地址的正则 _ip_pattern = r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b' @@ -353,7 +355,6 @@ class DynamicWeChat(_PluginBase): } response = requests.post(pushplus_url, json=pushplus_data) - def ChangeIP(self): logger.info("开始请求企业微信管理更改可信IP") try: @@ -424,7 +425,6 @@ class DynamicWeChat(_PluginBase): if current_cookies is None: logger.error("无法获取当前 cookies") return - formatted_cookies = {} for cookie in current_cookies: domain = cookie.get('domain') # 使用 get() 方法避免 KeyError @@ -434,7 +434,7 @@ class DynamicWeChat(_PluginBase): if domain not in formatted_cookies: formatted_cookies[domain] = [] formatted_cookies[domain].append(cookie) - flag = self._cc_server.update_cookie({'cookie_data': formatted_cookies}) + flag = self._cc_server.update_cookie(formatted_cookies) if flag: logger.info("更新 CookieCloud 成功") else: @@ -442,8 +442,7 @@ class DynamicWeChat(_PluginBase): else: logger.error("连接 CookieCloud 失败", self._server) except Exception as e: - logger.error( - f"更新 cookie 发生错误: {e}") + logger.error(f"更新 cookie 发生错误: {e}") else: logger.error("CookieCloud没有启用或配置错误, 不刷新cookie") @@ -455,11 +454,14 @@ class DynamicWeChat(_PluginBase): if not cookies: # CookieCloud获取cookie失败 logger.error(f"CookieCloud获取cookie失败,失败原因:{msg}") return - # cookie_header = self._cookie_header else: for domain, cookie in cookies.items(): if domain == ".work.weixin.qq.com": cookie_header = cookie + if '_upload_type=A' in cookie: + self._is_special_upload = True + else: + self._is_special_upload = False break if cookie_header == '': cookie_header = self._cookie_header @@ -469,7 +471,6 @@ class DynamicWeChat(_PluginBase): return cookie except Exception as e: logger.error(f"从CookieCloud获取cookie错误,错误原因:{e}") - # logger.info("尝试推送登录二维码") return @staticmethod @@ -498,8 +499,9 @@ class DynamicWeChat(_PluginBase): time.sleep(3) if not self.check_login_status(page, task='refresh_cookie'): self._cookie_valid = False - logger.info("cookie已失效,下次IP变动推送二维码") + logger.warning("cookie已失效,下次IP变动推送二维码") else: + self._cookie_valid = True PyCookieCloud.increase_cookie_lifetime(1200) self._cookie_lifetime = PyCookieCloud.load_cookie_lifetime() browser.close() @@ -552,7 +554,8 @@ class DynamicWeChat(_PluginBase): except Exception as e: # logger.debug(str(e)) # 基于bug运行,请不要将错误输出到日志 # try: # 没有登录成功,也没有短信验证码 - if self.find_qrc(page) and not task == 'refresh_cookie' and not task == 'local_scanning': # 延长任务找到的二维码不会被发送,所以不算用户没有扫码 + if self.find_qrc( + page) and not task == 'refresh_cookie' and not task == 'local_scanning': # 延长任务找到的二维码不会被发送,所以不算用户没有扫码 logger.warning(f"用户没有扫描二维码") return False @@ -563,8 +566,8 @@ class DynamicWeChat(_PluginBase): # ("//span[@class='frame_nav_item_title' and text()='应用管理']", "应用管理"), # ("//div[@class='app_index_item_title ' and contains(text(), 'MoviePilot')]", "MoviePilot"), ( - "//div[contains(@class, 'js_show_ipConfig_dialog')]//a[contains(@class, '_mod_card_operationLink') and text()='配置']", - "配置") + "//div[contains(@class, 'js_show_ipConfig_dialog')]//a[contains(@class, '_mod_card_operationLink') and text()='配置']", + "配置") ] if self._input_id_list: id_list = self._input_id_list.split(",") @@ -808,7 +811,7 @@ class DynamicWeChat(_PluginBase): 'component': 'VTextField', 'props': { 'model': 'cron', - 'label': '检测周期', + 'label': '[必填]检测周期', 'placeholder': '0 * * * *' } } @@ -829,7 +832,7 @@ class DynamicWeChat(_PluginBase): 'component': 'VTextarea', 'props': { 'model': 'input_id_list', - 'label': '应用ID', + 'label': '[必填]应用ID', 'rows': 1, 'placeholder': '输入应用ID,多个ID用英文逗号分隔。在企业微信应用页面URL末尾获取' } @@ -852,7 +855,7 @@ class DynamicWeChat(_PluginBase): 'component': 'VTextarea', 'props': { 'model': 'pushplus_token', - 'label': 'pushplus_token', + 'label': '[可选]pushplus_token', 'rows': 1, 'placeholder': '[可选] 请输入 pushplus_token' } @@ -870,7 +873,7 @@ class DynamicWeChat(_PluginBase): 'component': 'VTextarea', 'props': { 'model': 'helloimg_s_token', - 'label': 'helloimg_s_token', + 'label': '[可选]helloimg_s_token', 'rows': 1, 'placeholder': '[可选] 请输入 helloimg_token' } @@ -893,7 +896,7 @@ class DynamicWeChat(_PluginBase): 'props': { 'type': 'info', 'variant': 'tonal', - 'text': '使用内建CookieCloud 或 自定义 或 填写两个token 至少三选一。任何扫码操作都会更新Cookie!' + 'text': '使用内建CookieCloud 或 自定义 或 填写两个token 至少三选一。具体请查看作者主页' } } ] @@ -986,36 +989,33 @@ class DynamicWeChat(_PluginBase): } } } + if self._is_special_upload: + # 计算 cookie_lifetime 的天数、小时数和分钟数 + cookie_lifetime_days = self._cookie_lifetime // 86400 # 一天的秒数为 86400 + cookie_lifetime_hours = (self._cookie_lifetime % 86400) // 3600 # 计算小时数 + cookie_lifetime_minutes = (self._cookie_lifetime % 3600) // 60 # 计算分钟数 + bg_color = "#40bb45" if self._cookie_valid else "#ff0000" + cookie_lifetime_text = f"Cookie 已使用: {cookie_lifetime_days}天{cookie_lifetime_hours}小时{cookie_lifetime_minutes}分钟" - # 计算 cookie_lifetime 的天数、小时数和分钟数 - cookie_lifetime_days = self._cookie_lifetime // 86400 # 一天的秒数为 86400 - cookie_lifetime_hours = (self._cookie_lifetime % 86400) // 3600 # 计算小时数 - cookie_lifetime_minutes = (self._cookie_lifetime % 3600) // 60 # 计算分钟数 - if self._cookie_valid: - bg_color = "#40bb45" - else: - bg_color = "#ff0000" - cookie_lifetime_text = ( - f"Cookie 已使用: {cookie_lifetime_days}天{cookie_lifetime_hours}小时{cookie_lifetime_minutes}分钟" - ) - cookie_lifetime_component = { - "component": "div", - "text": cookie_lifetime_text, - "props": { - "style": { - "fontSize": "18px", - "color": "#ffffff", # 白色字体 - "backgroundColor": bg_color, - "padding": "10px", - "borderRadius": "5px", - "textAlign": "center", - "marginTop": "10px", - "display": "inline-block" + cookie_lifetime_component = { + "component": "div", + "text": cookie_lifetime_text, + "props": { + "style": { + "fontSize": "18px", + "color": "#ffffff", + "backgroundColor": bg_color, + "padding": "10px", + "borderRadius": "5px", + "textAlign": "center", + "marginTop": "10px", + "display": "block" + } } } - } + else: + cookie_lifetime_component = None # 不生成该组件 - # 页面内容,显示二维码状态信息和二维码图片或提示信息 base_content = [ { "component": "div", @@ -1027,30 +1027,35 @@ class DynamicWeChat(_PluginBase): "content": [ { "component": "div", - "text": vaild_text, "props": { "style": { - "fontSize": "22px", - "fontWeight": "bold", - "color": "#ffffff", - "backgroundColor": color, - "padding": "8px", - "borderRadius": "5px", - "display": "inline-block", - "textAlign": "center", - "marginBottom": "10px" + "display": "flex", + "justifyContent": "center", + "alignItems": "center", + "flexDirection": "column", # 垂直排列 + "gap": "10px" # 控制间距 } - } - }, - { - "component": "div", - "content": [cookie_lifetime_component], - "props": { - "style": { - "textAlign": "center", - "marginBottom": "10px" - } - } + }, + "content": [ + { + "component": "div", + "text": vaild_text, + "props": { + "style": { + "fontSize": "22px", + "fontWeight": "bold", + "color": "#ffffff", + "backgroundColor": color, + "padding": "8px", + "borderRadius": "5px", + "textAlign": "center", + "marginBottom": "10px", + "display": "inline-block" + } + } + }, + cookie_lifetime_component if cookie_lifetime_component else {}, + ] }, img_component # 二维码图片或提示信息 ] @@ -1161,5 +1166,3 @@ class DynamicWeChat(_PluginBase): self._scheduler = None except Exception as e: logger.error(str(e)) - - diff --git a/plugins/dynamicwechat/update_help.py b/plugins/dynamicwechat/update_help.py index dc0fdeb..9da818a 100644 --- a/plugins/dynamicwechat/update_help.py +++ b/plugins/dynamicwechat/update_help.py @@ -57,21 +57,33 @@ class PyCookieCloud: except Exception as e: return False - def update_cookie(self, cookie: Dict[str, Any]) -> bool: + def update_cookie(self, formatted_cookies: Dict[str, Any]) -> bool: """ Update cookie data to CookieCloud. - :param cookie: cookie value to update, if this cookie does not contain 'cookie_data' key, it will be added into 'cookie_data'. + :param formatted_cookies: cookie value to update. :return: if update success, return True, else return False. """ - if 'cookie_data' not in cookie: - cookie = {'cookie_data': cookie} + if '.work.weixin.qq.com' not in formatted_cookies: + formatted_cookies['.work.weixin.qq.com'] = [] + formatted_cookies['.work.weixin.qq.com'].append({ + 'name': '_upload_type', + 'value': 'A', + 'domain': '.work.weixin.qq.com', + 'path': '/', + 'expires': -1, + 'httpOnly': False, + 'secure': False, + 'sameSite': 'Lax' + }) + + cookie = {'cookie_data': formatted_cookies} raw_data = json.dumps(cookie) encrypted_data = encrypt(raw_data.encode('utf-8'), self.get_the_key().encode('utf-8')).decode('utf-8') cookie_cloud_request = requests.post(self.url + '/update', json={'uuid': self.uuid, 'encrypted': encrypted_data}) if cookie_cloud_request.status_code == 200: - if cookie_cloud_request.json()['action'] == 'done': + if cookie_cloud_request.json().get('action') == 'done': return True return False From 32d732d5f33c0596cc5b9a1d597ded37f4e18d16 Mon Sep 17 00:00:00 2001 From: Attente <19653207+wikrin@users.noreply.github.com> Date: Fri, 8 Nov 2024 22:53:41 +0800 Subject: [PATCH 158/218] =?UTF-8?q?=E5=A4=9A=E8=A1=8C=E6=B3=A8=E9=87=8A?= =?UTF-8?q?=E5=8D=83=E4=B8=87=E4=B8=8D=E8=83=BD=E5=8F=AA=E5=86=99=E4=B8=80?= =?UTF-8?q?=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/bangumicoll/__init__.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/plugins/bangumicoll/__init__.py b/plugins/bangumicoll/__init__.py index 2abb002..83565e2 100644 --- a/plugins/bangumicoll/__init__.py +++ b/plugins/bangumicoll/__init__.py @@ -49,7 +49,7 @@ class BangumiColl(_PluginBase): auth_level = 1 # 私有属性 - _scheduler: Optional[BackgroundScheduler] = None + _scheduler = None siteoper: SiteOper = None subscribehelper: SubscribeHelper = None subscribeoper: SubscribeOper = None @@ -148,8 +148,10 @@ class BangumiColl(_PluginBase): return form(sites_options) def get_service(self) -> List[Dict[str, Any]]: - """注册插件公共服务""" - if self._enabled: + """ + 注册插件公共服务 + """ + if self._enabled or self._cron: trigger = CronTrigger.from_crontab(self._cron) if self._cron else "interval" kwargs = {"hours": 6} if not self._cron else {} return [ @@ -207,8 +209,6 @@ class BangumiColl(_PluginBase): # 新增和移除条目 self.manage_subscriptions(items) - - logger.info("Bangumi收藏订阅执行完成") except Exception as e: logger.error(f"执行失败: {str(e)}") @@ -249,10 +249,12 @@ class BangumiColl(_PluginBase): del_items = {db_sub[i]: i for i in del_sub} logger.info("开始移除订阅...") self.delete_subscribe(del_items) + logger.info("移除完成") if new_sub: logger.info("开始添加订阅...") msg = self.add_subscribe({i: items[i] for i in new_sub}) + logger.info("添加完成") if msg: logger.info("\n".ljust(49, ' ').join(list(msg.values()))) From 882d928e29b8e307d349b7894beba5de5675d3dd Mon Sep 17 00:00:00 2001 From: Attente <19653207+wikrin@users.noreply.github.com> Date: Fri, 8 Nov 2024 23:01:12 +0800 Subject: [PATCH 159/218] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 7 +++---- plugins/bangumicoll/__init__.py | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 68f421f..3c15381 100644 --- a/package.json +++ b/package.json @@ -890,16 +890,15 @@ "name": "Bangumi收藏订阅", "description": "Bangumi用户收藏添加到订阅", "labels": "订阅", - "version": "1.5.1", + "version": "1.5.2", "icon": "bangumi_b.png", "author": "Attente", "level": 1, "v2": true, "history": { + "v1.5.2": "修复定时任务未正确注册的问题", "v1.5.1": "修复季度信息未传递的问题. 新增站点列表同步删除", - "v1.5": "修复总集数会同步TMDB变动的问题,增加开关选项", - "v1.4": "结构优化", - "v1.3.1": "修复因修改季号导致未下载剧集而完成订阅的问题" + "v1.5": "修复总集数会同步TMDB变动的问题,增加开关选项" } } } diff --git a/plugins/bangumicoll/__init__.py b/plugins/bangumicoll/__init__.py index 83565e2..5728e41 100644 --- a/plugins/bangumicoll/__init__.py +++ b/plugins/bangumicoll/__init__.py @@ -36,7 +36,7 @@ class BangumiColl(_PluginBase): # 插件图标 plugin_icon = "bangumi_b.png" # 插件版本 - plugin_version = "1.5.1" + plugin_version = "1.5.2" # 插件作者 plugin_author = "Attente" # 作者主页 From ac04bb809f54714de809f04dbff619ffcd0c3538 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 9 Nov 2024 17:51:11 +0800 Subject: [PATCH 160/218] fix #543 --- package.json | 29 +++++++++++++++-------------- plugins/doubansync/__init__.py | 16 ++++++++++++---- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index 3c15381..4d332fa 100644 --- a/package.json +++ b/package.json @@ -74,12 +74,13 @@ "name": "豆瓣想看", "description": "同步豆瓣想看数据,自动添加订阅。", "labels": "订阅", - "version": "1.9", + "version": "1.9.1", "icon": "douban.png", "author": "jxxghp", "level": 2, "v2": true, "history": { + "v1.9.1": "修复版本兼容问题", "v1.9": "请求豆瓣RSS时增加请求头", "v1.8": "不同步在看条目", "v1.7": "增强API安全性", @@ -887,18 +888,18 @@ } }, "BangumiColl": { - "name": "Bangumi收藏订阅", - "description": "Bangumi用户收藏添加到订阅", - "labels": "订阅", - "version": "1.5.2", - "icon": "bangumi_b.png", - "author": "Attente", - "level": 1, - "v2": true, - "history": { - "v1.5.2": "修复定时任务未正确注册的问题", - "v1.5.1": "修复季度信息未传递的问题. 新增站点列表同步删除", - "v1.5": "修复总集数会同步TMDB变动的问题,增加开关选项" - } + "name": "Bangumi收藏订阅", + "description": "Bangumi用户收藏添加到订阅", + "labels": "订阅", + "version": "1.5.2", + "icon": "bangumi_b.png", + "author": "Attente", + "level": 1, + "v2": true, + "history": { + "v1.5.2": "修复定时任务未正确注册的问题", + "v1.5.1": "修复季度信息未传递的问题. 新增站点列表同步删除", + "v1.5": "修复总集数会同步TMDB变动的问题,增加开关选项" + } } } diff --git a/plugins/doubansync/__init__.py b/plugins/doubansync/__init__.py index ffb6570..1c3508d 100644 --- a/plugins/doubansync/__init__.py +++ b/plugins/doubansync/__init__.py @@ -34,7 +34,7 @@ class DoubanSync(_PluginBase): # 插件图标 plugin_icon = "douban.png" # 插件版本 - plugin_version = "1.9" + plugin_version = "1.9.1" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -498,6 +498,11 @@ class DoubanSync(_PluginBase): """ if not self._users: return + # 版本 + if hasattr(settings, 'VERSION_FLAG'): + version = settings.VERSION_FLAG # V2 + else: + version = "v1" # 读取历史记录 if self._clearflag: history = [] @@ -509,9 +514,12 @@ class DoubanSync(_PluginBase): continue logger.info(f"开始同步用户 {user_id} 的豆瓣想看数据 ...") url = self._interests_url % user_id - results = self.rsshelper.parse(url, headers={ - "User-Agent": settings.USER_AGENT - }) + if version == "v2": + results = self.rsshelper.parse(url, headers={ + "User-Agent": settings.USER_AGENT + }) + else: + results = self.rsshelper.parse(url) if not results: logger.warn(f"未获取到用户 {user_id} 豆瓣RSS数据:{url}") continue From 8def52ab6b53faca0c91766ab2897dd9ab1e0d73 Mon Sep 17 00:00:00 2001 From: cikezhu <604054726@qq.com> Date: Tue, 12 Nov 2024 10:49:29 +0800 Subject: [PATCH 161/218] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=89=A7=E9=9B=86=E7=BB=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 8 +- plugins/episodegroupmeta/__init__.py | 363 +++++++++++++++++++++++++-- 2 files changed, 353 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index 4d332fa..3ee61b2 100644 --- a/package.json +++ b/package.json @@ -527,10 +527,14 @@ "name": "TMDB剧集组刮削", "description": "从TMDB剧集组刮削季集的实际顺序。", "labels": "刮削", - "version": "1.1", + "version": "2.0", "icon": "Element_A.png", "author": "叮叮当", - "level": 1 + "level": 1, + "v2": true, + "history": { + "v2.0": "新增 手动选择剧集组功能" + } }, "CustomIndexer": { "name": "自定义索引站点", diff --git a/plugins/episodegroupmeta/__init__.py b/plugins/episodegroupmeta/__init__.py index 7a3be26..a76fb7f 100644 --- a/plugins/episodegroupmeta/__init__.py +++ b/plugins/episodegroupmeta/__init__.py @@ -22,7 +22,8 @@ from app.plugins import _PluginBase from app.schemas.types import EventType from app.utils.common import retry from app.utils.http import RequestUtils - +from app.db.models import PluginData +from app.utils.object import ObjectUtils class ExistMediaInfo(BaseModel): # 类型 电影、电视剧 @@ -47,7 +48,7 @@ class EpisodeGroupMeta(_PluginBase): # 主题色 plugin_color = "#098663" # 插件版本 - plugin_version = "1.1" + plugin_version = "2.0" # 插件作者 plugin_author = "叮叮当" # 作者主页 @@ -70,6 +71,7 @@ class EpisodeGroupMeta(_PluginBase): jellyfin = None _enabled = False + _autorun = True _ignorelock = False _delay = 0 _allowlist = [] @@ -82,6 +84,7 @@ class EpisodeGroupMeta(_PluginBase): self.jellyfin = Jellyfin() if config: self._enabled = config.get("enabled") + self._autorun = config.get("autorun") self._ignorelock = config.get("ignorelock") self._delay = config.get("delay") or 120 self._allowlist = [] @@ -90,6 +93,12 @@ class EpisodeGroupMeta(_PluginBase): if s and s not in self._allowlist: self._allowlist.append(s) self.log_info(f"白名单数量: {len(self._allowlist)} > {self._allowlist}") + if not ("autorun" in config): + # 新版本v1.2更新插件配置默认配置 + self._autorun = True + config["autorun"] = True + self.update_config(config) + self.log_warn(f"新版本v{self.plugin_version} 配置修正 ...") def get_state(self) -> bool: return self._enabled @@ -99,7 +108,65 @@ class EpisodeGroupMeta(_PluginBase): pass def get_api(self) -> List[Dict[str, Any]]: - pass + # plugin/EpisodeGroupMeta/delete_media_database + # plugin/EpisodeGroupMeta/start_rt + self.log_warn("api已添加: /start_rt") + self.log_warn("api已添加: /delete_media_database") + return [ + { + "path": "/delete_media_database", + "endpoint": self.delete_media_database, + "methods": ["GET"], + "summary": "剧集组刮削", + "description": "移除待处理媒体信息", + }, + { + "path": "/start_rt", + "endpoint": self.go_start_rt, + "methods": ["GET"], + "summary": "剧集组刮削", + "description": "刮削指定剧集组", + } + ] + + def delete_media_database(self, tmdb_id: str, apikey: str) -> schemas.Response: + """ + 删除待处理剧集组的媒体信息 + """ + if apikey != settings.API_TOKEN: + return schemas.Response(success=False, message="API密钥错误") + if not tmdb_id: + return schemas.Response(success=False, message="缺少重要参数") + self.del_data(tmdb_id) + return schemas.Response(success=True, message="删除成功") + + def go_start_rt(self, tmdb_id: str, group_id: str, apikey: str) -> schemas.Response: + if apikey != settings.API_TOKEN: + return schemas.Response(success=False, message="API密钥错误") + if not tmdb_id or not group_id: + return schemas.Response(success=False, message="缺少重要参数") + # 解析待处理数据 + try: + # 查询待处理数据 + data = self.get_data(tmdb_id) + if not data: + return schemas.Response(success=False, message="未找到待处理数据") + mediainfo_dict = data.get("mediainfo_dict") + mediainfo: schemas.MediaInfo = schemas.MediaInfo.parse_obj(mediainfo_dict) + episode_groups = data.get("episode_groups") + except Exception as e: + self.log_error(f"解析媒体信息失败: {str(e)}") + return schemas.Response(success=False, message="解析媒体信息失败") + # 开始刮削 + self.log_info(f"开始刮削: {mediainfo.title} | {mediainfo.year} | {episode_groups}") + if self.start_rt(mediainfo, episode_groups, group_id): + self.log_info("刮削剧集组, 执行成功! 后台正在执行,请稍等!") + self.systemmessage.put("后台正在执行,请稍等!", title="剧集组刮削") + return schemas.Response(success=True, message="刮削剧集组, 执行成功!") + else: + self.log_error("执行失败, 请查看插件日志!") + self.systemmessage.put("执行失败, 请查看插件日志!", title="剧集组刮削") + return schemas.Response(success=False, message="执行失败, 请查看插件日志") def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: """ @@ -116,7 +183,7 @@ class EpisodeGroupMeta(_PluginBase): 'component': 'VCol', 'props': { 'cols': 12, - 'md': 6 + 'md': 4 }, 'content': [ { @@ -132,14 +199,30 @@ class EpisodeGroupMeta(_PluginBase): 'component': 'VCol', 'props': { 'cols': 12, - 'md': 6 + 'md': 4 }, 'content': [ { - 'component': 'VSwitch', + 'component': 'VCheckboxBtn', + 'props': { + 'model': 'autorun', + 'label': '季集匹配时自动刮削', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VCheckboxBtn', 'props': { 'model': 'ignorelock', - 'label': '媒体信息锁定时也进行刮削', + 'label': '强制刮削已锁定的媒体信息', } } ] @@ -203,7 +286,7 @@ class EpisodeGroupMeta(_PluginBase): 'props': { 'type': 'info', 'variant': 'tonal', - 'text': '注意:刮削白名单(留空), 则全部刮削. 否则仅刮削白名单.' + 'text': '注意:刮削白名单(留空)则全部刮削. 否则仅刮削白名单.' } } ] @@ -235,18 +318,212 @@ class EpisodeGroupMeta(_PluginBase): } ], { "enabled": False, + "autorun": True, "ignorelock": False, "allowlist": "", "delay": 120 } def get_page(self) -> List[dict]: - pass + """ + 拼装插件详情页面,需要返回页面配置,同时附带数据 + """ + # 查询待处理数据列表 + mediainfo_list: List[PluginData] = self.get_data() + # 拼装页面 + contents = [] + for plugin_data in mediainfo_list: + try: + tmdb_id = plugin_data.key + # fix v1版本数据读取问题 + if ObjectUtils.is_obj(plugin_data.value): + data = json.loads(plugin_data.value) + else: + data = plugin_data.value + mediainfo: schemas.MediaInfo = schemas.MediaInfo.parse_obj(data.get("mediainfo_dict")) + episode_groups = data.get("episode_groups") + except Exception as e: + self.log_error(f"解析媒体信息失败: {plugin_data.key} -> {plugin_data.value} \n ------ \n {str(e)}") + continue + # 剧集组菜单明细 + groups_menu = [] + index = 0 + for group in episode_groups: + index += 1 + title = group.get('name') + groups_menu.append({ + 'component': 'VListItem', + 'props': { + ':key': str(index), + ':value': str(index) + }, + 'events': { + 'click': { + 'api': 'plugin/EpisodeGroupMeta/start_rt', + 'method': 'get', + 'params': { + 'apikey': settings.API_TOKEN, + 'tmdb_id': tmdb_id, + 'group_id': group.get('id') + } + } + }, + 'content': [ + { + 'component': 'VListItemTitle', + 'text': title + }, + { + 'component': 'VListItemSubtitle', + 'text': f"{group.get('group_count')}组, {group.get('episode_count')}集" + }, + ] + }) + # 拼装待处理媒体卡片 + contents.append( + { + 'component': 'VCard', + 'content': [ + { + 'component': 'VImg', + 'props': { + 'src': mediainfo.backdrop_path or mediainfo.poster_path, + 'height': '120px', + 'cover': True + }, + }, + { + 'component': 'VCardTitle', + 'content': [ + { + 'component': 'a', + 'props': { + 'href': f"{mediainfo.detail_link}/episode_groups", + 'target': '_blank' + }, + 'text': mediainfo.title + } + ] + }, + { + 'component': 'VCardSubtitle', + 'content': [ + { + 'component': 'a', + 'props': { + 'href': f"{mediainfo.detail_link}/episode_groups", + 'target': '_blank' + }, + 'text': f"{mediainfo.year} | 共{len(episode_groups)}个剧集组" + } + ] + }, + { + 'component': 'VCardActions', + 'props': { + 'style': 'min-height:64px;' + }, + 'content': [ + { + 'component': 'VBtn', + 'props': { + 'class': 'ms-2', + 'size': 'small', + 'rounded': 'xl', + 'elevation': '20', + 'append-icon': 'mdi-chevron-right' + }, + 'text': '选择剧集组', + 'content': [ + { + 'component': 'VMenu', + 'props': { + 'activator': 'parent' + }, + 'content': [ + { + 'component': 'VList', + 'content': groups_menu + } + ] + } + ] + }, + { + 'component': 'VBtn', + 'props': { + 'class': 'ms-2', + 'size': 'small', + 'elevation': '20', + 'rounded': 'xl', + }, + 'text': '忽略', + 'events': { + 'click': { + 'api': 'plugin/EpisodeGroupMeta/delete_media_database', + 'method': 'get', + 'params': { + 'apikey': settings.API_TOKEN, + 'tmdb_id': tmdb_id + } + } + }, + } + ] + } + ] + } + ) + + if not contents: + return [ + { + 'component': 'div', + 'text': '暂无待处理数据', + 'props': { + 'class': 'text-center', + } + } + ] + + return [ + { + 'component': 'VRow', + 'props': { + 'class': 'mb-3' + }, + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '注意:1. 点击名字可跳转tmdb剧集组页面。2. 选择剧集组时后台已经开始执行,请通过日志查看进度,不要重复执行。' + } + } + ] + } + ] + }, + { + 'component': 'div', + 'props': { + 'class': 'grid gap-6 grid-info-card', + }, + 'content': contents + } + ] @eventmanager.register(EventType.TransferComplete) def scrap_rt(self, event: Event): """ - 根据事件实时刮削剧集组信息 + 根据事件判断是否需要刮削 """ if not self.get_state(): return @@ -279,17 +556,62 @@ class EpisodeGroupMeta(_PluginBase): except Exception as e: self.log_error(f"{mediainfo.title} {str(e)}") return + # 写入至插件数据 + mediainfo_dict = None + try: + # 实际传递的不是基于BaseModel的实例 + mediainfo_dict = mediainfo.dict() + except Exception as e: + # app.core.context.MediaInfo + try: + mediainfo_dict = mediainfo.to_dict() + except Exception as e: + self.log_error(f"{mediainfo.title} 无法处理MediaInfo数据 {str(e)}") + if mediainfo_dict: + data = { + "episode_groups": episode_groups, + "mediainfo_dict": mediainfo_dict + } + self.save_data(str(mediainfo.tmdb_id), data) + self.log_info("写入待处理数据 - ok") + # 禁止自动刮削时直接返回 + if not self._autorun: + self.log_warn(f"{mediainfo.title} 未勾选自动刮削, 无需处理") + return # 延迟 if self._delay: self.log_warn(f"{mediainfo.title} 将在 {self._delay} 秒后开始处理..") time.sleep(int(self._delay)) + # 开始处理 + self.start_rt(mediainfo=mediainfo, episode_groups=episode_groups) + + def start_rt(self, mediainfo: schemas.MediaInfo, episode_groups: Any | None, group_id: str = None) -> bool: + """ + 通过媒体信息读取剧集组并刮削季集信息 + """ + # 当不是从事件触发时,应再次判断是否存在剧集组 + if not episode_groups: + try: + episode_groups = self.tv.episode_groups(mediainfo.tmdb_id) + if not episode_groups: + self.log_warn(f"{mediainfo.title} 没有剧集组, 无需处理") + return False + self.log_info(f"{mediainfo.title_year} 剧集组数量: {len(episode_groups)} - {episode_groups}") + # episodegroup = self.tv.group_episodes(episode_groups[0].get('id')) + except Exception as e: + self.log_error(f"{mediainfo.title} {str(e)}") + return False # 获取可用的媒体服务器 _existsinfo = self.chain.media_exists(mediainfo=mediainfo) + if not _existsinfo: + self.log_warn(f"{mediainfo.title_year} 无可用的媒体服务器") + return False + # 存在媒体服务器 existsinfo: ExistMediaInfo = self.__media_exists(server=_existsinfo.server, mediainfo=mediainfo, existsinfo=_existsinfo) if not existsinfo or not existsinfo.itemid: self.log_warn(f"{mediainfo.title_year} 在媒体库中不存在") - return + return False # 新增需要的属性 existsinfo.server = _existsinfo.server existsinfo.type = _existsinfo.type @@ -309,6 +631,9 @@ class EpisodeGroupMeta(_PluginBase): name = episode_group.get('name') if not id: continue + # 指定剧集组id时, 跳过其他剧集组 + if group_id and str(id) != str(group_id): + continue # 处理 self.log_info(f"正在匹配剧集组: {id}") groups_meta = self.tv.group_episodes(id) @@ -325,9 +650,14 @@ class EpisodeGroupMeta(_PluginBase): continue # 进行集数匹配, 确定剧集组信息 ep = existsinfo.groupep.get(order) - if not ep or len(ep) != len(episodes): - continue - self.log_info(f"已匹配剧集组: {name}, {id}, 第 {order} 季") + # 指定剧集组id时, 不再通过季集数量匹配 + if group_id: + self.log_info(f"已指定剧集组: {name}, {id}, 第 {order} 季") + else: + # 进行集数匹配, 确定剧集组信息 + if not ep or len(ep) != len(episodes): + continue + self.log_info(f"已匹配剧集组: {name}, {id}, 第 {order} 季") # 遍历全部媒体项并更新 for _index, _ids in enumerate(existsinfo.groupid.get(order)): # 提取出媒体库中集id对应的集数index @@ -338,8 +668,8 @@ class EpisodeGroupMeta(_PluginBase): if not iteminfo: self.log_info(f"未找到媒体项 - itemid: {_id}, 第 {order} 季, 第 {ep_num} 集") continue - # 是否无视项目锁定 - if not self._ignorelock: + # 是否无视项目锁定, 指定剧集组id时也属于无视项目锁定 + if not self._ignorelock and not group_id: if iteminfo.get("LockData") or ( "Name" in iteminfo.get("LockedFields", []) and "Overview" in iteminfo.get("LockedFields", [])): @@ -376,6 +706,7 @@ class EpisodeGroupMeta(_PluginBase): continue self.log_info(f"{mediainfo.title_year} 已经运行完毕了..") + return True @staticmethod def __append_to_list(list, item): From acb441e746006cc5b20080beaea6a5458acc10ae Mon Sep 17 00:00:00 2001 From: cikezhu <604054726@qq.com> Date: Tue, 12 Nov 2024 13:23:06 +0800 Subject: [PATCH 162/218] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=8F=91=E9=80=81?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E6=8F=90=E9=86=92=E9=80=89=E6=8B=A9=E5=89=A7?= =?UTF-8?q?=E9=9B=86=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 5 +- plugins/episodegroupmeta/__init__.py | 73 +++++++++++++++++++++++----- 2 files changed, 65 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index 3ee61b2..2639509 100644 --- a/package.json +++ b/package.json @@ -527,13 +527,14 @@ "name": "TMDB剧集组刮削", "description": "从TMDB剧集组刮削季集的实际顺序。", "labels": "刮削", - "version": "2.0", + "version": "2.1", "icon": "Element_A.png", "author": "叮叮当", "level": 1, "v2": true, "history": { - "v2.0": "新增 手动选择剧集组功能" + "v2.1": "增加发送通知提醒选择剧集组", + "v2.0": "增加手动选择剧集组的功能" } }, "CustomIndexer": { diff --git a/plugins/episodegroupmeta/__init__.py b/plugins/episodegroupmeta/__init__.py index a76fb7f..371ad1c 100644 --- a/plugins/episodegroupmeta/__init__.py +++ b/plugins/episodegroupmeta/__init__.py @@ -48,7 +48,7 @@ class EpisodeGroupMeta(_PluginBase): # 主题色 plugin_color = "#098663" # 插件版本 - plugin_version = "2.0" + plugin_version = "2.1" # 插件作者 plugin_author = "叮叮当" # 作者主页 @@ -71,6 +71,7 @@ class EpisodeGroupMeta(_PluginBase): jellyfin = None _enabled = False + _notify = True _autorun = True _ignorelock = False _delay = 0 @@ -84,6 +85,7 @@ class EpisodeGroupMeta(_PluginBase): self.jellyfin = Jellyfin() if config: self._enabled = config.get("enabled") + self._notify = config.get("notify") self._autorun = config.get("autorun") self._ignorelock = config.get("ignorelock") self._delay = config.get("delay") or 120 @@ -93,9 +95,11 @@ class EpisodeGroupMeta(_PluginBase): if s and s not in self._allowlist: self._allowlist.append(s) self.log_info(f"白名单数量: {len(self._allowlist)} > {self._allowlist}") - if not ("autorun" in config): - # 新版本v1.2更新插件配置默认配置 + if not ("notify" in config): + # 新版本v2.0更新插件配置默认配置 + self._notify = True self._autorun = True + config["notify"] = True config["autorun"] = True self.update_config(config) self.log_warn(f"新版本v{self.plugin_version} 配置修正 ...") @@ -159,13 +163,28 @@ class EpisodeGroupMeta(_PluginBase): return schemas.Response(success=False, message="解析媒体信息失败") # 开始刮削 self.log_info(f"开始刮削: {mediainfo.title} | {mediainfo.year} | {episode_groups}") + self.systemmessage.put("正在刮削中,请稍等!", title="剧集组刮削") if self.start_rt(mediainfo, episode_groups, group_id): - self.log_info("刮削剧集组, 执行成功! 后台正在执行,请稍等!") - self.systemmessage.put("后台正在执行,请稍等!", title="剧集组刮削") + self.log_info("刮削剧集组, 执行成功!") + self.systemmessage.put("刮削剧集组, 执行成功!", title="剧集组刮削") + # 处理成功时, 发送通知 + if self._notify: + self.post_message( + mtype=schemas.NotificationType.Manual, + title="【剧集组处理结果: 成功】", + text=f"媒体名称:{mediainfo.title}\n发行年份: {mediainfo.year}\n剧集组数: {len(episode_groups)}" + ) return schemas.Response(success=True, message="刮削剧集组, 执行成功!") else: self.log_error("执行失败, 请查看插件日志!") self.systemmessage.put("执行失败, 请查看插件日志!", title="剧集组刮削") + # 处理成功时, 发送通知 + if self._notify: + self.post_message( + mtype=schemas.NotificationType.Manual, + title="【剧集组处理结果: 失败】", + text=f"媒体名称:{mediainfo.title}\n发行年份: {mediainfo.year}\n剧集组数: {len(episode_groups)}\n注意: 失败原因请查看日志.." + ) return schemas.Response(success=False, message="执行失败, 请查看插件日志") def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: @@ -183,7 +202,7 @@ class EpisodeGroupMeta(_PluginBase): 'component': 'VCol', 'props': { 'cols': 12, - 'md': 4 + 'md': 3 }, 'content': [ { @@ -199,7 +218,7 @@ class EpisodeGroupMeta(_PluginBase): 'component': 'VCol', 'props': { 'cols': 12, - 'md': 4 + 'md': 3 }, 'content': [ { @@ -215,18 +234,34 @@ class EpisodeGroupMeta(_PluginBase): 'component': 'VCol', 'props': { 'cols': 12, - 'md': 4 + 'md': 3 }, 'content': [ { 'component': 'VCheckboxBtn', 'props': { 'model': 'ignorelock', - 'label': '强制刮削已锁定的媒体信息', + 'label': '无视锁定的媒体', } } ] - } + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VCheckboxBtn', + 'props': { + 'model': 'notify', + 'label': '开启通知', + } + } + ] + }, ] }, { @@ -318,6 +353,7 @@ class EpisodeGroupMeta(_PluginBase): } ], { "enabled": False, + "notify": True, "autorun": True, "ignorelock": False, "allowlist": "", @@ -577,13 +613,28 @@ class EpisodeGroupMeta(_PluginBase): # 禁止自动刮削时直接返回 if not self._autorun: self.log_warn(f"{mediainfo.title} 未勾选自动刮削, 无需处理") + # 发送通知 + if self._notify and mediainfo_dict: + self.post_message( + mtype=schemas.NotificationType.Manual, + title="【待手动处理的剧集组】", + text=f"媒体名称:{mediainfo.title}\n发行年份: {mediainfo.year}\n剧集组数: {len(episode_groups)}" + ) return # 延迟 if self._delay: self.log_warn(f"{mediainfo.title} 将在 {self._delay} 秒后开始处理..") time.sleep(int(self._delay)) # 开始处理 - self.start_rt(mediainfo=mediainfo, episode_groups=episode_groups) + if self.start_rt(mediainfo=mediainfo, episode_groups=episode_groups): + # 处理完成时, 属于自动匹配的, 发送通知 + if self._notify and mediainfo_dict: + self.post_message( + mtype=schemas.NotificationType.Manual, + title="【已自动匹配的剧集组】", + text=f"媒体名称:{mediainfo.title}\n发行年份: {mediainfo.year}\n剧集组数: {len(episode_groups)}" + ) + return def start_rt(self, mediainfo: schemas.MediaInfo, episode_groups: Any | None, group_id: str = None) -> bool: """ From 6e39f5a854b25ce935a97bf5cba626af2084f97a Mon Sep 17 00:00:00 2001 From: jxxghp Date: Tue, 12 Nov 2024 20:25:24 +0800 Subject: [PATCH 163/218] =?UTF-8?q?add=20=E5=8E=86=E5=8F=B2=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E8=BF=81=E7=A7=BB=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 9 + plugins.v2/historytov2/__init__.py | 335 +++++++++++++++++++++++++++++ 2 files changed, 344 insertions(+) create mode 100644 plugins.v2/historytov2/__init__.py diff --git a/package.v2.json b/package.v2.json index c369fb7..3a90b35 100644 --- a/package.v2.json +++ b/package.v2.json @@ -203,5 +203,14 @@ "v2.1": "调整IYUU最新域名", "v2.0": "兼容MoviePilot V2 版本" } + }, + "HistoryToV2": { + "name": "历史记录迁移", + "description": "将MoviePilot V1版本的整理历史记录迁移至V2版本。", + "labels": "整理,历史记录", + "version": "1.0", + "icon": "Moviepilot_A.png", + "author": "jxxghp", + "level": 1 } } \ No newline at end of file diff --git a/plugins.v2/historytov2/__init__.py b/plugins.v2/historytov2/__init__.py new file mode 100644 index 0000000..6bc2144 --- /dev/null +++ b/plugins.v2/historytov2/__init__.py @@ -0,0 +1,335 @@ +import json +from pathlib import Path +from typing import Any, List, Dict, Tuple, Optional + +from app.db import SessionFactory +from app.db.models import TransferHistory +from app.log import logger +from app.plugins import _PluginBase +from app.utils.http import RequestUtils + + +class HistoryToV2(_PluginBase): + # 插件名称 + plugin_name = "历史记录迁移" + # 插件描述 + plugin_desc = "将MoviePilot V1版本的整理历史记录迁移至V2版本。" + # 插件图标 + plugin_icon = "Moviepilot_A.png" + # 插件版本 + plugin_version = "1.0" + # 插件作者 + plugin_author = "jxxghp" + # 作者主页 + author_url = "https://github.com/jxxghp" + # 插件配置项ID前缀 + plugin_config_prefix = "historytov2_" + # 加载顺序 + plugin_order = 99 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + historyoper = None + _enabled = False + _host = None + _username = None + _password = None + + def init_plugin(self, config: dict = None): + if config: + self._enabled = config.get("enabled") + self._host = config.get("host") + self._username = config.get("username") + self._password = config.get("password") + + if self._enabled and self._host and self._username and self._password: + # 关闭开关 + self.__close_config() + # 登录MP获取token + token = self.__login_mp() + if token: + # 当前页码 + page = 1 + # 总记录数 + total = 0 + # 获取历史记录 + history = self.__get_history(token) + while history: + # 处理历史记录 + logger.info(f"开始处理第 {page} 页历史记录 ...") + self.__insert_history(history) + # 处理成功一批 + total += len(history) + logger.info(f"第 {page} 页处理完成,共处理 {total} 条记录") + # 获取下一页历史记录 + page += 1 + history = self.__get_history(token, page=page) + # 处理完成 + logger.info(f"历史记录迁移完成,共迁移 {total} 条记录!") + self.systemmessage.put(f"历史记录迁移完成,共迁移 {total} 条记录!", title="MoviePilot历史记录迁移") + else: + self.systemmessage.put(f"配置不完整,服务启动失败!", title="MoviePilot历史记录迁移") + # 关闭开关 + self.__close_config() + + def __close_config(self): + """ + 关闭开关 + """ + self._enabled = False + self.update_config({ + "enabled": self._enabled, + "host": self._host, + "username": self._username, + "password": self._password + }) + + def get_state(self) -> bool: + return self._enabled + + @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、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'host', + 'label': 'MoviePilot V1地址', + 'placeholder': 'http://localhost:3000', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'username', + 'label': '登录用户名', + 'placeholder': 'admin' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'password', + 'label': '登录密码', + 'type': 'password', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': 'MoviePilot V1 需要是启动状态且能正常访问,V1版本和V2版本目录映射需要保持一致,迁移时间可能较长,完成后会收到系统通知。' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "host": None, + "username": None, + "password": None + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + pass + + def __login_mp(self) -> Optional[str]: + """ + 登录MP获取token + """ + if not self._host or not self._username or not self._password: + return None + url = f"{self._host}/api/v1/login/access-token" + headers = { + "Content-Type": "application/x-www-form-urlencoded" + } + data = { + "username": self._username, + "password": self._password + } + logger.info(f"登录MoviePilot: {url}") + # 发送POST请求 + response = RequestUtils(headers=headers).post_res(url, data=data) + # 检查响应状态 + if response.status_code == 200: + # 成功获取token + token_data = response.json() + logger.info(f"登录MoviePilot成功,获取token:{token_data['access_token']}", ) + return token_data["access_token"] + else: + # 处理失败响应 + logger.warn(f"登录MoviePilot失败: {response.json()}") + self.systemmessage.put(f"登录MoviePilot失败,无法同步历史记录!", title="MoviePilot历史记录迁移") + return None + + def __get_history(self, token: str, page: int = 1, count: int = 30) -> Optional[List[dict]]: + """ + 获取历史记录 + """ + if not token: + return [] + url = f"{self._host}/api/v1/history/transfer" + headers = { + "Authorization": f"Bearer {token}" + } + params = { + "page": page, + "count": count + } + logger.info(f"查询转移历史记录: {url},params: {params}") + # 发送GET请求 + response = RequestUtils(headers=headers).get_res(url, params=params) + # 检查响应状态 + if response.status_code == 200: + # 返回数据 + response_data = response.json() + data = response_data.get("data") + logger.info(f"查询转移历史记录成功,共 {len(data.get('list'))} 条记录") + return data.get("list") + else: + # 处理失败响应 + logger.warn("查询转移历史记录失败:", response.json()) + self.systemmessage.put(f"查询转移历史记录失败,无法同步历史记录!", title="MoviePilot历史记录迁移") + return [] + + @staticmethod + def __insert_history(history: List[dict]): + """ + 插入历史记录 + """ + if not history: + return + with SessionFactory() as db: + for item in history: + if item.get("src"): + transferhistory = TransferHistory.get_by_src(db, item.get("src")) + if transferhistory: + transferhistory.delete(db, transferhistory.id) + try: + TransferHistory( + src=item.get("src"), + src_storage="local", + src_fileitem={ + "storage": "local", + "type": "file", + "path": item.get("src"), + "name": Path(item.get("src")).name, + "basename": Path(item.get("src")).stem, + "extension": Path(item.get("src")).suffix[1:], + }, + dest=item.get("dest"), + dest_storage="local", + dest_fileitem={ + "storage": "local", + "type": "file", + "path": item.get("dest"), + "name": Path(item.get("dest")).name, + "basename": Path(item.get("dest")).stem, + "extension": Path(item.get("dest")).suffix[1:], + }, + mode=item.get("mode"), + type=item.get("type"), + category=item.get("category"), + title=item.get("title"), + year=item.get("year"), + tmdbid=item.get("tmdbid"), + imdbid=item.get("imdbid"), + tvdbid=item.get("tvdbid"), + doubanid=item.get("doubanid"), + seasons=item.get("seasons"), + episodes=item.get("episodes"), + image=item.get("image"), + download_hash=item.get("download_hash"), + status=item.get("status"), + files=json.loads(item.get("files")) if item.get("files") else [], + date=item.get("date"), + errmsg=item.get("errmsg") + ).create(db) + except Exception as e: + logger.error(f"插入历史记录失败:{e}") + continue From 61dd0ef9180ca8f0d3739dbe1e0268413910dc26 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Tue, 12 Nov 2024 20:55:54 +0800 Subject: [PATCH 164/218] =?UTF-8?q?=E5=90=8C=E6=AD=A5CookieCloud=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20V2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 2639509..71b0244 100644 --- a/package.json +++ b/package.json @@ -884,6 +884,7 @@ "icon": "Cookiecloud_A.png", "author": "thsrite", "level": 1, + "v2": true, "history": { "v2.0": "调整逻辑,修复问题", "v1.3": "感谢MidnightShake共享代码(同步时保留MoviePilot不匹配站点的cookie)", From c9cc458dca540e6bb15614e0a92c6b0bf53219be Mon Sep 17 00:00:00 2001 From: ramen <1205925392@qq.com> Date: Wed, 13 Nov 2024 03:35:07 +0800 Subject: [PATCH 165/218] =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=BC=81=E5=BE=AE?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E9=80=9A=E7=9F=A5=E5=92=8C=E7=AC=ACServer?= =?UTF-8?q?=E3=80=81Anpush=E3=80=81PushPlus=E7=AD=89=E7=AC=AC=E4=B8=89?= =?UTF-8?q?=E6=96=B9=E6=8E=A8=E9=80=81=E3=80=82=E6=8C=89=E8=A6=81=E6=B1=82?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=8F=92=E4=BB=B6=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 14 +- plugins/dynamicwechat/__init__.py | 320 ++++++++++--------------- plugins/dynamicwechat/notify_helper.py | 133 ++++++++++ plugins/dynamicwechat/update_help.py | 18 +- 4 files changed, 271 insertions(+), 214 deletions(-) create mode 100644 plugins/dynamicwechat/notify_helper.py diff --git a/package.json b/package.json index 71b0244..048729b 100644 --- a/package.json +++ b/package.json @@ -857,23 +857,21 @@ "v2": true }, "DynamicWeChat": { - "name": "假的企业微信可信IP", - "description": "优先使用cookie,可本地扫码刷新Cookie,验证码以?结尾发送到企业微信应用", + "name": "动态企微可信IP", + "description": "修改企微应用可信IP,详细说明查看'作者主页',支持第三方通知。验证码以?结尾发送到企业微信应用", "labels": "消息通知", - "version": "1.4.1", + "version": "1.5.0", "icon": "Wecom_A.png", "author": "RamenRa", "level": 2, "v2": true, "history": { - "v1.4.1": "完善面板说明,为了和原仓库做出明显区别在插件名称添加了特殊字符", + "v1.5.0": "支持企微应用通知和第Serve酱等第三方推送。按要求修改插件名称", + "v1.4.1": "完善面板说明", "v1.4.0": "修复强制更改IP时配置面板延时过长的问题。庆祝v2进入正式版,显示了一个没用的参数", "v1.3.1": "修正一些逻辑判断,修改ip成功会通知一次", "v1.3.0": "兼容v2,操作cookie前检查一次CookieCloud", - "v1.2.0": "远程命令/push_qr,立即推送一次二维码到pushplus。添加<本地扫码刷新cookie>", - "v1.1.5": "将chromium运行设置为headless模式", - "v1.1.4": "放弃self.post_message()的消息推送,还原成send_pushplus_message()", - "v1.1.3": "关闭cookie输入框,延长cookie任务成功时不输出日志,使用设定中的CookieCloud设置" + "v1.2.0": "远程命令/push_qr,立即推送一次二维码到pushplus。添加<本地扫码刷新cookie>" } }, "SyncCookieCloud": { diff --git a/plugins/dynamicwechat/__init__.py b/plugins/dynamicwechat/__init__.py index d4d0f19..0b1942a 100644 --- a/plugins/dynamicwechat/__init__.py +++ b/plugins/dynamicwechat/__init__.py @@ -19,18 +19,19 @@ from app.helper.cookiecloud import CookieCloudHelper from app.log import logger from app.plugins import _PluginBase from app.plugins.dynamicwechat.update_help import PyCookieCloud +from app.plugins.dynamicwechat.notify_helper import MySender from app.schemas.types import EventType, NotificationType class DynamicWeChat(_PluginBase): # 插件名称 - plugin_name = "假的企业微信可信IP" + plugin_name = "动态企微可信IP" # 插件描述 - plugin_desc = "优先使用cookie,可本地扫码刷新Cookie,验证码以?结尾发送到企业微信应用" + plugin_desc = "修改企微应用可信IP,详细说明查看'作者主页',支持第三方通知。验证码以?结尾发送到企业微信应用" # 插件图标 plugin_icon = "Wecom_A.png" # 插件版本 - plugin_version = "1.4.1" + plugin_version = "1.5.0" # 插件作者 plugin_author = "RamenRa" # 作者主页 @@ -56,6 +57,10 @@ class DynamicWeChat(_PluginBase): _local_scan = False # 类初始化时添加标记变量 _is_special_upload = False + # 聚合通知 + _my_send = None + # 通知方式token/api + _notification_token = '' # 匹配ip地址的正则 _ip_pattern = r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b' @@ -69,17 +74,16 @@ class DynamicWeChat(_PluginBase): _refresh_cron = '*/20 * * * *' # 输入的企业应用id _input_id_list = '' - # helloimg的token - _helloimg_s_token = "" - # pushplus的token - _pushplus_token = "" # 二维码 _qr_code_image = None + # 用户消息 text = "" # 手机验证码 _verification_code = '' # 过期时间 _future_timestamp = 0 + # 配置文件路径 + _settings_file_path = None # cookie有效检测 _cookie_valid = True @@ -97,30 +101,31 @@ class DynamicWeChat(_PluginBase): def init_plugin(self, config: dict = None): # 清空配置 - self._helloimg_s_token = '' - self._pushplus_token = '' + self._notification_token = '' self._ip_changed = True self._forced_update = False self._use_cookiecloud = True self._local_scan = False self._input_id_list = '' self._cookie_header = "" - self._current_ip_address = self.get_ip_from_url(self._ip_urls[0]) - self._cookie_lifetime = PyCookieCloud.load_cookie_lifetime() + self._current_ip_address = self.get_ip_from_url(random.choice(self._ip_urls)) + self._settings_file_path = self.get_data_path() / "settings.json" + # self._cookie_lifetime = PyCookieCloud.load_cookie_lifetime() if config: self._enabled = config.get("enabled") + self._notification_token = config.get("notification_token") self._cron = config.get("cron") self._onlyonce = config.get("onlyonce") self._input_id_list = config.get("input_id_list") self._current_ip_address = config.get("current_ip_address") - self._pushplus_token = config.get("pushplus_token") - self._helloimg_s_token = config.get("helloimg_s_token") self._forced_update = config.get("forced_update") self._local_scan = config.get("local_scan") self._use_cookiecloud = config.get("use_cookiecloud") self._cookie_header = config.get("cookie_header") self._ip_changed = config.get("ip_changed") - + self._my_send = MySender(self._notification_token) + if not self._my_send.init_success: # 没有输入通知方式,不通知 + self._my_send = None # 停止现有任务 self.stop_service() if (self._enabled or self._onlyonce) and self._input_id_list: @@ -174,8 +179,25 @@ class DynamicWeChat(_PluginBase): event_data = event.event_data if not event_data or event_data.get("action") != "dynamicwechat": return - self.ChangeIP() - self.__update_config() + # 先尝试cookie登陆 + try: + with sync_playwright() as p: + browser = p.chromium.launch(headless=True, args=['--lang=zh-CN']) + context = browser.new_context() + cookie = self.get_cookie() + if cookie: + context.add_cookies(cookie) + page = context.new_page() + page.goto(self._wechatUrl) + time.sleep(3) + if self.check_login_status(page, task='forced_change'): + self.click_app_management_buttons(page) + else: + logger.error("cookie失效,强制修改IP失败:请使用'本地扫码修改IP'") + browser.close() + except Exception as err: + logger.error(f"强制修改IP失败:{err}") + logger.info("----------------------本次任务结束----------------------") @eventmanager.register(EventType.PluginAction) @@ -198,7 +220,6 @@ class DynamicWeChat(_PluginBase): page = context.new_page() page.goto(self._wechatUrl) time.sleep(3) # 页面加载等待时间 - if self.find_qrc(page): current_time = datetime.now() future_time = current_time + timedelta(seconds=110) @@ -242,12 +263,7 @@ class DynamicWeChat(_PluginBase): self.ChangeIP() self.__update_config() - # logger.info("检测公网IP完毕") logger.info("----------------------本次任务结束----------------------") - # if event: - # self.post_message(channel=event.event_data.get("channel"), - # title="检测公网IP完毕", - # userid=event.event_data.get("user")) def CheckIP(self): retry_urls = random.sample(self._ip_urls, len(self._ip_urls)) @@ -338,22 +354,16 @@ class DynamicWeChat(_PluginBase): qr_code_data = requests.get(qr_code_url).content self._qr_code_image = io.BytesIO(qr_code_data) - return True + refuse_time = (datetime.now() + timedelta(seconds=115)).strftime("%Y-%m-%d %H:%M:%S") + return qr_code_url, refuse_time else: logger.warning("未找到二维码") - return False + return None, None except Exception as e: logger.debug(str(e)) - return False + return None, None + - def send_pushplus_message(self, title, content): - pushplus_url = f"http://www.pushplus.plus/send/{self._pushplus_token}" - pushplus_data = { - "title": title, - "content": content, - "template": "html" - } - response = requests.post(pushplus_url, json=pushplus_data) def ChangeIP(self): logger.info("开始请求企业微信管理更改可信IP") @@ -362,25 +372,20 @@ class DynamicWeChat(_PluginBase): # 启动 Chromium 浏览器并设置语言为中文 browser = p.chromium.launch(headless=True, args=['--lang=zh-CN']) context = browser.new_context() - # ----------cookie addd----------------- cookie = self.get_cookie() if cookie: context.add_cookies(cookie) - # ----------cookie END----------------- page = context.new_page() page.goto(self._wechatUrl) time.sleep(3) - if self.find_qrc(page): - if self._pushplus_token and self._helloimg_s_token: - img_src, refuse_time = self.upload_image(self._qr_code_image) - self.send_pushplus_message(refuse_time, f"企业微信登录二维码
") - # if img_src: - # self.post_message( - # mtype=NotificationType.Plugin, - # title="企业微信登录二维码", - # text=refuse_time, - # image=img_src - # ) + img_src, refuse_time = self.find_qrc(page) + if img_src: + if self._my_send: + result = self._my_send.send("企业微信登录二维码", content=None, image=img_src, force_send=False) + if result: + logger.info(f"二维码发送失败,原因:{result}") + browser.close() + return logger.info("二维码已经发送,等待用户 90 秒内扫码登录") # logger.info("如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301?") time.sleep(90) # 等待用户扫码 @@ -391,17 +396,15 @@ class DynamicWeChat(_PluginBase): else: self._ip_changed = False else: - logger.info("cookie失效,请重新上传或者配置pushplus_token和helloimg_s_token。") + self._ip_changed = False + logger.info("cookie已失效") else: # 如果直接进入企业微信 logger.info("尝试cookie登录") - # ----------cookie addd----------------- login_status = self.check_login_status(page, "") if login_status: self.click_app_management_buttons(page) else: - # ----------cookie END----------------- self._ip_changed = False - return browser.close() except Exception as e: @@ -411,7 +414,7 @@ class DynamicWeChat(_PluginBase): def _update_cookie(self, page, context): self._future_timestamp = 0 # 标记二维码失效 - PyCookieCloud.save_cookie_lifetime(0) # 重置cookie存活时间 + PyCookieCloud.save_cookie_lifetime(self._settings_file_path, 0) # 重置cookie存活时间 if self._use_cookiecloud: if not self._cc_server: # 连接失败返回 False self.try_connect_cc() # 再尝试一次连接 @@ -487,26 +490,34 @@ class DynamicWeChat(_PluginBase): return cookies def refresh_cookie(self): # 保活 - try: - with sync_playwright() as p: - browser = p.chromium.launch(headless=True, args=['--lang=zh-CN']) - context = browser.new_context() - cookie = self.get_cookie() - if cookie: - context.add_cookies(cookie) - page = context.new_page() - page.goto(self._wechatUrl) - time.sleep(3) - if not self.check_login_status(page, task='refresh_cookie'): - self._cookie_valid = False - logger.warning("cookie已失效,下次IP变动推送二维码") - else: - self._cookie_valid = True - PyCookieCloud.increase_cookie_lifetime(1200) - self._cookie_lifetime = PyCookieCloud.load_cookie_lifetime() - browser.close() - except Exception as e: - logger.error(f"cookie校验失败:{e}") + if self._use_cookiecloud: + try: + with sync_playwright() as p: + browser = p.chromium.launch(headless=True, args=['--lang=zh-CN']) + context = browser.new_context() + cookie = self.get_cookie() + if cookie: + context.add_cookies(cookie) + page = context.new_page() + page.goto(self._wechatUrl) + time.sleep(3) + if not self.check_login_status(page, task='refresh_cookie'): + self._cookie_valid = False + if self._my_send: + result = self._my_send.send(title="cookie已失效,请及时更新", + content="请在企业微信应用发送/push_qr,让插件推送二维码。如果是使用微信通知请确保公网IP还没有变动", + image=None, force_send=False) # 标题,内容,图片,是否强制发送 + if result: + logger.info(f"cookie失效通知发送失败,原因:{result}") + else: + self._cookie_valid = True + if self._my_send: + self._my_send.reset_limit() + PyCookieCloud.increase_cookie_lifetime(self._settings_file_path, 1200) + self._cookie_lifetime = PyCookieCloud.load_cookie_lifetime(self._settings_file_path) + browser.close() + except Exception as e: + logger.error(f"cookie校验失败:{e}") # def check_login_status(self, page, task): @@ -600,81 +611,15 @@ class DynamicWeChat(_PluginBase): logger.info(f"应用: {app_id} 输入IP:" + self._current_ip_address) ip_parts = self._current_ip_address.split('.') masked_ip = f"{ip_parts[0]}.{len(ip_parts[1]) * '*'}.{len(ip_parts[2]) * '*'}.{ip_parts[3]}" - self.post_message( - mtype=NotificationType.Plugin, - title="更新可信IP成功", - text='应用: ' + app_id + ' 输入IP:' + masked_ip, - # image=img_src - ) + if self._my_send: + result = self._my_send.send(title="更新可信IP成功", + content='应用: ' + app_id + ' 输入IP:' + masked_ip, + force_send=True, diy_channel="WeChat") return else: logger.error("未找到应用id,修改IP失败") return - def upload_image(self, file_obj, permission=1, strategy_id=1, album_id=1): - """ - 上传图片到 helloimg 图床,支持传入文件路径或 BytesIO 对象。 - - :param file_obj: 文件对象,可以是路径 (str) 或 BytesIO 对象 - :param permission: 上传图片的权限设置,默认 1 - :param strategy_id: 上传策略 ID,默认 1 - :param album_id: 相册 ID,默认 1 - :return: 上传成功返回图片链接,失败返回 None - """ - helloimg_token = "Bearer " + self._helloimg_s_token - helloimg_url = "https://www.helloimg.com/api/v1/upload" - headers = { - "Authorization": helloimg_token, - "Accept": "application/json", - } - - # 构造上传的文件,支持传入 BytesIO 或文件路径 - if isinstance(file_obj, io.BytesIO): - # 如果是 BytesIO 对象,直接使用 - files = { - "file": ('qr_code.png', file_obj, 'image/png') - } - else: - # 如果是文件路径,打开文件进行读取 - files = { - "file": open(file_obj, "rb") - } - - expired_at = (datetime.now() + timedelta(hours=24)).strftime("%Y-%m-%d %H:%M:%S") - helloimg_data = { - "token": "你的临时上传 Token", # 确保这里的 token 是有效的 - "permission": permission, - "strategy_id": strategy_id, - "album_id": album_id, - "expired_at": expired_at - } - refuse_time = (datetime.now() + timedelta(seconds=110)).strftime("%Y-%m-%d %H:%M:%S") - - # 发送上传请求 - response = requests.post(helloimg_url, headers=headers, files=files, data=helloimg_data) - - # 检查响应内容是否符合预期 - response_data = None - try: - response_data = response.json() - if not response_data['status']: - if response_data['message'] == "Unauthenticated.": - logger.error("Token失效,无法上传图片。请检查你的上传Token。") - logger.info(f"使用的Token: {helloimg_token}") - # self._ip_changed = False - return - else: - logger.error(f"上传到图床失败: {response_data['message']}") - self._ip_changed = False - return - - img_src = response_data['data']['links']['html'] - return img_src.split('"')[1], refuse_time # 提取 img src - except KeyError as e: - logger.error(f"上传图片时解析响应失败: {e}, 响应内容: {response_data}") - self._ip_changed = False - return - def __update_config(self): """ 更新配置 @@ -683,13 +628,11 @@ class DynamicWeChat(_PluginBase): "enabled": self._enabled, "onlyonce": self._onlyonce, "cron": self._cron, - # "wechatUrl": self._wechatUrl, + "notification_token": self._notification_token, "current_ip_address": self._current_ip_address, "ip_changed": self._ip_changed, "forced_update": self._forced_update, "local_scan": self._local_scan, - "helloimg_s_token": self._helloimg_s_token, - "pushplus_token": self._pushplus_token, "input_id_list": self._input_id_list, "cookie_header": self._cookie_header, "use_cookiecloud": self._use_cookiecloud, @@ -759,7 +702,6 @@ class DynamicWeChat(_PluginBase): } ] }, - # 添加 "使用CookieCloud获取cookie" 开关按钮 { 'component': 'VRow', 'content': [ @@ -816,6 +758,24 @@ class DynamicWeChat(_PluginBase): } } ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'notification_token', + 'label': '[可选] 通知方式', + 'rows': 1, + 'placeholder': '支持微信、Server酱、PushPlus、AnPush等Token或API' + } + } + ] } ] }, @@ -841,47 +801,6 @@ class DynamicWeChat(_PluginBase): } ] }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VTextarea', - 'props': { - 'model': 'pushplus_token', - 'label': '[可选]pushplus_token', - 'rows': 1, - 'placeholder': '[可选] 请输入 pushplus_token' - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VTextarea', - 'props': { - 'model': 'helloimg_s_token', - 'label': '[可选]helloimg_s_token', - 'rows': 1, - 'placeholder': '[可选] 请输入 helloimg_token' - } - } - ] - } - ] - }, { 'component': 'VRow', 'content': [ @@ -896,7 +815,7 @@ class DynamicWeChat(_PluginBase): 'props': { 'type': 'info', 'variant': 'tonal', - 'text': '使用内建CookieCloud 或 自定义 或 填写两个token 至少三选一。具体请查看作者主页' + 'text': '建议启用内建或自定义CookieCloud。支持微信、Server酱、PushPlus、AnPush,具体请查看作者主页' } } ] @@ -916,7 +835,7 @@ class DynamicWeChat(_PluginBase): 'component': 'VAlert', 'props': { 'type': 'info', - 'text': '优先使用cookie,当IP变动 且 cookie失效 且 填写两个token才会调用API推送登录二维码。', + 'text': 'cookie失效时通知用户,用户使用/push_qr让插件推送二维码。使用第三方通知时填写对应Token/API' } } ] @@ -931,11 +850,10 @@ class DynamicWeChat(_PluginBase): "onlyonce": False, "forceUpdate": False, "use_cookiecloud": True, - "use_local_qr": False, # 默认关闭本地扫码 + "use_local_qr": False, "cookie_header": "", - "pushplus_token": "", - "helloimg_token": "", - "input_id_list": "", + "notification_token": "", + "input_id_list": "" } def get_page(self) -> List[dict]: @@ -1083,10 +1001,15 @@ class DynamicWeChat(_PluginBase): page = context.new_page() page.goto(self._wechatUrl) time.sleep(3) - if self.find_qrc(page): - if self._pushplus_token and self._helloimg_s_token: - img_src, refuse_time = self.upload_image(self._qr_code_image) - self.send_pushplus_message(refuse_time, f"企业微信登录二维码
") + image_src, refuse_time = self.find_qrc(page) + if image_src: + if self._my_send: + # logger.info(f"远程推送任务: {image_src}") + result = self._my_send.send("企业微信登录二维码", content=None, image=image_src, force_send=False) + if result: + logger.info(f"远程推送任务: 二维码发送失败,原因:{result}") + browser.close() + return logger.info("远程推送任务: 二维码已经发送,等待用户 90 秒内扫码登录") # logger.info("远程推送任务: 如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301?") time.sleep(90) @@ -1096,9 +1019,10 @@ class DynamicWeChat(_PluginBase): # logger.info("远程推送任务: 没有可用的CookieCloud服务器,只修改可信IP") self.click_app_management_buttons(page) else: - logger.warning("远程推送任务: 未配置pushplus_token和helloimg_s_token") + logger.warning("远程推送任务: 任何通知方式") else: logger.warning("远程推送任务: 未找到二维码") + browser.close() except Exception as e: logger.error(f"远程推送任务: 推送二维码失败: {e}") @@ -1108,7 +1032,7 @@ class DynamicWeChat(_PluginBase): { "cmd": "/push_qr", "event": EventType.PluginAction, - "desc": "立即推送登录二维码到pushplus", + "desc": "立即推送登录二维码到", "category": "", "data": { "action": "push_qrcode" diff --git a/plugins/dynamicwechat/notify_helper.py b/plugins/dynamicwechat/notify_helper.py new file mode 100644 index 0000000..92c2e35 --- /dev/null +++ b/plugins/dynamicwechat/notify_helper.py @@ -0,0 +1,133 @@ +import re +import requests +from app.modules.wechat.wechat import WeChat + + +class MySender: + def __init__(self, token=None): + if not token: # 如果 token 为空 + self.token = None + self.channel = None + self.init_success = False # 标识初始化失败 + else: + self.token = token + self.channel = self.send_channel() # 初始化时确定发送渠道 + self.first_text_sent = False # 记录是否已经发送过纯文本消息 + self.init_success = True # 标识初始化成功 + + def send_channel(self): + if self.token: + if self.token == "WeChat": + return "WeChat" + + letters_only = ''.join(re.findall(r'[A-Za-z]', self.token)) + # 判断其他推送渠道 + if self.token.startswith("SCT"): + return "ServerChan" + elif letters_only.isupper(): + return "AnPush" + else: + return "PushPlus" + return None + + # 标题,内容,图片,是否强制发送 + def send(self, title, content, image=None, force_send=False, diy_channel=None): + if not self.init_success: + return # 如果初始化失败,直接返回 + # 判断发送的内容类型 + contains_image = bool(image) # 是否包含图片 + + if not contains_image and not force_send: + if self.first_text_sent: + return + else: + self.first_text_sent = True + + try: + if not diy_channel: + channel = self.channel + else: + channel = diy_channel + + if channel == "WeChat": + return MySender.send_wechat(title, content, image) + elif channel == "ServerChan": + return self.send_serverchan(title, content, image) + elif channel == "AnPush": + return self.send_anpush(title, content, image) + elif channel == "PushPlus": + return self.send_pushplus(title, content, image) + else: + return "Unknown channel" + except Exception as e: + return f"Error occurred: {str(e)}" + + @staticmethod + def send_wechat(title, content, image): + wechat = WeChat() + if image: + send_status = wechat.send_msg(title='企业微信登录二维码', image=image, link=image) + else: + send_status = wechat.send_msg(title=title, text=content) + + if send_status is None: + return "微信通知发送错误" + return None + + def send_serverchan(self, title, content, image): + if self.token.startswith('sctp'): + match = re.match(r'sctp(\d+)t', self.token) + if match: + num = match.group(1) + url = f'https://{num}.push.ft07.com/send/{self.token}.send' + else: + raise ValueError('Invalid sendkey format for sctp') + else: + url = f'https://sctapi.ftqq.com/{self.token}.send' + + params = {'title': title, 'desp': f'![img]({image})' if image else content} + headers = {'Content-Type': 'application/json;charset=utf-8'} + response = requests.post(url, json=params, headers=headers) + result = response.json() + if result.get('code') != 0: + return f"Server酱通知错误: {result.get('message')}" + return None + + def send_anpush(self, title, content, image): + if ',' in self.token: + channel, token = self.token.split(',', 1) + else: + return + url = f"https://api.anpush.com/push/{token}" + payload = { + "title": title, + "content": f"" if image else content, + "channel": channel + } + headers = {"Content-Type": "application/x-www-form-urlencoded"} + response = requests.post(url, headers=headers, data=payload) + result = response.json() + # 判断返回的code和msgIds + if result.get('code') != 200: + return f"AnPush: {result.get('msg')}" + elif not result.get('data') or not result['data'].get('msgIds'): + return "AnPush 消息通道未找到" + return None + + def send_pushplus(self, title, content, image): + pushplus_url = f"http://www.pushplus.plus/send/{self.token}" + # PushPlus发送逻辑 + data = { + "title": title, + "content": f"企业微信登录二维码
" if image else content, + "template": "html" + } + response = requests.post(pushplus_url, json=data) + result = response.json() + if result.get('code') != 200: + return f"PushPlus send failed: {result.get('msg')}" + return None + + def reset_limit(self): + """解除限制,允许再次发送纯文本消息""" + self.first_text_sent = False diff --git a/plugins/dynamicwechat/update_help.py b/plugins/dynamicwechat/update_help.py index 9da818a..e3cd6f7 100644 --- a/plugins/dynamicwechat/update_help.py +++ b/plugins/dynamicwechat/update_help.py @@ -7,9 +7,6 @@ from typing import Dict, Any from Crypto import Random from Crypto.Cipher import AES -script_dir = os.path.dirname(os.path.abspath(__file__)) -settings_file = os.path.join(script_dir, 'settings.json') - def bytes_to_key(data: bytes, salt: bytes, output=48) -> bytes: # 兼容v2 将bytes_to_key和encrypt导入 @@ -98,7 +95,7 @@ class PyCookieCloud: return md5.hexdigest()[:16] @staticmethod - def load_cookie_lifetime(): # 返回时间戳 单位秒 + def load_cookie_lifetime(settings_file: str = None): # 返回时间戳 单位秒 if os.path.exists(settings_file): with open(settings_file, 'r') as file: settings = json.load(file) @@ -107,13 +104,18 @@ class PyCookieCloud: return 0 @staticmethod - def save_cookie_lifetime(cookie_lifetime): # 传入时间戳 单位秒 + def save_cookie_lifetime(settings_file, cookie_lifetime): # 传入时间戳 单位秒 with open(settings_file, 'w') as file: json.dump({'_cookie_lifetime': cookie_lifetime}, file) @staticmethod - def increase_cookie_lifetime(seconds: int): - current_lifetime = PyCookieCloud.load_cookie_lifetime() + def increase_cookie_lifetime(settings_file, seconds: int): + if os.path.exists(settings_file): + with open(settings_file, 'r') as file: + settings = json.load(file) + current_lifetime = settings.get('_cookie_lifetime', 0) + else: + current_lifetime = 0 new_lifetime = current_lifetime + seconds # 保存新的 _cookie_lifetime - PyCookieCloud.save_cookie_lifetime(new_lifetime) + PyCookieCloud.save_cookie_lifetime(settings_file, new_lifetime) From 90738219f156205c4f25c5a1d2305b963120e837 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Wed, 13 Nov 2024 08:41:06 +0800 Subject: [PATCH 166/218] =?UTF-8?q?IYUU=E6=B6=88=E6=81=AF=E6=8E=A8?= =?UTF-8?q?=E9=80=81=20=E5=A2=9E=E5=8A=A0=20=E9=99=90=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 17 ++++- plugins/iyuumsg/__init__.py | 127 ++++++++++++++++++++++++------------ 2 files changed, 100 insertions(+), 44 deletions(-) diff --git a/package.json b/package.json index 048729b..1fd28e0 100644 --- a/package.json +++ b/package.json @@ -891,7 +891,7 @@ "v1.0": "同步MoviePilot站点Cookie到CookieCloud" } }, - "BangumiColl": { + "BangumiColl": { "name": "Bangumi收藏订阅", "description": "Bangumi用户收藏添加到订阅", "labels": "订阅", @@ -905,5 +905,18 @@ "v1.5.1": "修复季度信息未传递的问题. 新增站点列表同步删除", "v1.5": "修复总集数会同步TMDB变动的问题,增加开关选项" } - } + }, + "IyuuMsg": { + "name": "IYUU消息推送", + "description": "支持使用IYUU发送消息通知。", + "labels": "消息通知,IYUU", + "version": "1.3", + "icon": "Iyuu_A.png", + "author": "jxxghp", + "level": 1, + "v2": true, + "history": { + "v1.3": "消息限流发送,以缓解IYUU服务器压力" + } + } } diff --git a/plugins/iyuumsg/__init__.py b/plugins/iyuumsg/__init__.py index f492ccb..542bf4c 100644 --- a/plugins/iyuumsg/__init__.py +++ b/plugins/iyuumsg/__init__.py @@ -1,11 +1,14 @@ +import threading +from queue import Queue +from time import time, sleep +from typing import Any, List, Dict, Tuple from urllib.parse import urlencode -from app.plugins import _PluginBase 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 -from typing import Any, List, Dict, Tuple -from app.log import logger class IyuuMsg(_PluginBase): @@ -16,7 +19,7 @@ class IyuuMsg(_PluginBase): # 插件图标 plugin_icon = "Iyuu_A.png" # 插件版本 - plugin_version = "1.2" + plugin_version = "1.3" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -33,12 +36,30 @@ class IyuuMsg(_PluginBase): _token = None _msgtypes = [] + # 消息处理线程 + processing_thread = None + # 上次发送时间 + last_send_time = 0 + # 消息队列 + message_queue = Queue() + # 消息发送间隔(秒) + send_interval = 5 + # 退出事件 + __event = threading.Event() + def init_plugin(self, config: dict = None): + self.__event.clear() if config: self._enabled = config.get("enabled") self._token = config.get("token") self._msgtypes = config.get("msgtypes") or [] + if self._enabled and self._token: + # 启动处理队列的后台线程 + self.processing_thread = threading.Thread(target=self.process_queue) + self.processing_thread.daemon = True + self.processing_thread.start() + def get_state(self) -> bool: return self._enabled and (True if self._token else False) @@ -143,55 +164,77 @@ class IyuuMsg(_PluginBase): @eventmanager.register(EventType.NoticeMessage) def send(self, event: Event): """ - 消息发送事件 + 消息发送事件,将消息加入队列 """ - if not self.get_state(): - return - - if not event.event_data: + if not self.get_state() or not event.event_data: return msg_body = event.event_data - # 渠道 - channel = msg_body.get("channel") - if channel: - return - # 类型 - msg_type: NotificationType = msg_body.get("type") - # 标题 - title = msg_body.get("title") - # 文本 - text = msg_body.get("text") - - if not title and not text: + # 验证消息的有效性 + if not msg_body.get("title") and not msg_body.get("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 + # 将消息加入队列 + self.message_queue.put(msg_body) + logger.info("消息已加入队列等待发送") - try: - sc_url = "https://iyuu.cn/%s.send?%s" % (self._token, urlencode({"text": title, "desp": text})) - res = RequestUtils().get_res(sc_url) - if res and res.status_code == 200: - ret_json = res.json() - errno = ret_json.get('errcode') - error = ret_json.get('errmsg') - if errno == 0: - logger.info("IYUU消息发送成功") + def process_queue(self): + """ + 处理队列中的消息,按间隔时间发送 + """ + while True: + if self.__event.is_set(): + logger.info("消息发送线程正在退出...") + break + # 获取队列中的下一条消息 + msg_body = self.message_queue.get() + + # 检查是否满足发送间隔时间 + current_time = time() + time_since_last_send = current_time - self.last_send_time + if time_since_last_send < self.send_interval: + sleep(self.send_interval - time_since_last_send) + + # 处理消息内容 + channel = msg_body.get("channel") + if channel: + continue + msg_type: NotificationType = msg_body.get("type") + title = msg_body.get("title") + text = msg_body.get("text") + + # 检查消息类型是否已启用 + if msg_type and self._msgtypes and msg_type.name not in self._msgtypes: + logger.info(f"消息类型 {msg_type.value} 未开启消息发送") + continue + + # 尝试发送消息 + try: + sc_url = "https://iyuu.cn/%s.send?%s" % (self._token, urlencode({"text": title, "desp": text})) + res = RequestUtils().get_res(sc_url) + if res and res.status_code == 200: + ret_json = res.json() + errno = ret_json.get('errcode') + error = ret_json.get('errmsg') + if errno == 0: + logger.info("IYUU消息发送成功") + # 更新上次发送时间 + self.last_send_time = time() + else: + logger.warn(f"IYUU消息发送失败,错误码:{errno},错误原因:{error}") + elif res is not None: + logger.warn(f"IYUU消息发送失败,错误码:{res.status_code},错误原因:{res.reason}") else: - logger.warn(f"IYUU消息发送失败,错误码:{errno},错误原因:{error}") - elif res is not None: - logger.warn(f"IYUU消息发送失败,错误码:{res.status_code},错误原因:{res.reason}") - else: - logger.warn("IYUU消息发送失败,未获取到返回信息") - except Exception as msg_e: - logger.error(f"IYUU消息发送失败,{str(msg_e)}") + logger.warn("IYUU消息发送失败,未获取到返回信息") + except Exception as msg_e: + logger.error(f"IYUU消息发送失败,{str(msg_e)}") + + # 标记任务完成 + self.message_queue.task_done() def stop_service(self): """ 退出插件 """ - pass + self.__event.set() From d53b3965f5f31433c7dc7325e01600a6f40b62e9 Mon Sep 17 00:00:00 2001 From: cikezhu <604054726@qq.com> Date: Wed, 13 Nov 2024 15:34:35 +0800 Subject: [PATCH 167/218] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dv2=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E8=AF=BB=E5=8F=96=E6=95=B0=E6=8D=AE=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 ++- plugins/episodegroupmeta/__init__.py | 12 +++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 1fd28e0..3e35740 100644 --- a/package.json +++ b/package.json @@ -527,12 +527,13 @@ "name": "TMDB剧集组刮削", "description": "从TMDB剧集组刮削季集的实际顺序。", "labels": "刮削", - "version": "2.1", + "version": "2.2", "icon": "Element_A.png", "author": "叮叮当", "level": 1, "v2": true, "history": { + "v2.2": "修复v2版本无法读取数据的问题", "v2.1": "增加发送通知提醒选择剧集组", "v2.0": "增加手动选择剧集组的功能" } diff --git a/plugins/episodegroupmeta/__init__.py b/plugins/episodegroupmeta/__init__.py index 371ad1c..293db25 100644 --- a/plugins/episodegroupmeta/__init__.py +++ b/plugins/episodegroupmeta/__init__.py @@ -23,7 +23,6 @@ from app.schemas.types import EventType from app.utils.common import retry from app.utils.http import RequestUtils from app.db.models import PluginData -from app.utils.object import ObjectUtils class ExistMediaInfo(BaseModel): # 类型 电影、电视剧 @@ -48,7 +47,7 @@ class EpisodeGroupMeta(_PluginBase): # 主题色 plugin_color = "#098663" # 插件版本 - plugin_version = "2.1" + plugin_version = "2.2" # 插件作者 plugin_author = "叮叮当" # 作者主页 @@ -360,6 +359,13 @@ class EpisodeGroupMeta(_PluginBase): "delay": 120 } + def is_objstr(self, obj: Any): + if not isinstance(obj, str): + return False + return str(obj).startswith("{") \ + or str(obj).startswith("[") \ + or str(obj).startswith("(") + def get_page(self) -> List[dict]: """ 拼装插件详情页面,需要返回页面配置,同时附带数据 @@ -372,7 +378,7 @@ class EpisodeGroupMeta(_PluginBase): try: tmdb_id = plugin_data.key # fix v1版本数据读取问题 - if ObjectUtils.is_obj(plugin_data.value): + if self.is_objstr(plugin_data.value): data = json.loads(plugin_data.value) else: data = plugin_data.value From ee030e1e46830eee1b84681db49c6409e4ed487e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AE=E5=8F=AE=E5=BD=93?= <604054726@qq.com> Date: Thu, 14 Nov 2024 20:00:51 +0800 Subject: [PATCH 168/218] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dv2=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E8=AF=BB=E5=8F=96=E5=AA=92=E4=BD=93=E5=BA=93?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 4 +- plugins/episodegroupmeta/__init__.py | 235 +++++++++++++++++++-------- 2 files changed, 169 insertions(+), 70 deletions(-) diff --git a/package.json b/package.json index 3e35740..f73f643 100644 --- a/package.json +++ b/package.json @@ -527,12 +527,14 @@ "name": "TMDB剧集组刮削", "description": "从TMDB剧集组刮削季集的实际顺序。", "labels": "刮削", - "version": "2.2", + "version": "2.5", "icon": "Element_A.png", "author": "叮叮当", "level": 1, "v2": true, "history": { + "v2.5": "修复当媒体服务器中剧集的季不完整时会中断的问题", + "v2.3": "修复v2版本无法读取媒体库的问题", "v2.2": "修复v2版本无法读取数据的问题", "v2.1": "增加发送通知提醒选择剧集组", "v2.0": "增加手动选择剧集组的功能" diff --git a/plugins/episodegroupmeta/__init__.py b/plugins/episodegroupmeta/__init__.py index 293db25..e2f919b 100644 --- a/plugins/episodegroupmeta/__init__.py +++ b/plugins/episodegroupmeta/__init__.py @@ -2,6 +2,8 @@ import base64 import json import threading import time +import importlib.util +import sys from pathlib import Path from typing import Any, List, Dict, Tuple, Optional, Union @@ -31,7 +33,9 @@ class ExistMediaInfo(BaseModel): groupep: Optional[Dict[int, list]] = {} # 集在媒体服务器的ID groupid: Optional[Dict[int, List[list]]] = {} - # 媒体服务器 + # 媒体服务器类型 + server_type: Optional[str] = None + # 媒体服务器名字 server: Optional[str] = None # 媒体ID itemid: Optional[Union[str, int]] = None @@ -47,7 +51,7 @@ class EpisodeGroupMeta(_PluginBase): # 主题色 plugin_color = "#098663" # 插件版本 - plugin_version = "2.2" + plugin_version = "2.5" # 插件作者 plugin_author = "叮叮当" # 作者主页 @@ -63,11 +67,11 @@ class EpisodeGroupMeta(_PluginBase): _event = threading.Event() # 私有属性 - mschain = None tv = None emby = None plex = None jellyfin = None + mediaserver_helper = None _enabled = False _notify = True @@ -77,11 +81,7 @@ class EpisodeGroupMeta(_PluginBase): _allowlist = [] def init_plugin(self, config: dict = None): - self.mschain = MediaServerChain() self.tv = TV() - self.emby = Emby() - self.plex = Plex() - self.jellyfin = Jellyfin() if config: self._enabled = config.get("enabled") self._notify = config.get("notify") @@ -240,7 +240,7 @@ class EpisodeGroupMeta(_PluginBase): 'component': 'VCheckboxBtn', 'props': { 'model': 'ignorelock', - 'label': '无视锁定的媒体', + 'label': '锁定的剧集也刮削', } } ] @@ -658,21 +658,51 @@ class EpisodeGroupMeta(_PluginBase): except Exception as e: self.log_error(f"{mediainfo.title} {str(e)}") return False - # 获取可用的媒体服务器 - _existsinfo = self.chain.media_exists(mediainfo=mediainfo) - if not _existsinfo: - self.log_warn(f"{mediainfo.title_year} 无可用的媒体服务器") - return False - # 存在媒体服务器 - existsinfo: ExistMediaInfo = self.__media_exists(server=_existsinfo.server, mediainfo=mediainfo, - existsinfo=_existsinfo) - if not existsinfo or not existsinfo.itemid: - self.log_warn(f"{mediainfo.title_year} 在媒体库中不存在") - return False - # 新增需要的属性 - existsinfo.server = _existsinfo.server - existsinfo.type = _existsinfo.type - self.log_info(f"{mediainfo.title_year} 存在于媒体服务器: {_existsinfo.server}") + # 获取全部可用的媒体服务器, 兼容v2 + service_infos = self.service_infos() + if self.mediaserver_helper == None: + # v1版本 单一媒体服务器的方式 + _existsinfo = self.chain.media_exists(mediainfo=mediainfo) + if not _existsinfo: + self.log_warn(f"{mediainfo.title_year} 无可用的媒体服务器") + return False + # 存在媒体服务器 + existsinfo: ExistMediaInfo = self.__media_exists(mediainfo=mediainfo, existsinfo=_existsinfo) + if not existsinfo or not existsinfo.itemid: + self.log_warn(f"{mediainfo.title_year} 在媒体库中不存在") + return False + return self.__start_rt_mediaserver(mediainfo=mediainfo, existsinfo=existsinfo, episode_groups=episode_groups, group_id=group_id) + else: + # v2版本 遍历所有媒体服务器的方式 + if not service_infos: + self.log_warn(f"{mediainfo.title_year} 无可用的媒体服务器") + return False + # 遍历媒体服务器 + relust_bool = False + for name, info in service_infos.items(): + self.log_info(f"正在查询媒体服务器: ({info.type}){name}") + _existsinfo = self.chain.media_exists(mediainfo=mediainfo, server=name) + if not _existsinfo: + self.log_warn(f"{mediainfo.title_year} 在 ({info.type}){name} 媒体服务器中不存在") + continue + existsinfo: ExistMediaInfo = self.__media_exists(mediainfo=mediainfo, existsinfo=_existsinfo, mediaserver_instance=info.instance) + if not existsinfo or not existsinfo.itemid: + self.log_warn(f"{mediainfo.title_year} 在 ({info.type}){name} 媒体服务器中不存在") + continue + _bool = self.__start_rt_mediaserver(mediainfo=mediainfo, existsinfo=existsinfo, episode_groups=episode_groups, group_id=group_id, mediaserver_instance=info.instance) + relust_bool = relust_bool or _bool + return relust_bool + + def __start_rt_mediaserver(self, + mediainfo: schemas.MediaInfo, + existsinfo: ExistMediaInfo, + episode_groups: Any | None, + group_id: str = None, + mediaserver_instance: Any = None) -> bool: + """ + 遍历媒体服务器剧集信息,并匹配合适的剧集组刷新季集信息 + """ + self.log_info(f"{mediainfo.title_year} 存在于 {existsinfo.server_type} 媒体服务器: {existsinfo.server}") # 获取全部剧集组信息 copy_keys = ['Id', 'Name', 'ChannelNumber', 'OriginalTitle', 'ForcedSortName', 'SortName', 'CommunityRating', 'CriticRating', 'IndexNumber', 'ParentIndexNumber', 'SortParentIndexNumber', 'SortIndexNumber', @@ -716,21 +746,24 @@ class EpisodeGroupMeta(_PluginBase): continue self.log_info(f"已匹配剧集组: {name}, {id}, 第 {order} 季") # 遍历全部媒体项并更新 + if existsinfo.groupid.get(order) is None: + self.log_info(f"媒体库中不存在: {mediainfo.title_year}, 第 {order} 季") + continue for _index, _ids in enumerate(existsinfo.groupid.get(order)): # 提取出媒体库中集id对应的集数index ep_num = ep[_index] for _id in _ids: # 获取媒体服务器媒体项 - iteminfo = self.get_iteminfo(server=existsinfo.server, itemid=_id) + iteminfo = self.get_iteminfo(server_type=existsinfo.server_type, itemid=_id, mediaserver_instance=mediaserver_instance) if not iteminfo: self.log_info(f"未找到媒体项 - itemid: {_id}, 第 {order} 季, 第 {ep_num} 集") continue - # 是否无视项目锁定, 指定剧集组id时也属于无视项目锁定 - if not self._ignorelock and not group_id: + # 锁定的剧集是否也刮削? + if not self._ignorelock: if iteminfo.get("LockData") or ( "Name" in iteminfo.get("LockedFields", []) and "Overview" in iteminfo.get("LockedFields", [])): - self.log_warn(f"已锁定媒体项 - itemid: {_id}, 第 {order} 季, 第 {ep_num} 集") + self.log_warn(f"已锁定媒体项 - itemid: {_id}, 第 {order} 季, 第 {ep_num} 集, 如果需要刮削请打开设置中的“锁定的剧集也刮削”选项") continue # 替换项目数据 episode = episodes[ep_num - 1] @@ -748,11 +781,12 @@ class EpisodeGroupMeta(_PluginBase): self.__append_to_list(new_dict["LockedFields"], "Name") self.__append_to_list(new_dict["LockedFields"], "Overview") # 更新数据 - self.set_iteminfo(server=existsinfo.server, itemid=_id, iteminfo=new_dict) + self.set_iteminfo(server_type=existsinfo.server_type, itemid=_id, iteminfo=new_dict, mediaserver_instance=mediaserver_instance) # still_path 图片 if episode.get("still_path"): - self.set_item_image(server=existsinfo.server, itemid=_id, - imageurl=f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{episode['still_path']}") + self.set_item_image(server_type=existsinfo.server_type, itemid=_id, + imageurl=f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{episode['still_path']}", + mediaserver_instance=mediaserver_instance) self.log_info(f"已修改剧集 - itemid: {_id}, 第 {order} 季, 第 {ep_num} 集") # 移除已经处理成功的季 existsinfo.groupep.pop(order, 0) @@ -770,19 +804,21 @@ class EpisodeGroupMeta(_PluginBase): if item not in list: list.append(item) - def __media_exists(self, server: str, mediainfo: schemas.MediaInfo, - existsinfo: schemas.ExistMediaInfo) -> ExistMediaInfo: + def __media_exists(self, mediainfo: schemas.MediaInfo, existsinfo: schemas.ExistMediaInfo, mediaserver_instance: Any = None) -> ExistMediaInfo: """ 根据媒体信息,返回剧集列表与剧集ID列表 :param mediainfo: 媒体信息 :return: 剧集列表与剧集ID列表 """ + # fix v1版本对比v2版本, 属性含义发生变化, 代码做兼容处理 + server_type = existsinfo.server_type if hasattr(existsinfo, 'server_type') else existsinfo.server def __emby_media_exists(): # 获取系列id item_id = None try: - res = self.emby.get_data(("[HOST]emby/Items?" + instance = mediaserver_instance or self.emby + res = instance.get_data(("[HOST]emby/Items?" "IncludeItemTypes=Series" "&Fields=ProductionYear" "&StartIndex=0" @@ -798,18 +834,18 @@ class EpisodeGroupMeta(_PluginBase): not mediainfo.year or str(res_item.get('ProductionYear')) == str(mediainfo.year)): item_id = res_item.get('Id') except Exception as e: - self.log_error(f"连接Items出错:" + str(e)) + self.log_error(f"媒体服务器 ({server_type}){existsinfo.server} 发生了错误, 连接Items出错:" + str(e)) if not item_id: return None # 验证tmdbid是否相同 - item_info = self.emby.get_iteminfo(item_id) + item_info = instance.get_iteminfo(item_id) if item_info: if mediainfo.tmdb_id and item_info.tmdbid: if str(mediainfo.tmdb_id) != str(item_info.tmdbid): self.log_error(f"tmdbid不匹配或不存在") return None try: - res_json = self.emby.get_data( + res_json = instance.get_data( "[HOST]emby/Shows/%s/Episodes?Season=&IsMissing=false&api_key=[APIKEY]" % item_id) if res_json: tv_item = res_json.json() @@ -837,17 +873,21 @@ class EpisodeGroupMeta(_PluginBase): return ExistMediaInfo( itemid=item_id, groupep=group_ep, - groupid=group_id + groupid=group_id, + type=existsinfo.type, + server_type=server_type, + server=existsinfo.server, ) except Exception as e: - self.log_error(f"连接Shows/Id/Episodes出错:{str(e)}") + self.log_error(f"媒体服务器 ({server_type}){existsinfo.server} 发生了错误, 连接Shows/Id/Episodes出错:{str(e)}") return None def __jellyfin_media_exists(): # 获取系列id item_id = None try: - res = self.jellyfin.get_data(url=f"[HOST]Users/[USER]/Items?api_key=[APIKEY]" + instance = mediaserver_instance or self.jellyfin + res = instance.get_data(url=f"[HOST]Users/[USER]/Items?api_key=[APIKEY]" f"&searchTerm={mediainfo.title}" f"&IncludeItemTypes=Series" f"&Limit=10&Recursive=true") @@ -858,18 +898,18 @@ class EpisodeGroupMeta(_PluginBase): not mediainfo.year or str(res_item.get('ProductionYear')) == str(mediainfo.year)): item_id = res_item.get('Id') except Exception as e: - self.log_error(f"连接Items出错:" + str(e)) + self.log_error(f"媒体服务器 ({server_type}){existsinfo.server} 发生了错误, 连接Items出错:" + str(e)) if not item_id: return None # 验证tmdbid是否相同 - item_info = self.jellyfin.get_iteminfo(item_id) + item_info = instance.get_iteminfo(item_id) if item_info: if mediainfo.tmdb_id and item_info.tmdbid: if str(mediainfo.tmdb_id) != str(item_info.tmdbid): self.log_error(f"tmdbid不匹配或不存在") return None try: - res_json = self.jellyfin.get_data( + res_json = instance.get_data( "[HOST]emby/Shows/%s/Episodes?Season=&IsMissing=false&api_key=[APIKEY]" % item_id) if res_json: tv_item = res_json.json() @@ -897,15 +937,19 @@ class EpisodeGroupMeta(_PluginBase): return ExistMediaInfo( itemid=item_id, groupep=group_ep, - groupid=group_id + groupid=group_id, + type=existsinfo.type, + server_type=server_type, + server=existsinfo.server, ) except Exception as e: - self.log_error(f"连接Shows/Id/Episodes出错:{str(e)}") + self.log_error(f"媒体服务器 ({server_type}){existsinfo.server} 发生了错误, 连接Shows/Id/Episodes出错:{str(e)}") return None def __plex_media_exists(): try: - _plex = self.plex.get_plex() + instance = mediaserver_instance or self.plex.get_plex() + _plex = instance.get_plex() if not _plex: return None if existsinfo.itemid: @@ -957,10 +1001,13 @@ class EpisodeGroupMeta(_PluginBase): return ExistMediaInfo( itemid=videos.key, groupep=group_ep, - groupid=group_id + groupid=group_id, + type=existsinfo.type, + server_type=server_type, + server=existsinfo.server, ) except Exception as e: - self.log_error(f"连接Shows/Id/Episodes出错:{str(e)}") + self.log_error(f"媒体服务器 ({server_type}){existsinfo.server} 发生了错误, 连接Shows/Id/Episodes出错:{str(e)}") return None def __get_ids(guids: List[Any]) -> dict: @@ -986,14 +1033,14 @@ class EpisodeGroupMeta(_PluginBase): break return ids - if server == "emby": + if server_type == "emby": return __emby_media_exists() - elif server == "jellyfin": + elif server_type == "jellyfin": return __jellyfin_media_exists() else: return __plex_media_exists() - def get_iteminfo(self, server: str, itemid: str) -> dict: + def get_iteminfo(self, server_type: str, itemid: str, mediaserver_instance: Any = None) -> dict: """ 获得媒体项详情 """ @@ -1003,9 +1050,10 @@ class EpisodeGroupMeta(_PluginBase): 获得Emby媒体项详情 """ try: + instance = mediaserver_instance or self.emby url = f'[HOST]emby/Users/[USER]/Items/{itemid}?' \ f'Fields=ChannelMappingInfo&api_key=[APIKEY]' - res = self.emby.get_data(url=url) + res = instance.get_data(url=url) if res: return res.json() except Exception as err: @@ -1017,8 +1065,9 @@ class EpisodeGroupMeta(_PluginBase): 获得Jellyfin媒体项详情 """ try: + instance = mediaserver_instance or self.jellyfin url = f'[HOST]Users/[USER]/Items/{itemid}?Fields=ChannelMappingInfo&api_key=[APIKEY]' - res = self.jellyfin.get_data(url=url) + res = instance.jellyfin.get_data(url=url) if res: result = res.json() if result: @@ -1034,7 +1083,8 @@ class EpisodeGroupMeta(_PluginBase): """ iteminfo = {} try: - plexitem = self.plex.get_plex().library.fetchItem(ekey=itemid) + instance = mediaserver_instance or self.plex + plexitem = instance.get_plex().library.fetchItem(ekey=itemid) if 'movie' in plexitem.METADATA_TYPE: iteminfo['Type'] = 'Movie' iteminfo['IsFolder'] = False @@ -1063,27 +1113,27 @@ class EpisodeGroupMeta(_PluginBase): if plexitem.title.locked: iteminfo['LockedFields'].append('Name') except Exception as err: - logger.warn(f"获取Plex媒体项详情失败:{str(err)}") + self.log_warn(f"获取Plex媒体项详情失败:{str(err)}") pass try: if plexitem.summary.locked: iteminfo['LockedFields'].append('Overview') except Exception as err: - logger.warn(f"获取Plex媒体项详情失败:{str(err)}") + self.log_warn(f"获取Plex媒体项详情失败:{str(err)}") pass return iteminfo except Exception as err: self.log_error(f"获取Plex媒体项详情失败:{str(err)}") return {} - if server == "emby": + if server_type == "emby": return __get_emby_iteminfo() - elif server == "jellyfin": + elif server_type == "jellyfin": return __get_jellyfin_iteminfo() else: return __get_plex_iteminfo() - def set_iteminfo(self, server: str, itemid: str, iteminfo: dict): + def set_iteminfo(self, server_type: str, itemid: str, iteminfo: dict, mediaserver_instance: Any = None): """ 更新媒体项详情 """ @@ -1093,7 +1143,8 @@ class EpisodeGroupMeta(_PluginBase): 更新Emby媒体项详情 """ try: - res = self.emby.post_data( + instance = mediaserver_instance or self.emby + res = instance.post_data( url=f'[HOST]emby/Items/{itemid}?api_key=[APIKEY]&reqformat=json', data=json.dumps(iteminfo), headers={ @@ -1114,7 +1165,8 @@ class EpisodeGroupMeta(_PluginBase): 更新Jellyfin媒体项详情 """ try: - res = self.jellyfin.post_data( + instance = mediaserver_instance or self.jellyfin + res = instance.post_data( url=f'[HOST]Items/{itemid}?api_key=[APIKEY]', data=json.dumps(iteminfo), headers={ @@ -1135,7 +1187,8 @@ class EpisodeGroupMeta(_PluginBase): 更新Plex媒体项详情 """ try: - plexitem = self.plex.get_plex().library.fetchItem(ekey=itemid) + instance = mediaserver_instance or self.plex + plexitem = instance.get_plex().library.fetchItem(ekey=itemid) if 'CommunityRating' in iteminfo and iteminfo['CommunityRating']: edits = { 'audienceRating.value': iteminfo['CommunityRating'], @@ -1148,15 +1201,15 @@ class EpisodeGroupMeta(_PluginBase): self.log_error(f"更新Plex媒体项详情失败:{str(err)}") return False - if server == "emby": + if server_type == "emby": return __set_emby_iteminfo() - elif server == "jellyfin": + elif server_type == "jellyfin": return __set_jellyfin_iteminfo() else: return __set_plex_iteminfo() @retry(RequestException, logger=logger) - def set_item_image(self, server: str, itemid: str, imageurl: str): + def set_item_image(self, server_type: str, itemid: str, imageurl: str, mediaserver_instance: Any = None): """ 更新媒体项图片 """ @@ -1185,8 +1238,9 @@ class EpisodeGroupMeta(_PluginBase): 更新Emby媒体项图片 """ try: + instance = mediaserver_instance or self.emby url = f'[HOST]emby/Items/{itemid}/Images/Primary?api_key=[APIKEY]' - res = self.emby.post_data( + res = instance.post_data( url=url, data=_base64, headers={ @@ -1208,9 +1262,10 @@ class EpisodeGroupMeta(_PluginBase): # FIXME 改为预下载图片 """ try: + instance = mediaserver_instance or self.jellyfin url = f'[HOST]Items/{itemid}/RemoteImages/Download?' \ f'Type=Primary&ImageUrl={imageurl}&ProviderName=TheMovieDb&api_key=[APIKEY]' - res = self.jellyfin.post_data(url=url) + res = instance.post_data(url=url) if res and res.status_code in [200, 204]: return True else: @@ -1226,24 +1281,66 @@ class EpisodeGroupMeta(_PluginBase): # FIXME 改为预下载图片 """ try: - plexitem = self.plex.get_plex().library.fetchItem(ekey=itemid) + instance = mediaserver_instance or self.plex + plexitem = instance.get_plex().library.fetchItem(ekey=itemid) plexitem.uploadPoster(url=imageurl) return True except Exception as err: self.log_error(f"更新Plex媒体项图片失败:{err}") return False - if server == "emby": + if server_type == "emby": # 下载图片获取base64 image_base64 = __download_image() if image_base64: return __set_emby_item_image(image_base64) - elif server == "jellyfin": + elif server_type == "jellyfin": return __set_jellyfin_item_image() else: return __set_plex_item_image() return None + def service_infos(self, type_filter: Optional[str] = None): + """ + 服务信息 + """ + if self.mediaserver_helper == None: + # 动态载入媒体服务器帮助类 + module_name = "app.helper.mediaserver" + spec = importlib.util.find_spec(module_name) + if spec is not None: + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + if hasattr(module, 'MediaServerHelper'): + self.log_info(f"v2版本初始化媒体库类") + self.mediaserver_helper = module.MediaServerHelper() + if self.mediaserver_helper == None: + if self.emby == None: + self.log_info(f"v1版本初始化媒体库类") + self.emby = Emby() + self.plex = Plex() + self.jellyfin = Jellyfin() + return None + + services = self.mediaserver_helper.get_services(type_filter=type_filter)#, name_filters=self._mediaservers) + if not services: + self.log_warn("获取媒体服务器实例失败,请检查配置") + return None + + active_services = {} + for service_name, service_info in services.items(): + if service_info.instance.is_inactive(): + self.log_warn(f"媒体服务器 {service_name} 未连接,请检查配置") + else: + active_services[service_name] = service_info + + if not active_services: + self.log_warn("没有已连接的媒体服务器,请检查配置") + return None + + return active_services + def log_error(self, ss: str): logger.error(f"<{self.plugin_name}> {str(ss)}") From 765008fc692338928791ade8a8ab900076f83204 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Thu, 14 Nov 2024 22:43:38 +0800 Subject: [PATCH 169/218] fix #552 --- package.v2.json | 4 ++-- plugins.v2/torrentremover/__init__.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.v2.json b/package.v2.json index 3a90b35..353b7a4 100644 --- a/package.v2.json +++ b/package.v2.json @@ -182,12 +182,12 @@ "name": "自动删种", "description": "自动删除下载器中的下载任务。", "labels": "做种", - "version": "2.1", + "version": "2.1.1", "icon": "delete.jpg", "author": "jxxghp", "level": 2, "history": { - "v2.1": "修复兼容MoviePilot V2 版本", + "v2.1.1": "修复兼容MoviePilot V2 版本", "v2.0": "兼容MoviePilot V2 版本" } }, diff --git a/plugins.v2/torrentremover/__init__.py b/plugins.v2/torrentremover/__init__.py index 6aa67ee..f3ab683 100644 --- a/plugins.v2/torrentremover/__init__.py +++ b/plugins.v2/torrentremover/__init__.py @@ -26,7 +26,7 @@ class TorrentRemover(_PluginBase): # 插件图标 plugin_icon = "delete.jpg" # 插件版本 - plugin_version = "2.1" + plugin_version = "2.1.1" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -814,7 +814,7 @@ class TorrentRemover(_PluginBase): name = remove_torrent.get("name") size = remove_torrent.get("size") for torrent in torrents: - if downloader == "qbittorrent": + if downloader_config.type == "qbittorrent": plus_id = torrent.hash plus_name = torrent.name plus_size = torrent.size From 1127d88f3b7cc4cb18cc6f2a7a983d15800c7e21 Mon Sep 17 00:00:00 2001 From: ramen <1205925392@qq.com> Date: Fri, 15 Nov 2024 00:33:16 +0800 Subject: [PATCH 170/218] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8Dv2=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E9=80=9A=E7=9F=A5=EF=BC=8C=E5=8F=AF=E4=BB=A5=E6=8C=87?= =?UTF-8?q?=E5=AE=9A=E5=BE=AE=E4=BF=A1=E9=80=9A=E7=9F=A5ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 5 +- plugins/dynamicwechat/__init__.py | 45 +++++++++------- plugins/dynamicwechat/notify_helper.py | 75 +++++++++++++++----------- 3 files changed, 75 insertions(+), 50 deletions(-) diff --git a/package.json b/package.json index f73f643..764f2bb 100644 --- a/package.json +++ b/package.json @@ -861,14 +861,15 @@ }, "DynamicWeChat": { "name": "动态企微可信IP", - "description": "修改企微应用可信IP,详细说明查看'作者主页',支持第三方通知。验证码以?结尾发送到企业微信应用", + "description": "修改企微应用可信IP,支持Srever酱等第三方通知。验证码以?结尾发送到企业微信应用", "labels": "消息通知", - "version": "1.5.0", + "version": "1.5.1", "icon": "Wecom_A.png", "author": "RamenRa", "level": 2, "v2": true, "history": { + "v1.5.1": "修复v2微信通知,可以指定微信通知ID", "v1.5.0": "支持企微应用通知和第Serve酱等第三方推送。按要求修改插件名称", "v1.4.1": "完善面板说明", "v1.4.0": "修复强制更改IP时配置面板延时过长的问题。庆祝v2进入正式版,显示了一个没用的参数", diff --git a/plugins/dynamicwechat/__init__.py b/plugins/dynamicwechat/__init__.py index 0b1942a..aa16cfc 100644 --- a/plugins/dynamicwechat/__init__.py +++ b/plugins/dynamicwechat/__init__.py @@ -20,7 +20,7 @@ from app.log import logger from app.plugins import _PluginBase from app.plugins.dynamicwechat.update_help import PyCookieCloud from app.plugins.dynamicwechat.notify_helper import MySender -from app.schemas.types import EventType, NotificationType +from app.schemas.types import EventType class DynamicWeChat(_PluginBase): @@ -31,7 +31,7 @@ class DynamicWeChat(_PluginBase): # 插件图标 plugin_icon = "Wecom_A.png" # 插件版本 - plugin_version = "1.5.0" + plugin_version = "1.5.1" # 插件作者 plugin_author = "RamenRa" # 作者主页 @@ -42,6 +42,8 @@ class DynamicWeChat(_PluginBase): plugin_order = 47 # 可使用的用户级别 auth_level = 2 + # 检测间隔时间,默认10分钟 + _refresh_cron = '*/20 * * * *' # ------------------------------------------私有属性------------------------------------------ _enabled = False # 开关 @@ -70,8 +72,7 @@ class DynamicWeChat(_PluginBase): _current_ip_address = '0.0.0.0' # 企业微信登录 _wechatUrl = 'https://work.weixin.qq.com/wework_admin/loginpage_wx?from=myhome' - # 检测间隔时间,默认10分钟 - _refresh_cron = '*/20 * * * *' + # 输入的企业应用id _input_id_list = '' # 二维码 @@ -99,6 +100,10 @@ class DynamicWeChat(_PluginBase): # 定时器 _scheduler: Optional[BackgroundScheduler] = None + if hasattr(settings, 'VERSION_FLAG'): + version = settings.VERSION_FLAG # V2 + else: + version = "v1" def init_plugin(self, config: dict = None): # 清空配置 self._notification_token = '' @@ -123,7 +128,10 @@ class DynamicWeChat(_PluginBase): self._use_cookiecloud = config.get("use_cookiecloud") self._cookie_header = config.get("cookie_header") self._ip_changed = config.get("ip_changed") - self._my_send = MySender(self._notification_token) + if self.version != "v1": + self._my_send = MySender(self._notification_token, func=self.post_message) + else: + self._my_send = MySender(self._notification_token) if not self._my_send.init_success: # 没有输入通知方式,不通知 self._my_send = None # 停止现有任务 @@ -258,12 +266,14 @@ class DynamicWeChat(_PluginBase): if not event_data or event_data.get("action") != "dynamicwechat": return - logger.info("开始检测公网IP") - if self.CheckIP(): - self.ChangeIP() - self.__update_config() - - logger.info("----------------------本次任务结束----------------------") + if self._cookie_valid: + logger.info("开始检测公网IP") + if self.CheckIP(): + self.ChangeIP() + self.__update_config() + logger.info("----------------------本次任务结束----------------------") + else: + logger.warning("cookie已失效请及时更新,不检测公网IP") def CheckIP(self): retry_urls = random.sample(self._ip_urls, len(self._ip_urls)) @@ -381,7 +391,7 @@ class DynamicWeChat(_PluginBase): img_src, refuse_time = self.find_qrc(page) if img_src: if self._my_send: - result = self._my_send.send("企业微信登录二维码", content=None, image=img_src, force_send=False) + result = self._my_send.send(title="企业微信登录二维码", image=img_src) if result: logger.info(f"二维码发送失败,原因:{result}") browser.close() @@ -815,7 +825,7 @@ class DynamicWeChat(_PluginBase): 'props': { 'type': 'info', 'variant': 'tonal', - 'text': '建议启用内建或自定义CookieCloud。支持微信、Server酱、PushPlus、AnPush,具体请查看作者主页' + 'text': '建议启用内建或自定义CookieCloud。支持微信、Server酱等第三方通知,具体请查看作者主页' } } ] @@ -835,7 +845,7 @@ class DynamicWeChat(_PluginBase): 'component': 'VAlert', 'props': { 'type': 'info', - 'text': 'cookie失效时通知用户,用户使用/push_qr让插件推送二维码。使用第三方通知时填写对应Token/API' + 'text': 'Cookie失效时通知用户,用户使用/push_qr让插件推送二维码。使用第三方通知时填写对应Token/API' } } ] @@ -1004,8 +1014,7 @@ class DynamicWeChat(_PluginBase): image_src, refuse_time = self.find_qrc(page) if image_src: if self._my_send: - # logger.info(f"远程推送任务: {image_src}") - result = self._my_send.send("企业微信登录二维码", content=None, image=image_src, force_send=False) + result = self._my_send.send("企业微信登录二维码", image=image_src) if result: logger.info(f"远程推送任务: 二维码发送失败,原因:{result}") browser.close() @@ -1019,7 +1028,7 @@ class DynamicWeChat(_PluginBase): # logger.info("远程推送任务: 没有可用的CookieCloud服务器,只修改可信IP") self.click_app_management_buttons(page) else: - logger.warning("远程推送任务: 任何通知方式") + logger.warning("远程推送任务: 没有找到可用的通知方式") else: logger.warning("远程推送任务: 未找到二维码") browser.close() @@ -1032,7 +1041,7 @@ class DynamicWeChat(_PluginBase): { "cmd": "/push_qr", "event": EventType.PluginAction, - "desc": "立即推送登录二维码到", + "desc": "立即推送登录二维码", "category": "", "data": { "action": "push_qrcode" diff --git a/plugins/dynamicwechat/notify_helper.py b/plugins/dynamicwechat/notify_helper.py index 92c2e35..6c70a5d 100644 --- a/plugins/dynamicwechat/notify_helper.py +++ b/plugins/dynamicwechat/notify_helper.py @@ -1,48 +1,44 @@ import re import requests -from app.modules.wechat.wechat import WeChat +from app.modules.wechat import WeChat +from app.schemas.types import NotificationType class MySender: - def __init__(self, token=None): - if not token: # 如果 token 为空 - self.token = None - self.channel = None - self.init_success = False # 标识初始化失败 - else: - self.token = token - self.channel = self.send_channel() # 初始化时确定发送渠道 - self.first_text_sent = False # 记录是否已经发送过纯文本消息 - self.init_success = True # 标识初始化成功 + def __init__(self, token=None, func=None): + self.token = token + self.channel = self.send_channel() if token else None # 初始化时确定发送渠道 + self.first_text_sent = False # 记录是否已发送过纯文本消息 + self.init_success = bool(token) # 标识初始化成功 + self.post_message_func = func # V2微信模式的 post_message 方法 def send_channel(self): - if self.token: - if self.token == "WeChat": - return "WeChat" + if "WeChat" in self.token: + return "WeChat" - letters_only = ''.join(re.findall(r'[A-Za-z]', self.token)) - # 判断其他推送渠道 - if self.token.startswith("SCT"): - return "ServerChan" - elif letters_only.isupper(): - return "AnPush" - else: - return "PushPlus" - return None + letters_only = ''.join(re.findall(r'[A-Za-z]', self.token)) + if self.token.lower().startswith("sct".lower()): + return "ServerChan" + elif letters_only.isupper(): + return "AnPush" + else: + return "PushPlus" # 标题,内容,图片,是否强制发送 - def send(self, title, content, image=None, force_send=False, diy_channel=None): + def send(self, title, content=None, image=None, force_send=False, diy_channel=None): if not self.init_success: return # 如果初始化失败,直接返回 - # 判断发送的内容类型 - contains_image = bool(image) # 是否包含图片 - if not contains_image and not force_send: + if not image and not force_send: if self.first_text_sent: return else: self.first_text_sent = True + # # 如果是 V2 微信通知直接处理 + if self.channel == "WeChat" and self.post_message_func: + return self.send_v2_wechat(title, content, image) + try: if not diy_channel: channel = self.channel @@ -50,7 +46,7 @@ class MySender: channel = diy_channel if channel == "WeChat": - return MySender.send_wechat(title, content, image) + return MySender.send_wechat(title, content, image, self.token) elif channel == "ServerChan": return self.send_serverchan(title, content, image) elif channel == "AnPush": @@ -63,10 +59,14 @@ class MySender: return f"Error occurred: {str(e)}" @staticmethod - def send_wechat(title, content, image): + def send_wechat(title, content, image, token): wechat = WeChat() + if ',' in token: + channel, actual_userid = token.split(',', 1) + else: + actual_userid = None if image: - send_status = wechat.send_msg(title='企业微信登录二维码', image=image, link=image) + send_status = wechat.send_msg(title='企业微信登录二维码', image=image, link=image, userid=actual_userid) else: send_status = wechat.send_msg(title=title, text=content) @@ -128,6 +128,21 @@ class MySender: return f"PushPlus send failed: {result.get('msg')}" return None + def send_v2_wechat(self, title, content, image): + """V2 微信通知发送""" + if not self.token or ',' not in self.token: + return '没有指定V2微信用户ID' + channel, actual_userid = self.token.split(',', 1) + self.post_message_func( + mtype=NotificationType.Plugin, + title=title, + text=content, + image=image, + link=image, + userid=actual_userid + ) + return None + def reset_limit(self): """解除限制,允许再次发送纯文本消息""" self.first_text_sent = False From 80d2d31088f1dc4efc93ff75c2c19b8f1430ed46 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 15 Nov 2024 08:11:57 +0800 Subject: [PATCH 171/218] =?UTF-8?q?IYUU=E6=B6=88=E6=81=AF=E6=8E=A8?= =?UTF-8?q?=E9=80=81=20=E5=A2=9E=E5=8A=A0=20=E9=99=90=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 5 +- package.v2.json | 12 + plugins.v2/synccookiecloud/__init__.py | 300 +++++++++++++++++++++++++ plugins/synccookiecloud/__init__.py | 2 +- 4 files changed, 315 insertions(+), 4 deletions(-) create mode 100644 plugins.v2/synccookiecloud/__init__.py diff --git a/package.json b/package.json index 1fd28e0..34881dd 100644 --- a/package.json +++ b/package.json @@ -878,13 +878,12 @@ "name": "同步CookieCloud", "description": "同步MoviePilot站点Cookie到本地CookieCloud。", "labels": "站点", - "version": "2.0", + "version": "1.4", "icon": "Cookiecloud_A.png", "author": "thsrite", "level": 1, - "v2": true, "history": { - "v2.0": "调整逻辑,修复问题", + "v1.4": "调整逻辑,修复问题", "v1.3": "感谢MidnightShake共享代码(同步时保留MoviePilot不匹配站点的cookie)", "v1.2": "同步到本地CookieCloud", "v1.1": "修复CookieCloud覆盖到浏览器", diff --git a/package.v2.json b/package.v2.json index 3a90b35..5c2da96 100644 --- a/package.v2.json +++ b/package.v2.json @@ -212,5 +212,17 @@ "icon": "Moviepilot_A.png", "author": "jxxghp", "level": 1 + }, + "SyncCookieCloud": { + "name": "同步CookieCloud", + "description": "同步MoviePilot站点Cookie到本地CookieCloud。", + "labels": "站点", + "version": "2.1", + "icon": "Cookiecloud_A.png", + "author": "thsrite", + "level": 1, + "history": { + "v2.1": "兼容MoviePilot V2" + } } } \ No newline at end of file diff --git a/plugins.v2/synccookiecloud/__init__.py b/plugins.v2/synccookiecloud/__init__.py new file mode 100644 index 0000000..6fcd4c4 --- /dev/null +++ b/plugins.v2/synccookiecloud/__init__.py @@ -0,0 +1,300 @@ +import json +from datetime import datetime, timedelta +from hashlib import md5 +from urllib.parse import urlparse + +import pytz + +from app.core.config import settings +from app.db.site_oper import SiteOper +from app.plugins import _PluginBase +from typing import Any, List, Dict, Tuple, Optional +from app.log import logger +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from app.utils.crypto import CryptoJsUtils + + +class SyncCookieCloud(_PluginBase): + # 插件名称 + plugin_name = "同步CookieCloud" + # 插件描述 + plugin_desc = "同步MoviePilot站点Cookie到本地CookieCloud。" + # 插件图标 + plugin_icon = "Cookiecloud_A.png" + # 插件版本 + plugin_version = "2.1" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "synccookiecloud_" + # 加载顺序 + plugin_order = 28 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled: bool = False + _onlyonce: bool = False + _cron: str = "" + siteoper = None + _scheduler: Optional[BackgroundScheduler] = None + + def init_plugin(self, config: dict = None): + self.siteoper = SiteOper() + + # 停止现有任务 + self.stop_service() + + if config: + self._enabled = config.get("enabled") + self._onlyonce = config.get("onlyonce") + self._cron = config.get("cron") + + if self._enabled or self._onlyonce: + # 定时服务 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + # 立即运行一次 + if self._onlyonce: + logger.info(f"同步CookieCloud服务启动,立即运行一次") + self._scheduler.add_job(self.__sync_to_cookiecloud, 'date', + run_date=datetime.now( + tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="同步CookieCloud") + # 关闭一次性开关 + self._onlyonce = False + + # 保存配置 + self.__update_config() + + # 周期运行 + if self._cron: + try: + self._scheduler.add_job(func=self.__sync_to_cookiecloud, + trigger=CronTrigger.from_crontab(self._cron), + name="同步CookieCloud") + except Exception as err: + logger.error(f"定时任务配置错误:{err}") + # 推送实时消息 + self.systemmessage.put(f"执行周期配置错误:{err}") + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def __sync_to_cookiecloud(self): + """ + 同步站点cookie到cookiecloud + """ + # 获取所有站点 + sites = self.siteoper.list_order_by_pri() + if not sites: + return + + if not settings.COOKIECLOUD_ENABLE_LOCAL: + logger.error('本地CookieCloud服务器未启用') + return + + cookies = {} + for site in sites: + domain = urlparse(site.url).netloc + cookie = site.cookie + + if not cookie: + logger.error(f"站点 {domain} 无cookie,跳过处理...") + continue + + # 解析cookie + site_cookies = [] + for ck in cookie.split(";"): + kv = ck.split("=") + if len(kv) < 2: + continue + site_cookies.append({ + "domain": domain, + "name": ck.split("=")[0], + "value": ck.split("=")[1] + }) + # 存储cookies + cookies[domain] = site_cookies + if cookies: + crypt_key = self._get_crypt_key() + try: + cookies = {'cookie_data': cookies} + encrypted_data = CryptoJsUtils.encrypt(json.dumps(cookies).encode('utf-8'), crypt_key).decode('utf-8') + except Exception as e: + logger.error(f"CookieCloud加密失败,{e}") + return + ck = {'encrypted': encrypted_data} + cookie_path = settings.COOKIE_PATH / f"{settings.COOKIECLOUD_KEY}.json" + cookie_path.write_bytes(json.dumps(ck).encode('utf-8')) + logger.info(f"同步站点cookie到本地CookieCloud成功") + else: + logger.error(f"同步站点cookie到本地CookieCloud失败,未获取到站点cookie") + + def __decrypted(self, encrypt_data: dict): + """ + 获取并解密本地CookieCloud数据 + """ + encrypted = encrypt_data.get("encrypted") + if not encrypted: + return {}, "未获取到cookie密文" + else: + crypt_key = self._get_crypt_key() + try: + decrypted_data = CryptoJsUtils.decrypt(encrypted, crypt_key).decode('utf-8') + result = json.loads(decrypted_data) + except Exception as e: + return {}, "cookie解密失败:" + str(e) + + if not result: + return {}, "cookie解密为空" + + if result.get("cookie_data"): + contents = result.get("cookie_data") + else: + contents = result + return contents + + @staticmethod + def _get_crypt_key() -> bytes: + """ + 使用UUID和密码生成CookieCloud的加解密密钥 + """ + md5_generator = md5() + md5_generator.update( + (str(settings.COOKIECLOUD_KEY).strip() + '-' + str(settings.COOKIECLOUD_PASSWORD).strip()).encode('utf-8')) + return (md5_generator.hexdigest()[:16]).encode('utf-8') + + def __update_config(self): + self.update_config({ + "enabled": self._enabled, + "onlyonce": self._onlyonce, + "cron": self._cron + }) + + def get_state(self) -> bool: + return self._enabled + + @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、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '执行周期', + 'placeholder': '5位cron表达式,留空自动' + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '需要MoviePilot设定-站点启用本地CookieCloud服务器。' + } + } + ] + } + ] + }, + ] + } + ], { + "enabled": False, + "onlyonce": False, + "cron": "5 1 * * *", + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) diff --git a/plugins/synccookiecloud/__init__.py b/plugins/synccookiecloud/__init__.py index 4f29226..97696d5 100644 --- a/plugins/synccookiecloud/__init__.py +++ b/plugins/synccookiecloud/__init__.py @@ -23,7 +23,7 @@ class SyncCookieCloud(_PluginBase): # 插件图标 plugin_icon = "Cookiecloud_A.png" # 插件版本 - plugin_version = "2.0" + plugin_version = "1.4" # 插件作者 plugin_author = "thsrite" # 作者主页 From 1baefc6054472f3d006ee1b3d35a14288185dbcc Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 16 Nov 2024 19:48:10 +0800 Subject: [PATCH 172/218] fix HistoryToV2 --- package.v2.json | 7 +++- plugins.v2/historytov2/__init__.py | 61 +++++++++++++++--------------- 2 files changed, 36 insertions(+), 32 deletions(-) diff --git a/package.v2.json b/package.v2.json index b6c3b5e..297417e 100644 --- a/package.v2.json +++ b/package.v2.json @@ -208,10 +208,13 @@ "name": "历史记录迁移", "description": "将MoviePilot V1版本的整理历史记录迁移至V2版本。", "labels": "整理,历史记录", - "version": "1.0", + "version": "1.1", "icon": "Moviepilot_A.png", "author": "jxxghp", - "level": 1 + "level": 1, + "history": { + "v1.1": "修复启动提示信息" + } }, "SyncCookieCloud": { "name": "同步CookieCloud", diff --git a/plugins.v2/historytov2/__init__.py b/plugins.v2/historytov2/__init__.py index 6bc2144..e163abe 100644 --- a/plugins.v2/historytov2/__init__.py +++ b/plugins.v2/historytov2/__init__.py @@ -17,7 +17,7 @@ class HistoryToV2(_PluginBase): # 插件图标 plugin_icon = "Moviepilot_A.png" # 插件版本 - plugin_version = "1.0" + plugin_version = "1.1" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -43,35 +43,36 @@ class HistoryToV2(_PluginBase): self._username = config.get("username") self._password = config.get("password") - if self._enabled and self._host and self._username and self._password: - # 关闭开关 - self.__close_config() - # 登录MP获取token - token = self.__login_mp() - if token: - # 当前页码 - page = 1 - # 总记录数 - total = 0 - # 获取历史记录 - history = self.__get_history(token) - while history: - # 处理历史记录 - logger.info(f"开始处理第 {page} 页历史记录 ...") - self.__insert_history(history) - # 处理成功一批 - total += len(history) - logger.info(f"第 {page} 页处理完成,共处理 {total} 条记录") - # 获取下一页历史记录 - page += 1 - history = self.__get_history(token, page=page) - # 处理完成 - logger.info(f"历史记录迁移完成,共迁移 {total} 条记录!") - self.systemmessage.put(f"历史记录迁移完成,共迁移 {total} 条记录!", title="MoviePilot历史记录迁移") - else: - self.systemmessage.put(f"配置不完整,服务启动失败!", title="MoviePilot历史记录迁移") - # 关闭开关 - self.__close_config() + if self._enabled: + if self._host and self._username and self._password: + # 关闭开关 + self.__close_config() + # 登录MP获取token + token = self.__login_mp() + if token: + # 当前页码 + page = 1 + # 总记录数 + total = 0 + # 获取历史记录 + history = self.__get_history(token) + while history: + # 处理历史记录 + logger.info(f"开始处理第 {page} 页历史记录 ...") + self.__insert_history(history) + # 处理成功一批 + total += len(history) + logger.info(f"第 {page} 页处理完成,共处理 {total} 条记录") + # 获取下一页历史记录 + page += 1 + history = self.__get_history(token, page=page) + # 处理完成 + logger.info(f"历史记录迁移完成,共迁移 {total} 条记录!") + self.systemmessage.put(f"历史记录迁移完成,共迁移 {total} 条记录!", title="MoviePilot历史记录迁移") + else: + self.systemmessage.put(f"配置不完整,服务启动失败!", title="MoviePilot历史记录迁移") + # 关闭开关 + self.__close_config() def __close_config(self): """ From 811c6b817edd39ceb92e9e3d4fef20b86053c2f7 Mon Sep 17 00:00:00 2001 From: DzAvril Date: Sat, 16 Nov 2024 15:12:50 +0800 Subject: [PATCH 173/218] Add qbcommand v2.0 --- package.v2.json | 12 + plugins.v2/qbcommand/__init__.py | 1279 ++++++++++++++++++++++++++++++ 2 files changed, 1291 insertions(+) create mode 100644 plugins.v2/qbcommand/__init__.py diff --git a/package.v2.json b/package.v2.json index 0f5e317..4018fc4 100644 --- a/package.v2.json +++ b/package.v2.json @@ -218,5 +218,17 @@ "history": { "v2.0": "兼容MoviePilot V2 版本" } + }, + "QbCommand": { + "name": "QB远程操作", + "description": "通过定时任务或交互命令远程操作QB暂停/开始/限速等。", + "labels": "下载管理,Qbittorrent", + "version": "2.0", + "icon": "Qbittorrent_A.png", + "author": "DzAvril", + "level": 1, + "history": { + "v2.0": "适配MoviePilot V2 版本" + } } } \ No newline at end of file diff --git a/plugins.v2/qbcommand/__init__.py b/plugins.v2/qbcommand/__init__.py new file mode 100644 index 0000000..44799e9 --- /dev/null +++ b/plugins.v2/qbcommand/__init__.py @@ -0,0 +1,1279 @@ +from typing import List, Tuple, Dict, Any, Optional +from enum import Enum +from urllib.parse import urlparse +import urllib +from app.log import logger +from app.plugins import _PluginBase +from app.schemas import NotificationType, ServiceInfo +from app.schemas.types import EventType +from apscheduler.triggers.cron import CronTrigger +from app.core.event import eventmanager, Event +from apscheduler.schedulers.background import BackgroundScheduler +from app.core.config import settings +from app.helper.sites import SitesHelper +from app.db.site_oper import SiteOper +from app.utils.string import StringUtils +from app.helper.downloader import DownloaderHelper +from datetime import datetime, timedelta + +import pytz +import time + + +class QbCommand(_PluginBase): + # 插件名称 + plugin_name = "QB远程操作" + # 插件描述 + plugin_desc = "通过定时任务或交互命令远程操作QB暂停/开始/限速等" + # 插件图标 + plugin_icon = "Qbittorrent_A.png" + # 插件版本 + plugin_version = "2.0" + # 插件作者 + plugin_author = "DzAvril" + # 作者主页 + author_url = "https://github.com/DzAvril" + # 插件配置项ID前缀 + plugin_config_prefix = "qbcommand_" + # 加载顺序 + plugin_order = 1 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _sites = None + _siteoper = None + _qb = None + _enabled: bool = False + _notify: bool = False + _pause_cron = None + _resume_cron = None + _only_pause_once = False + _only_resume_once = False + _only_pause_upload = False + _only_pause_download = False + _only_pause_checking = False + _upload_limit = 0 + _enable_upload_limit = False + _download_limit = 0 + _enable_download_limit = False + _op_site_ids = [] + _op_sites = [] + _multi_level_root_domain = ["edu.cn", "com.cn", "net.cn", "org.cn"] + _scheduler = None + _exclude_dirs = "" + def init_plugin(self, config: dict = None): + self._sites = SitesHelper() + self._siteoper = SiteOper() + self.downloader_helper = DownloaderHelper() + # 停止现有任务 + self.stop_service() + # 读取配置 + if config: + self._enabled = config.get("enabled") + self._notify = config.get("notify") + self._pause_cron = config.get("pause_cron") + self._resume_cron = config.get("resume_cron") + self._only_pause_once = config.get("onlypauseonce") + self._only_resume_once = config.get("onlyresumeonce") + self._only_pause_upload = config.get("onlypauseupload") + self._only_pause_download = config.get("onlypausedownload") + self._only_pause_checking = config.get("onlypausechecking") + self._download_limit = config.get("download_limit") + self._upload_limit = config.get("upload_limit") + self._enable_download_limit = config.get("enable_download_limit") + self._enable_upload_limit = config.get("enable_upload_limit") + + self._op_site_ids = config.get("op_site_ids") or [] + self._downloaders = config.get("downloaders") + # 查询所有站点 + all_sites = [site for site in self._sites.get_indexers() if not site.get("public")] + self.__custom_sites() + # 过滤掉没有选中的站点 + self._op_sites = [site for site in all_sites if site.get("id") in self._op_site_ids] + self._exclude_dirs = config.get("exclude_dirs") or "" + + if self._only_pause_once or self._only_resume_once: + if self._only_pause_once and self._only_resume_once: + logger.warning("只能选择一个: 立即暂停或立即开始所有任务") + elif self._only_pause_once: + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + logger.info(f"立即运行一次暂停所有任务") + self._scheduler.add_job( + self.pause_torrent, + "date", + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + + timedelta(seconds=3), + ) + elif self._only_resume_once: + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + logger.info(f"立即运行一次开始所有任务") + self._scheduler.add_job( + self.resume_torrent, + "date", + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + + timedelta(seconds=3), + ) + + self._only_resume_once = False + self._only_pause_once = False + self.update_config( + { + "onlypauseonce": False, + "onlyresumeonce": False, + "enabled": self._enabled, + "notify": self._notify, + "downloaders": self._downloaders, + "pause_cron": self._pause_cron, + "resume_cron": self._resume_cron, + "op_site_ids": self._op_site_ids, + "exclude_dirs": self._exclude_dirs, + } + ) + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + if ( + self._only_pause_upload + or self._only_pause_download + or self._only_pause_checking + ): + if self._only_pause_upload: + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + logger.info(f"立即运行一次暂停所有上传任务") + self._scheduler.add_job( + self.pause_torrent, + "date", + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + + timedelta(seconds=3), + kwargs={ + 'type': self.TorrentType.UPLOADING + } + ) + if self._only_pause_download: + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + logger.info(f"立即运行一次暂停所有下载任务") + self._scheduler.add_job( + self.pause_torrent, + "date", + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + + timedelta(seconds=3), + kwargs={ + 'type': self.TorrentType.DOWNLOADING + } + ) + if self._only_pause_checking: + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + logger.info(f"立即运行一次暂停所有检查任务") + self._scheduler.add_job( + self.pause_torrent, + "date", + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + + timedelta(seconds=3), + kwargs={ + 'type': self.TorrentType.CHECKING + } + ) + + self._only_pause_upload = False + self._only_pause_download = False + self._only_pause_checking = False + self.update_config( + { + "onlypauseupload": False, + "onlypausedownload": False, + "onlypausechecking": False, + "enabled": self._enabled, + "notify": self._notify, + "pause_cron": self._pause_cron, + "resume_cron": self._resume_cron, + "op_site_ids": self._op_site_ids, + } + ) + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + self.set_limit(self._upload_limit, self._download_limit) + + @property + def service_info(self) -> Optional[ServiceInfo]: + """ + 服务信息 + """ + if not self._downloaders: + logger.warning("尚未配置下载器,请检查配置") + return None + + services = self.downloader_helper.get_services(name_filters=self._downloaders) + + if not services: + logger.warning("获取下载器实例失败,请检查配置") + return None + + active_services = {} + for service_name, service_info in services.items(): + if service_info.instance.is_inactive(): + logger.warning(f"下载器 {service_name} 未连接,请检查配置") + elif not self.check_is_qb(service_info): + logger.warning(f"不支持的下载器类型 {service_name},仅支持QB,请检查配置") + else: + active_services[service_name] = service_info + + if not active_services: + logger.warning("没有已连接的下载器,请检查配置") + return None + + return active_services + + def check_is_qb(self, service_info) -> bool: + """ + 检查下载器类型是否为 qbittorrent 或 transmission + """ + if self.downloader_helper.is_downloader(service_type="qbittorrent", service=service_info): + return True + elif self.downloader_helper.is_downloader(service_type="transmission", service=service_info): + return False + return False + def get_state(self) -> bool: + return self._enabled + + class TorrentType(Enum): + ALL = 1 + DOWNLOADING = 2 + UPLOADING = 3 + CHECKING = 4 + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + """ + 定义远程控制命令 + :return: 命令关键字、事件、描述、附带数据 + """ + return [ + { + "cmd": "/pause_torrents", + "event": EventType.PluginAction, + "desc": "暂停QB所有任务", + "category": "QB", + "data": {"action": "pause_torrents"}, + }, + { + "cmd": "/pause_upload_torrents", + "event": EventType.PluginAction, + "desc": "暂停QB上传任务", + "category": "QB", + "data": {"action": "pause_upload_torrents"}, + }, + { + "cmd": "/pause_download_torrents", + "event": EventType.PluginAction, + "desc": "暂停QB下载任务", + "category": "QB", + "data": {"action": "pause_download_torrents"}, + }, + { + "cmd": "/pause_checking_torrents", + "event": EventType.PluginAction, + "desc": "暂停QB检查任务", + "category": "QB", + "data": {"action": "pause_checking_torrents"}, + }, + { + "cmd": "/resume_torrents", + "event": EventType.PluginAction, + "desc": "开始QB所有任务", + "category": "QB", + "data": {"action": "resume_torrents"}, + }, + { + "cmd": "/qb_status", + "event": EventType.PluginAction, + "desc": "QB当前任务状态", + "category": "QB", + "data": {"action": "qb_status"}, + }, + { + "cmd": "/toggle_upload_limit", + "event": EventType.PluginAction, + "desc": "QB切换上传限速状态", + "category": "QB", + "data": {"action": "toggle_upload_limit"}, + }, + { + "cmd": "/toggle_download_limit", + "event": EventType.PluginAction, + "desc": "QB切换下载限速状态", + "category": "QB", + "data": {"action": "toggle_download_limit"}, + }, + ] + + def __custom_sites(self) -> List[Any]: + custom_sites = [] + custom_sites_config = self.get_config("CustomSites") + if custom_sites_config and custom_sites_config.get("enabled"): + custom_sites = custom_sites_config.get("sites") + return custom_sites + + 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 and self._pause_cron and self._resume_cron: + return [ + { + "id": "QbPause", + "name": "暂停QB所有任务", + "trigger": CronTrigger.from_crontab(self._pause_cron), + "func": self.pause_torrent, + "kwargs": {}, + }, + { + "id": "QbResume", + "name": "开始QB所有任务", + "trigger": CronTrigger.from_crontab(self._resume_cron), + "func": self.resume_torrent, + "kwargs": {}, + }, + ] + if self._enabled and self._pause_cron: + return [ + { + "id": "QbPause", + "name": "暂停QB所有任务", + "trigger": CronTrigger.from_crontab(self._pause_cron), + "func": self.pause_torrent, + "kwargs": {}, + } + ] + if self._enabled and self._resume_cron: + return [ + { + "id": "QbResume", + "name": "开始QB所有任务", + "trigger": CronTrigger.from_crontab(self._resume_cron), + "func": self.resume_torrent, + "kwargs": {}, + } + ] + return [] + + def get_all_torrents(self, service): + downloader_name = service.name + downloader_obj = service.instance + all_torrents, error = downloader_obj.get_torrents() + if error: + logger.error(f"获取下载器:{downloader_name}种子失败: {error}") + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【QB远程操作】", + text=f"获取下载器:{downloader_name}种子失败,请检查下载器配置", + ) + return [] + + if not all_torrents: + logger.warning(f"下载器:{downloader_name}没有种子") + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【QB远程操作】", + text=f"下载器:{downloader_name}中没有种子", + ) + return [] + return all_torrents + + @staticmethod + def get_torrents_status(torrents): + downloading_torrents = [] + uploading_torrents = [] + paused_torrents = [] + checking_torrents = [] + error_torrents = [] + for torrent in torrents: + if torrent.state_enum.is_uploading and not torrent.state_enum.is_paused: + uploading_torrents.append(torrent.get("hash")) + elif ( + torrent.state_enum.is_downloading + and not torrent.state_enum.is_paused + and not torrent.state_enum.is_checking + ): + downloading_torrents.append(torrent.get("hash")) + elif torrent.state_enum.is_checking: + checking_torrents.append(torrent.get("hash")) + elif torrent.state_enum.is_paused: + paused_torrents.append(torrent.get("hash")) + elif torrent.state_enum.is_errored: + error_torrents.append(torrent.get("hash")) + + return ( + downloading_torrents, + uploading_torrents, + paused_torrents, + checking_torrents, + error_torrents, + ) + + @eventmanager.register(EventType.PluginAction) + def handle_pause_torrent(self, event: Event): + if not self._enabled: + return + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "pause_torrents": + return + self.pause_torrent() + + @eventmanager.register(EventType.PluginAction) + def handle_pause_upload_torrent(self, event: Event): + if not self._enabled: + return + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "pause_upload_torrents": + return + self.pause_torrent(self.TorrentType.UPLOADING) + + @eventmanager.register(EventType.PluginAction) + def handle_pause_download_torrent(self, event: Event): + if not self._enabled: + return + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "pause_download_torrents": + return + self.pause_torrent(self.TorrentType.DOWNLOADING) + + @eventmanager.register(EventType.PluginAction) + def handle_pause_checking_torrent(self, event: Event): + if not self._enabled: + return + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "pause_checking_torrents": + return + self.pause_torrent(self.TorrentType.CHECKING) + + def pause_torrent(self, type: TorrentType = TorrentType.ALL): + if not self._enabled: + return + for service in self.service_info.values(): + downloader_name = service.name + downloader_obj = service.instance + if not downloader_obj: + logger.error(f"{self.LOG_TAG} 获取下载器失败 {downloader_name}") + continue + all_torrents = self.get_all_torrents(service) + hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( + self.get_torrents_status(all_torrents) + ) + + logger.info( + f"下载器{downloader_name}暂定任务启动 \n" + f"种子总数: {len(all_torrents)} \n" + f"做种数量: {len(hash_uploading)}\n" + f"下载数量: {len(hash_downloading)}\n" + f"检查数量: {len(hash_checking)}\n" + f"暂停数量: {len(hash_paused)}\n" + f"错误数量: {len(hash_error)}\n" + f"暂停操作中请稍等...\n", + ) + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【下载器{downloader_name}暂停任务启动】", + text=f"种子总数: {len(all_torrents)} \n" + f"做种数量: {len(hash_uploading)}\n" + f"下载数量: {len(hash_downloading)}\n" + f"检查数量: {len(hash_checking)}\n" + f"暂停数量: {len(hash_paused)}\n" + f"错误数量: {len(hash_error)}\n" + f"暂停操作中请稍等...\n", + ) + pause_torrents = self.filter_pause_torrents(all_torrents) + hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( + self.get_torrents_status(pause_torrents) + ) + if type == self.TorrentType.DOWNLOADING: + to_be_paused = hash_downloading + elif type == self.TorrentType.UPLOADING: + to_be_paused = hash_uploading + elif type == self.TorrentType.CHECKING: + to_be_paused = hash_checking + else: + to_be_paused = hash_downloading + hash_uploading + hash_checking + + if len(to_be_paused) > 0: + if downloader_obj.stop_torrents(ids=to_be_paused): + logger.info(f"暂停了{len(to_be_paused)}个种子") + else: + logger.error(f"下载器{downloader_name}暂停种子失败") + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【远程操作】", + text=f"下载器{downloader_name}暂停种子失败", + ) + # 每个种子等待1ms以让状态切换成功,至少等待1S + wait_time = 0.001 * len(to_be_paused) + 1 + time.sleep(wait_time) + + all_torrents = self.get_all_torrents(service) + hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( + self.get_torrents_status(all_torrents) + ) + logger.info( + f"下载器{downloader_name}暂定任务完成 \n" + f"种子总数: {len(all_torrents)} \n" + f"做种数量: {len(hash_uploading)}\n" + f"下载数量: {len(hash_downloading)}\n" + f"检查数量: {len(hash_checking)}\n" + f"暂停数量: {len(hash_paused)}\n" + f"错误数量: {len(hash_error)}\n" + ) + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【下载器{downloader_name}暂停任务完成】", + text=f"种子总数: {len(all_torrents)} \n" + f"做种数量: {len(hash_uploading)}\n" + f"下载数量: {len(hash_downloading)}\n" + f"检查数量: {len(hash_checking)}\n" + f"暂停数量: {len(hash_paused)}\n" + f"错误数量: {len(hash_error)}\n", + ) + + def __is_excluded(self, file_path) -> bool: + """ + 是否排除目录 + """ + for exclude_dir in self._exclude_dirs.split("\n"): + if exclude_dir and exclude_dir in str(file_path): + return True + return False + def filter_pause_torrents(self, all_torrents): + torrents = [] + for torrent in all_torrents: + if self.__is_excluded(torrent.get("content_path")): + continue + torrents.append(torrent) + return torrents + + @eventmanager.register(EventType.PluginAction) + def handle_resume_torrent(self, event: Event): + if not self._enabled: + return + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "resume_torrents": + return + self.resume_torrent() + + def resume_torrent(self): + if not self._enabled: + return + + for service in self.service_info.values(): + downloader_name = service.name + downloader_obj = service.instance + if not downloader_obj: + logger.error(f"{self.LOG_TAG} 获取下载器失败 {downloader_name}") + continue + all_torrents = self.get_all_torrents(service) + hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( + self.get_torrents_status(all_torrents) + ) + logger.info( + f"下载器{downloader_name}开始任务启动 \n" + f"种子总数: {len(all_torrents)} \n" + f"做种数量: {len(hash_uploading)}\n" + f"下载数量: {len(hash_downloading)}\n" + f"检查数量: {len(hash_checking)}\n" + f"暂停数量: {len(hash_paused)}\n" + f"错误数量: {len(hash_error)}\n" + f"开始操作中请稍等...\n", + ) + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【下载器{downloader_name}开始任务启动】", + text=f"种子总数: {len(all_torrents)} \n" + f"做种数量: {len(hash_uploading)}\n" + f"下载数量: {len(hash_downloading)}\n" + f"检查数量: {len(hash_checking)}\n" + f"暂停数量: {len(hash_paused)}\n" + f"错误数量: {len(hash_error)}\n" + f"开始操作中请稍等...\n", + ) + + resume_torrents = self.filter_resume_torrents(all_torrents) + hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( + self.get_torrents_status(resume_torrents) + ) + if not downloader_obj.start_torrents(ids=hash_paused): + logger.error(f"下载器{downloader_name}开始种子失败") + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【QB远程操作】", + text=f"下载器{downloader_name}开始种子失败", + ) + # 每个种子等待1ms以让状态切换成功,至少等待1S + wait_time = 0.001 * len(hash_paused) + 1 + time.sleep(wait_time) + + all_torrents = self.get_all_torrents(service) + hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( + self.get_torrents_status(all_torrents) + ) + logger.info( + f"下载器{downloader_name}开始任务完成 \n" + f"种子总数: {len(all_torrents)} \n" + f"做种数量: {len(hash_uploading)}\n" + f"下载数量: {len(hash_downloading)}\n" + f"检查数量: {len(hash_checking)}\n" + f"暂停数量: {len(hash_paused)}\n" + f"错误数量: {len(hash_error)}\n" + ) + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【下载器{downloader_name}开始任务完成】", + text=f"种子总数: {len(all_torrents)} \n" + f"做种数量: {len(hash_uploading)}\n" + f"下载数量: {len(hash_downloading)}\n" + f"检查数量: {len(hash_checking)}\n" + f"暂停数量: {len(hash_paused)}\n" + f"错误数量: {len(hash_error)}\n", + ) + + def filter_resume_torrents(self, all_torrents): + """ + 过滤掉不参与保种的种子 + """ + if len(self._op_sites) == 0: + return all_torrents + + urls = [site.get("url") for site in self._op_sites] + op_sites_main_domains = [] + for url in urls: + domain = StringUtils.get_url_netloc(url) + main_domain = self.get_main_domain(domain[1]) + op_sites_main_domains.append(main_domain) + + torrents = [] + for torrent in all_torrents: + if torrent.get("state") == "pausedUP": + tracker_url = self.get_torrent_tracker(torrent) + if not tracker_url: + logger.info(f"获取种子 {torrent.name} Tracker失败,不过滤该种子") + torrents.append(torrent) + _, tracker_domain = StringUtils.get_url_netloc(tracker_url) + if not tracker_domain: + logger.info(f"获取种子 {torrent.name} Tracker失败,不过滤该种子") + torrents.append(torrent) + tracker_main_domain = self.get_main_domain(domain=tracker_domain) + if tracker_main_domain in op_sites_main_domains: + logger.info( + f"种子 {torrent.name} 属于站点{tracker_main_domain},不执行操作" + ) + continue + + torrents.append(torrent) + return torrents + + @eventmanager.register(EventType.PluginAction) + def handle_qb_status(self, event: Event): + if not self._enabled: + return + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "qb_status": + return + self.qb_status() + + def qb_status(self): + if not self._enabled: + return + for service in self.service_info.values(): + downloader_name = service.name + downloader_obj = service.instance + if not downloader_obj: + logger.error(f"{self.LOG_TAG} 获取下载器失败 {downloader_name}") + continue + all_torrents = self.get_all_torrents(service) + hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( + self.get_torrents_status(all_torrents) + ) + logger.info( + f"下载器{downloader_name}任务状态 \n" + f"种子总数: {len(all_torrents)} \n" + f"做种数量: {len(hash_uploading)}\n" + f"下载数量: {len(hash_downloading)}\n" + f"检查数量: {len(hash_checking)}\n" + f"暂停数量: {len(hash_paused)}\n" + f"错误数量: {len(hash_error)}\n" + ) + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【下载器{downloader_name}任务状态】", + text=f"种子总数: {len(all_torrents)} \n" + f"做种数量: {len(hash_uploading)}\n" + f"下载数量: {len(hash_downloading)}\n" + f"检查数量: {len(hash_checking)}\n" + f"暂停数量: {len(hash_paused)}\n" + f"错误数量: {len(hash_error)}\n" + ) + + @eventmanager.register(EventType.PluginAction) + def handle_toggle_upload_limit(self, event: Event): + if not self._enabled: + return + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "toggle_upload_limit": + return + self.set_limit(self._upload_limit, self._download_limit) + + @eventmanager.register(EventType.PluginAction) + def handle_toggle_download_limit(self, event: Event): + if not self._enabled: + return + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "toggle_download_limit": + return + self.set_limit(self._upload_limit, self._download_limit) + + def set_both_limit(self, upload_limit, download_limit): + if not self._enable_upload_limit or not self._enable_upload_limit: + return True + + if ( + not upload_limit + or not upload_limit.isdigit() + or not download_limit + or not download_limit.isdigit() + ): + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【QB远程操作】", + text=f"设置QB限速失败,download_limit或upload_limit不是一个数值", + ) + return False + + flag = True + for service in self.service_info.values(): + downloader_name = service.name + downloader_obj = service.instance + if not downloader_obj: + logger.error(f"{self.LOG_TAG} 获取下载器失败 {downloader_name}") + continue + flag = flag and downloader_obj.set_speed_limit( + download_limit=int(download_limit), upload_limit=int(upload_limit) + ) + return flag + + def set_upload_limit(self, upload_limit): + if not self._enable_upload_limit: + return True + + if not upload_limit or not upload_limit.isdigit(): + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【QB远程操作】", + text=f"设置QB限速失败,upload_limit不是一个数值", + ) + return False + flag = True + for service in self.service_info.values(): + downloader_name = service.name + downloader_obj = service.instance + if not downloader_obj: + logger.error(f"{self.LOG_TAG} 获取下载器失败 {downloader_name}") + continue + download_limit_current_val, _ = downloader_obj.get_speed_limit() + flag = flag and downloader_obj.set_speed_limit( + download_limit=int(download_limit_current_val), + upload_limit=int(upload_limit), + ) + + def set_download_limit(self, download_limit): + if not self._enable_download_limit: + return True + + if not download_limit or not download_limit.isdigit(): + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【QB远程操作】", + text=f"设置QB限速失败,download_limit不是一个数值", + ) + return False + + flag = True + for service in self.service_info.values(): + downloader_name = service.name + downloader_obj = service.instance + if not downloader_obj: + logger.error(f"{self.LOG_TAG} 获取下载器失败 {downloader_name}") + continue + _, upload_limit_current_val = downloader_obj.get_speed_limit() + flag = flag and downloader_obj.set_speed_limit( + download_limit=int(download_limit), + upload_limit=int(upload_limit_current_val), + ) + return flag + + def set_limit(self, upload_limit, download_limit): + # 限速,满足以下三种情况设置限速 + # 1. 插件启用 && download_limit启用 + # 2. 插件启用 && upload_limit启用 + # 3. 插件启用 && download_limit启用 && upload_limit启用 + + flag = None + if self._enabled and self._enable_download_limit and self._enable_upload_limit: + flag = self.set_both_limit(upload_limit, download_limit) + + elif flag is None and self._enabled and self._enable_download_limit: + flag = self.set_download_limit(download_limit) + + elif flag is None and self._enabled and self._enable_upload_limit: + flag = self.set_upload_limit(upload_limit) + + if flag == True: + logger.info(f"设置QB限速成功") + if self._notify: + if upload_limit == 0: + text = f"上传无限速" + else: + text = f"上传限速:{upload_limit} KB/s" + if download_limit == 0: + text += f"\n下载无限速" + else: + text += f"\n下载限速:{download_limit} KB/s" + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【QB远程操作】", + text=text, + ) + elif flag == False: + logger.error(f"QB设置限速失败") + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【QB远程操作】", + text=f"设置QB限速失败", + ) + + def get_torrent_tracker(self, torrent): + """ + qb解析 tracker + :return: tracker url + """ + if not torrent: + return None + tracker = torrent.get("tracker") + if tracker and len(tracker) > 0: + return tracker + magnet_uri = torrent.get("magnet_uri") + if not magnet_uri or len(magnet_uri) <= 0: + return None + magnet_uri_obj = urlparse(magnet_uri) + query = urllib.parse.parse_qs(magnet_uri_obj.query) + tr = query["tr"] + if not tr or len(tr) <= 0: + return None + return tr[0] + + def get_main_domain(self, domain): + """ + 获取域名的主域名 + :param domain: 原域名 + :return: 主域名 + """ + if not domain: + return None + domain_arr = domain.split(".") + 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]}" + + def match_multi_level_root_domain(self, domain): + """ + 匹配多级根域名 + :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 get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + customSites = self.__custom_sites() + + site_options = [ + {"title": site.name, "value": site.id} + for site in self._siteoper.list_order_by_pri() + ] + [ + {"title": site.get("name"), "value": site.get("id")} for site in customSites + ] + return [ + { + "component": "VForm", + "content": [ + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "enabled", + "label": "启用插件", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "notify", + "label": "发送通知", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "onlypauseonce", + "label": "立即暂停所有任务", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "onlyresumeonce", + "label": "立即开始所有任务", + }, + } + ], + }, + ], + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': True, + 'chips': True, + 'clearable': True, + 'model': 'downloaders', + 'label': '下载器', + 'items': [{"title": config.name, "value": config.name} + for config in self.downloader_helper.get_configs().values()] + } + } + ] + } + ] + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "pause_cron", + "label": "暂停周期", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "resume_cron", + "label": "开始周期", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "enable_upload_limit", + "label": "上传限速", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "enable_download_limit", + "label": "下载限速", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "upload_limit", + "label": "上传限速 KB/s", + "placeholder": "KB/s", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "download_limit", + "label": "下载限速 KB/s", + "placeholder": "KB/s", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "onlypauseupload", + "label": "暂停上传任务", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "onlypausedownload", + "label": "暂停下载任务", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "onlypausechecking", + "label": "暂停检查任务", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VSelect", + "props": { + "chips": True, + "multiple": True, + "model": "op_site_ids", + "label": "停止保种站点(暂停保种后不会被恢复)", + "items": site_options, + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "exclude_dirs", + "label": "不暂停保种目录", + "rows": 5, + "placeholder": "该目录下的做种不会暂停,一行一个目录", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": { + "cols": 12, + }, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "开始周期和暂停周期使用Cron表达式,如:0 0 0 * *,仅针对开始/暂定全部任务", + }, + } + ], + }, + { + "component": "VCol", + "props": { + "cols": 12, + }, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "交互命令有暂停QB种子、开始QB种子、QB切换上传限速状态、QB切换下载限速状态", + }, + } + ], + }, + ], + }, + ], + } + ], { + "enabled": False, + "notify": True, + "onlypauseonce": False, + "onlyresumeonce": False, + "onlypauseupload": False, + "onlypausedownload": False, + "onlypausechecking": False, + "upload_limit": 0, + "download_limit": 0, + "enable_upload_limit": False, + "enable_download_limit": False, + "op_site_ids": [], + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) From f53ae6573dd64465487f3002e18cfa7e23714e3a Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 17 Nov 2024 15:34:28 +0800 Subject: [PATCH 174/218] add ChineseSubFinder V2 --- package.json | 3 +- package.v2.json | 12 ++ plugins.v2/chinesesubfinder/__init__.py | 255 ++++++++++++++++++++++++ 3 files changed, 268 insertions(+), 2 deletions(-) create mode 100644 plugins.v2/chinesesubfinder/__init__.py diff --git a/package.json b/package.json index 1ea49fe..c81332d 100644 --- a/package.json +++ b/package.json @@ -112,8 +112,7 @@ "version": "1.1", "icon": "chinesesubfinder.png", "author": "jxxghp", - "level": 1, - "v2": true + "level": 1 }, "DoubanRank": { "name": "豆瓣榜单订阅", diff --git a/package.v2.json b/package.v2.json index 297417e..ac2dd47 100644 --- a/package.v2.json +++ b/package.v2.json @@ -227,5 +227,17 @@ "history": { "v2.1": "兼容MoviePilot V2" } + }, + "ChineseSubFinder": { + "name": "ChineseSubFinder", + "description": "整理入库时通知ChineseSubFinder下载字幕。", + "labels": "字幕", + "version": "2.0", + "icon": "chinesesubfinder.png", + "author": "jxxghp", + "level": 1, + "history": { + "v2.0": "兼容MoviePilot V2" + } } } \ No newline at end of file diff --git a/plugins.v2/chinesesubfinder/__init__.py b/plugins.v2/chinesesubfinder/__init__.py new file mode 100644 index 0000000..eb80ff7 --- /dev/null +++ b/plugins.v2/chinesesubfinder/__init__.py @@ -0,0 +1,255 @@ +from functools import lru_cache +from pathlib import Path +from typing import List, Tuple, Dict, Any + +from app.core.config import settings +from app.core.context import MediaInfo +from app.core.event import eventmanager, Event +from app.log import logger +from app.plugins import _PluginBase +from app.schemas import TransferInfo, FileItem +from app.schemas.types import EventType, MediaType +from app.utils.http import RequestUtils +from app.utils.system import SystemUtils + + +class ChineseSubFinder(_PluginBase): + # 插件名称 + plugin_name = "ChineseSubFinder" + # 插件描述 + plugin_desc = "整理入库时通知ChineseSubFinder下载字幕。" + # 插件图标 + plugin_icon = "chinesesubfinder.png" + # 插件版本 + plugin_version = "2.0" + # 插件作者 + plugin_author = "jxxghp" + # 作者主页 + author_url = "https://github.com/jxxghp" + # 插件配置项ID前缀 + plugin_config_prefix = "chinesesubfinder_" + # 加载顺序 + plugin_order = 5 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _save_tmp_path = None + _enabled = False + _host = None + _api_key = None + _remote_path = None + _local_path = None + + def init_plugin(self, config: dict = None): + self._save_tmp_path = settings.TEMP_PATH + if config: + self._enabled = config.get("enabled") + self._api_key = config.get("api_key") + self._host = config.get('host') + if self._host: + if not self._host.startswith('http'): + self._host = "http://" + self._host + if not self._host.endswith('/'): + self._host = self._host + "/" + self._local_path = config.get("local_path") + self._remote_path = config.get("remote_path") + + @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]]: + 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, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'host', + 'label': '服务器' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'api_key', + 'label': 'API密钥' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'local_path', + 'label': '本地路径' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'remote_path', + 'label': '远端路径' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "host": "", + "api_key": "", + "local_path": "", + "remote_path": "" + } + + def get_state(self) -> bool: + return self._enabled + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + pass + + @eventmanager.register(EventType.TransferComplete) + def download(self, event: Event): + """ + 调用ChineseSubFinder下载字幕 + """ + if not self._enabled or not self._host or not self._api_key: + return + item = event.event_data + if not item: + return + # 请求地址 + req_url = "%sapi/v1/add-job" % self._host + + # 媒体信息 + item_media: MediaInfo = item.get("mediainfo") + # 转移信息 + item_transfer: TransferInfo = item.get("transferinfo") + # 类型 + item_type = item_media.type + # 目的路径 + item_dest: FileItem = item_transfer.target_diritem + # 是否蓝光原盘 + item_bluray = SystemUtils.is_bluray_dir(Path(item_dest.path)) + # 文件清单 + item_file_list = item_transfer.file_list_new + + if item_bluray: + # 蓝光原盘虚拟个文件 + item_file_list = ["%s.mp4" % Path(item_dest.path) / item_dest.name] + + for file_path in item_file_list: + # 路径替换 + if self._local_path and self._remote_path and file_path.startswith(self._local_path): + file_path = file_path.replace(self._local_path, self._remote_path).replace('\\', '/') + + # 调用CSF下载字幕 + self.__request_csf(req_url=req_url, + file_path=file_path, + item_type=0 if item_type == MediaType.MOVIE else 1, + item_bluray=item_bluray) + + @lru_cache(maxsize=128) + def __request_csf(self, req_url, file_path, item_type, item_bluray): + # 一个名称只建一个任务 + logger.info("通知ChineseSubFinder下载字幕: %s" % file_path) + params = { + "video_type": item_type, + "physical_video_file_full_path": file_path, + "task_priority_level": 3, + "media_server_inside_video_id": "", + "is_bluray": item_bluray + } + try: + res = RequestUtils(headers={ + "Authorization": "Bearer %s" % self._api_key + }).post(req_url, json=params) + if not res or res.status_code != 200: + logger.error("调用ChineseSubFinder API失败!") + else: + # 如果文件目录没有识别的nfo元数据, 此接口会返回控制符,推测是ChineseSubFinder的原因 + # emby refresh元数据时异步的 + if res.text: + job_id = res.json().get("job_id") + message = res.json().get("message") + if not job_id: + logger.warn("ChineseSubFinder下载字幕出错:%s" % message) + else: + logger.info("ChineseSubFinder任务添加成功:%s" % job_id) + elif res.status_code != 200: + logger.warn(f"ChineseSubFinder调用出错:{res.status_code} - {res.reason}") + except Exception as e: + logger.error("连接ChineseSubFinder出错:" + str(e)) From c7b28f0d110dbc85567aeedf917ce949bb7badf6 Mon Sep 17 00:00:00 2001 From: LongShengWen Date: Tue, 19 Nov 2024 17:39:11 +0800 Subject: [PATCH 175/218] =?UTF-8?q?=E7=9F=AD=E5=89=A7=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E8=B0=83=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 14 ++ plugins.v2/playletcategory/__init__.py | 323 +++++++++++++++++++++++++ 2 files changed, 337 insertions(+) create mode 100644 plugins.v2/playletcategory/__init__.py diff --git a/package.v2.json b/package.v2.json index ed288c0..c7b0490 100644 --- a/package.v2.json +++ b/package.v2.json @@ -251,5 +251,19 @@ "history": { "v2.0": "兼容MoviePilot V2" } + }, + "PlayletCategory": { + "name": "短剧自动分类", + "description": "网络短剧自动整理到独立的分类目录。", + "labels": "文件整理", + "version": "2.1", + "icon": "Amule_A.png", + "author": "jxxghp", + "level": 1, + "v2": true, + "history": { + "v2.1": "兼容MoviePilot V2", + "v2.0": "适配新的目录结构变化,短剧分类名称调整为配置目录路径,升级后需要重新调整设置后才能使用。" + } } } \ No newline at end of file diff --git a/plugins.v2/playletcategory/__init__.py b/plugins.v2/playletcategory/__init__.py new file mode 100644 index 0000000..9545e36 --- /dev/null +++ b/plugins.v2/playletcategory/__init__.py @@ -0,0 +1,323 @@ +import random +import shutil +import subprocess +import threading +from pathlib import Path +from typing import Any, List, Dict, Tuple + +from app.core.config import settings +from app.core.context import MediaInfo +from app.core.event import eventmanager, Event +from app.log import logger +from app.plugins import _PluginBase +from app.schemas import TransferInfo +from app.schemas.types import EventType, MediaType, NotificationType +from app.utils.system import SystemUtils + +lock = threading.Lock() + + +class PlayletCategory(_PluginBase): + # 插件名称 + plugin_name = "短剧自动分类" + # 插件描述 + plugin_desc = "网络短剧自动分类到独立目录。" + # 插件图标 + plugin_icon = "Amule_A.png" + # 插件版本 + plugin_version = "2.0" + # 插件作者 + plugin_author = "jxxghp" + # 作者主页 + author_url = "https://github.com/jxxghp" + # 插件配置项ID前缀 + plugin_config_prefix = "playletcategory_" + # 加载顺序 + plugin_order = 29 + # 可使用的用户级别 + auth_level = 1 + + _enabled = False + _notify = True + _delay = 0 + _category_dir = "" + _episode_duration = 8 + + def init_plugin(self, config: dict = None): + + if config: + self._enabled = config.get("enabled") + self._delay = config.get("delay") or 0 + self._notify = config.get("notify") + self._category_dir = config.get("category_dir") + self._episode_duration = config.get("episode_duration") + + def get_state(self) -> bool: + return True if self._enabled and self._category_dir and self._episode_duration 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、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '发送消息', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4, + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'category_dir', + 'label': '分类目录路径', + 'placeholder': '/media/短剧' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4, + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'episode_duration', + 'label': '单集时长(分钟)', + 'placeholder': '8' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4, + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'delay', + 'label': '入库延迟时间(秒)', + 'placeholder': '' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '小于单集时长的剧集视频文件将会移动到分类目录,入库延迟适用于网盘等需要延后处理的场景,需要安装FFmpeg。' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "notify": True, + "delay": '', + "category_dir": '短剧', + "episode_duration": '8' + } + + def get_page(self) -> List[dict]: + pass + + @eventmanager.register(EventType.TransferComplete) + def category_handler(self, event: Event): + """ + 根据事件实时刮削剧集组信息 + """ + if not event: + return + if not self.get_state(): + return + event_data = event.event_data + mediainfo: MediaInfo = event_data.get("mediainfo") + transferinfo: TransferInfo = event_data.get("transferinfo") + if not mediainfo or not transferinfo: + return + if not transferinfo.target_path: + return + if not transferinfo.target_path.exists(): + return + if mediainfo.type != MediaType.TV: + logger.info(f"{transferinfo.target_path} 不是电视剧,跳过分类处理") + return + # 加锁 + with lock: + file_list = transferinfo.file_list_new or [] + # 过滤掉不存在的文件 + file_list = [file for file in file_list if Path(file).exists()] + if not file_list: + logger.warn(f"{transferinfo.target_path} 无文件,跳过分类处理") + return + logger.info(f"开始处理 {transferinfo.target_path} 短剧分类,共有 {len(file_list)} 个文件") + # 从文件列表中随机抽取3个文件 + if len(file_list) > 3: + check_files = random.choices(file_list, k=3) + else: + check_files = file_list + # 计算文件时长,有任意文件时长大于单集时长则不处理 + need_category = True + for file in check_files: + duration = self.__get_duration(file) + if duration > float(self._episode_duration): + logger.info( + f"{file} 时长 {duration} 分钟,大于单集时长 {self._episode_duration} 分钟,不需要分类处理") + need_category = False + break + else: + logger.info(f"{file} 时长:{duration} 分钟") + if need_category: + logger.info(f"{transferinfo.target_path} 需要分类处理,开始移动文件...") + self.__move_files(target_path=transferinfo.target_path) + logger.info(f"{transferinfo.target_path} 短剧分类处理完成") + else: + logger.info(f"{transferinfo.target_path} 不是短剧,无需分类处理") + + @staticmethod + def __get_duration(video_path: str) -> float: + """ + 获取视频文件时长(分钟) + """ + + # 使用FFmpeg命令行工具获取视频时长 + cmd = ['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', + 'default=noprint_wrappers=1:nokey=1', str(video_path)] + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + output, error = process.communicate() + + # 如果有错误,输出错误信息 + if error: + logger.error(f"FFmpeg处理出错: {error.decode('utf-8')}") + return 0 + + # 获取视频时长(秒),转换为分钟 + return round(float(output) / 60, 1) + + def __move_files(self, target_path: Path): + """ + 移动文件到分类目录 + :param target_path: 电视剧时为季的目录 + """ + if not target_path.exists(): + return + if target_path.is_file(): + target_path = target_path.parent + # 剧集的根目录 + tv_path = target_path.parent + # 新的文件目录 + new_path = Path(self._category_dir) / tv_path.name + if not new_path.exists(): + # 移动目录 + try: + shutil.move(tv_path, new_path) + except Exception as e: + logger.error(f"移动文件失败:{e}") + return + else: + # 遍历目录下的所有文件,并移动到目的目录 + for file in tv_path.iterdir(): + if file.is_file(): + try: + # 相对路径 + relative_path = file.relative_to(tv_path) + shutil.move(file, new_path / relative_path) + except Exception as e: + logger.error(f"移动文件失败:{e}") + return + # 删除空目录 + if not SystemUtils.list_files(tv_path, extensions=settings.RMT_MEDIAEXT + settings.DOWNLOAD_TMPEXT): + try: + shutil.rmtree(tv_path, ignore_errors=True) + except Exception as e: + logger.error(f"删除空目录失败:{e}") + + # 发送消息 + if self._notify: + self.post_message( + mtype=NotificationType.Organize, + title="【短剧自动分类】", + text=f"已将 {tv_path.name} 分类到 {self._category_dir} 目录", + ) + + def stop_service(self): + """ + 停止服务 + """ + pass From fdbfe1c3c2fe3c15da4261a15714c3c6b7ebe186 Mon Sep 17 00:00:00 2001 From: LongShengWen Date: Tue, 19 Nov 2024 17:41:26 +0800 Subject: [PATCH 176/218] =?UTF-8?q?=E7=9F=AD=E5=89=A7=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E8=B0=83=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 4 ++-- plugins.v2/chinesesubfinder/__init__.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/package.v2.json b/package.v2.json index c7b0490..6312f8b 100644 --- a/package.v2.json +++ b/package.v2.json @@ -252,13 +252,13 @@ "v2.0": "兼容MoviePilot V2" } }, - "PlayletCategory": { + "PlayletCategory_v2": { "name": "短剧自动分类", "description": "网络短剧自动整理到独立的分类目录。", "labels": "文件整理", "version": "2.1", "icon": "Amule_A.png", - "author": "jxxghp", + "author": "longqiuyu", "level": 1, "v2": true, "history": { diff --git a/plugins.v2/chinesesubfinder/__init__.py b/plugins.v2/chinesesubfinder/__init__.py index eb80ff7..71a3b6e 100644 --- a/plugins.v2/chinesesubfinder/__init__.py +++ b/plugins.v2/chinesesubfinder/__init__.py @@ -23,7 +23,8 @@ class ChineseSubFinder(_PluginBase): # 插件版本 plugin_version = "2.0" # 插件作者 - plugin_author = "jxxghp" + # plugin_author = "jxxghp" + plugin_author = "longqiuyu" # 作者主页 author_url = "https://github.com/jxxghp" # 插件配置项ID前缀 From d3a3add51b8b112c4d98c383d448032e402a8f82 Mon Sep 17 00:00:00 2001 From: LongShengWen Date: Tue, 19 Nov 2024 18:24:10 +0800 Subject: [PATCH 177/218] =?UTF-8?q?=E7=9F=AD=E5=89=A7=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E8=B0=83=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins.v2/playletcategory/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins.v2/playletcategory/__init__.py b/plugins.v2/playletcategory/__init__.py index 9545e36..06e069a 100644 --- a/plugins.v2/playletcategory/__init__.py +++ b/plugins.v2/playletcategory/__init__.py @@ -17,7 +17,7 @@ from app.utils.system import SystemUtils lock = threading.Lock() -class PlayletCategory(_PluginBase): +class PlayletCategory_v2(_PluginBase): # 插件名称 plugin_name = "短剧自动分类" # 插件描述 @@ -25,13 +25,13 @@ class PlayletCategory(_PluginBase): # 插件图标 plugin_icon = "Amule_A.png" # 插件版本 - plugin_version = "2.0" + plugin_version = "2.1" # 插件作者 plugin_author = "jxxghp" # 作者主页 author_url = "https://github.com/jxxghp" # 插件配置项ID前缀 - plugin_config_prefix = "playletcategory_" + plugin_config_prefix = "playletcategory_v2_" # 加载顺序 plugin_order = 29 # 可使用的用户级别 From b2d5e84058bec5c3cdd6280f0a3a94caf838e6bb Mon Sep 17 00:00:00 2001 From: LongShengWen Date: Tue, 19 Nov 2024 18:29:33 +0800 Subject: [PATCH 178/218] =?UTF-8?q?=E7=9F=AD=E5=89=A7=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E8=B0=83=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 2 +- .../{playletcategory => playletcategory_v2}/__init__.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) rename plugins.v2/{playletcategory => playletcategory_v2}/__init__.py (99%) diff --git a/package.v2.json b/package.v2.json index 6312f8b..bede2c4 100644 --- a/package.v2.json +++ b/package.v2.json @@ -256,7 +256,7 @@ "name": "短剧自动分类", "description": "网络短剧自动整理到独立的分类目录。", "labels": "文件整理", - "version": "2.1", + "version": "2.2", "icon": "Amule_A.png", "author": "longqiuyu", "level": 1, diff --git a/plugins.v2/playletcategory/__init__.py b/plugins.v2/playletcategory_v2/__init__.py similarity index 99% rename from plugins.v2/playletcategory/__init__.py rename to plugins.v2/playletcategory_v2/__init__.py index 06e069a..3155415 100644 --- a/plugins.v2/playletcategory/__init__.py +++ b/plugins.v2/playletcategory_v2/__init__.py @@ -25,11 +25,11 @@ class PlayletCategory_v2(_PluginBase): # 插件图标 plugin_icon = "Amule_A.png" # 插件版本 - plugin_version = "2.1" + plugin_version = "2.2" # 插件作者 - plugin_author = "jxxghp" + plugin_author = "longqiuyu" # 作者主页 - author_url = "https://github.com/jxxghp" + author_url = "https://github.com/LongShengWen" # 插件配置项ID前缀 plugin_config_prefix = "playletcategory_v2_" # 加载顺序 From c05d727655bb8c71366575b696210a92900361d2 Mon Sep 17 00:00:00 2001 From: LongShengWen Date: Tue, 19 Nov 2024 18:38:59 +0800 Subject: [PATCH 179/218] =?UTF-8?q?=E7=9F=AD=E5=89=A7=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E8=B0=83=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 2 +- plugins.v2/playletcategory_v2/__init__.py | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/package.v2.json b/package.v2.json index bede2c4..9cbf8d9 100644 --- a/package.v2.json +++ b/package.v2.json @@ -256,7 +256,7 @@ "name": "短剧自动分类", "description": "网络短剧自动整理到独立的分类目录。", "labels": "文件整理", - "version": "2.2", + "version": "2.3", "icon": "Amule_A.png", "author": "longqiuyu", "level": 1, diff --git a/plugins.v2/playletcategory_v2/__init__.py b/plugins.v2/playletcategory_v2/__init__.py index 3155415..d5107d7 100644 --- a/plugins.v2/playletcategory_v2/__init__.py +++ b/plugins.v2/playletcategory_v2/__init__.py @@ -25,7 +25,7 @@ class PlayletCategory_v2(_PluginBase): # 插件图标 plugin_icon = "Amule_A.png" # 插件版本 - plugin_version = "2.2" + plugin_version = "2.3" # 插件作者 plugin_author = "longqiuyu" # 作者主页 @@ -202,18 +202,26 @@ class PlayletCategory_v2(_PluginBase): """ 根据事件实时刮削剧集组信息 """ + logger.info(f"触发短剧分类!") if not event: + logger.info(f"短剧分类异常:{event}") return if not self.get_state(): + logger.info(f"短剧分类异常:{event}") return event_data = event.event_data mediainfo: MediaInfo = event_data.get("mediainfo") transferinfo: TransferInfo = event_data.get("transferinfo") + logger.info(f"mediainfo:{mediainfo}") + logger.info(f"transferinfo:{transferinfo}") if not mediainfo or not transferinfo: + logger.info(f"1") return if not transferinfo.target_path: + logger.info(f"2") return if not transferinfo.target_path.exists(): + logger.info(f"3") return if mediainfo.type != MediaType.TV: logger.info(f"{transferinfo.target_path} 不是电视剧,跳过分类处理") @@ -275,7 +283,9 @@ class PlayletCategory_v2(_PluginBase): 移动文件到分类目录 :param target_path: 电视剧时为季的目录 """ + logger.info(f"target_path: {target_path}") if not target_path.exists(): + logger.info(f"4") return if target_path.is_file(): target_path = target_path.parent From 77b79d3f425d2e0ed59f54e395fcd3733a7c1dfe Mon Sep 17 00:00:00 2001 From: LongShengWen Date: Tue, 19 Nov 2024 20:01:56 +0800 Subject: [PATCH 180/218] =?UTF-8?q?=E7=9F=AD=E5=89=A7=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E8=B0=83=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 2 +- plugins.v2/playletcategory_v2/__init__.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/package.v2.json b/package.v2.json index 9cbf8d9..574a454 100644 --- a/package.v2.json +++ b/package.v2.json @@ -256,7 +256,7 @@ "name": "短剧自动分类", "description": "网络短剧自动整理到独立的分类目录。", "labels": "文件整理", - "version": "2.3", + "version": "2.4", "icon": "Amule_A.png", "author": "longqiuyu", "level": 1, diff --git a/plugins.v2/playletcategory_v2/__init__.py b/plugins.v2/playletcategory_v2/__init__.py index d5107d7..57d877d 100644 --- a/plugins.v2/playletcategory_v2/__init__.py +++ b/plugins.v2/playletcategory_v2/__init__.py @@ -25,7 +25,7 @@ class PlayletCategory_v2(_PluginBase): # 插件图标 plugin_icon = "Amule_A.png" # 插件版本 - plugin_version = "2.3" + plugin_version = "2.4" # 插件作者 plugin_author = "longqiuyu" # 作者主页 @@ -232,6 +232,7 @@ class PlayletCategory_v2(_PluginBase): # 过滤掉不存在的文件 file_list = [file for file in file_list if Path(file).exists()] if not file_list: + logger.info(f"{transferinfo.target_path} 无文件,跳过分类处理") logger.warn(f"{transferinfo.target_path} 无文件,跳过分类处理") return logger.info(f"开始处理 {transferinfo.target_path} 短剧分类,共有 {len(file_list)} 个文件") From 5e23741d07c8cbc0c83ead3efacc817665e5266f Mon Sep 17 00:00:00 2001 From: LongShengWen Date: Tue, 19 Nov 2024 21:04:01 +0800 Subject: [PATCH 181/218] =?UTF-8?q?=E7=9F=AD=E5=89=A7=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E8=B0=83=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 2 +- plugins.v2/playletcategory_v2/__init__.py | 25 ++++++++++------------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/package.v2.json b/package.v2.json index 574a454..432648d 100644 --- a/package.v2.json +++ b/package.v2.json @@ -256,7 +256,7 @@ "name": "短剧自动分类", "description": "网络短剧自动整理到独立的分类目录。", "labels": "文件整理", - "version": "2.4", + "version": "2.5", "icon": "Amule_A.png", "author": "longqiuyu", "level": 1, diff --git a/plugins.v2/playletcategory_v2/__init__.py b/plugins.v2/playletcategory_v2/__init__.py index 57d877d..b32bf96 100644 --- a/plugins.v2/playletcategory_v2/__init__.py +++ b/plugins.v2/playletcategory_v2/__init__.py @@ -25,7 +25,7 @@ class PlayletCategory_v2(_PluginBase): # 插件图标 plugin_icon = "Amule_A.png" # 插件版本 - plugin_version = "2.4" + plugin_version = "2.5" # 插件作者 plugin_author = "longqiuyu" # 作者主页 @@ -212,19 +212,17 @@ class PlayletCategory_v2(_PluginBase): event_data = event.event_data mediainfo: MediaInfo = event_data.get("mediainfo") transferinfo: TransferInfo = event_data.get("transferinfo") - logger.info(f"mediainfo:{mediainfo}") - logger.info(f"transferinfo:{transferinfo}") if not mediainfo or not transferinfo: - logger.info(f"1") + logger.info(f"关键信息不存在!") return - if not transferinfo.target_path: + if not transferinfo.target_item.path: logger.info(f"2") return - if not transferinfo.target_path.exists(): + if not transferinfo.target_item.path.exists(): logger.info(f"3") return if mediainfo.type != MediaType.TV: - logger.info(f"{transferinfo.target_path} 不是电视剧,跳过分类处理") + logger.info(f"{transferinfo.target_item.path} 不是电视剧,跳过分类处理") return # 加锁 with lock: @@ -232,10 +230,9 @@ class PlayletCategory_v2(_PluginBase): # 过滤掉不存在的文件 file_list = [file for file in file_list if Path(file).exists()] if not file_list: - logger.info(f"{transferinfo.target_path} 无文件,跳过分类处理") - logger.warn(f"{transferinfo.target_path} 无文件,跳过分类处理") + logger.warn(f"{transferinfo.target_item.path} 无文件,跳过分类处理") return - logger.info(f"开始处理 {transferinfo.target_path} 短剧分类,共有 {len(file_list)} 个文件") + logger.info(f"开始处理 {transferinfo.target_item.path} 短剧分类,共有 {len(file_list)} 个文件") # 从文件列表中随机抽取3个文件 if len(file_list) > 3: check_files = random.choices(file_list, k=3) @@ -253,11 +250,11 @@ class PlayletCategory_v2(_PluginBase): else: logger.info(f"{file} 时长:{duration} 分钟") if need_category: - logger.info(f"{transferinfo.target_path} 需要分类处理,开始移动文件...") - self.__move_files(target_path=transferinfo.target_path) - logger.info(f"{transferinfo.target_path} 短剧分类处理完成") + logger.info(f"{transferinfo.target_item.path} 需要分类处理,开始移动文件...") + self.__move_files(target_path=transferinfo.target_item.path) + logger.info(f"{transferinfo.target_item.path} 短剧分类处理完成") else: - logger.info(f"{transferinfo.target_path} 不是短剧,无需分类处理") + logger.info(f"{transferinfo.target_item.path} 不是短剧,无需分类处理") @staticmethod def __get_duration(video_path: str) -> float: From b81d800d0064f71496fd94ad55e090c5816aa7c9 Mon Sep 17 00:00:00 2001 From: LongShengWen Date: Tue, 19 Nov 2024 21:21:04 +0800 Subject: [PATCH 182/218] =?UTF-8?q?=E7=9F=AD=E5=89=A7=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E8=B0=83=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 2 +- plugins.v2/playletcategory_v2/__init__.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/package.v2.json b/package.v2.json index 432648d..d3deac4 100644 --- a/package.v2.json +++ b/package.v2.json @@ -256,7 +256,7 @@ "name": "短剧自动分类", "description": "网络短剧自动整理到独立的分类目录。", "labels": "文件整理", - "version": "2.5", + "version": "2.6", "icon": "Amule_A.png", "author": "longqiuyu", "level": 1, diff --git a/plugins.v2/playletcategory_v2/__init__.py b/plugins.v2/playletcategory_v2/__init__.py index b32bf96..1fd8c83 100644 --- a/plugins.v2/playletcategory_v2/__init__.py +++ b/plugins.v2/playletcategory_v2/__init__.py @@ -25,7 +25,7 @@ class PlayletCategory_v2(_PluginBase): # 插件图标 plugin_icon = "Amule_A.png" # 插件版本 - plugin_version = "2.5" + plugin_version = "2.6" # 插件作者 plugin_author = "longqiuyu" # 作者主页 @@ -224,9 +224,10 @@ class PlayletCategory_v2(_PluginBase): if mediainfo.type != MediaType.TV: logger.info(f"{transferinfo.target_item.path} 不是电视剧,跳过分类处理") return + logger.info("开始整理!") # 加锁 with lock: - file_list = transferinfo.file_list_new or [] + file_list = transferinfo.target_item.file_list_new or [] # 过滤掉不存在的文件 file_list = [file for file in file_list if Path(file).exists()] if not file_list: From b8700f22f29807b27d4c646769715be4a63071ff Mon Sep 17 00:00:00 2001 From: LongShengWen Date: Wed, 20 Nov 2024 08:59:50 +0800 Subject: [PATCH 183/218] =?UTF-8?q?=E7=9F=AD=E5=89=A7=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E8=B0=83=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 3 +- plugins.v2/playletcategory_v2/__init__.py | 35 ++++++++++++----------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/package.v2.json b/package.v2.json index d3deac4..250f6bd 100644 --- a/package.v2.json +++ b/package.v2.json @@ -256,11 +256,10 @@ "name": "短剧自动分类", "description": "网络短剧自动整理到独立的分类目录。", "labels": "文件整理", - "version": "2.6", + "version": "2.7", "icon": "Amule_A.png", "author": "longqiuyu", "level": 1, - "v2": true, "history": { "v2.1": "兼容MoviePilot V2", "v2.0": "适配新的目录结构变化,短剧分类名称调整为配置目录路径,升级后需要重新调整设置后才能使用。" diff --git a/plugins.v2/playletcategory_v2/__init__.py b/plugins.v2/playletcategory_v2/__init__.py index 1fd8c83..eb1a761 100644 --- a/plugins.v2/playletcategory_v2/__init__.py +++ b/plugins.v2/playletcategory_v2/__init__.py @@ -25,7 +25,7 @@ class PlayletCategory_v2(_PluginBase): # 插件图标 plugin_icon = "Amule_A.png" # 插件版本 - plugin_version = "2.6" + plugin_version = "2.7" # 插件作者 plugin_author = "longqiuyu" # 作者主页 @@ -209,21 +209,24 @@ class PlayletCategory_v2(_PluginBase): if not self.get_state(): logger.info(f"短剧分类异常:{event}") return - event_data = event.event_data - mediainfo: MediaInfo = event_data.get("mediainfo") - transferinfo: TransferInfo = event_data.get("transferinfo") - if not mediainfo or not transferinfo: - logger.info(f"关键信息不存在!") - return - if not transferinfo.target_item.path: - logger.info(f"2") - return - if not transferinfo.target_item.path.exists(): - logger.info(f"3") - return - if mediainfo.type != MediaType.TV: - logger.info(f"{transferinfo.target_item.path} 不是电视剧,跳过分类处理") - return + try: + event_data = event.event_data + mediainfo: MediaInfo = event_data.get("mediainfo") + transferinfo: TransferInfo = event_data.get("transferinfo") + if not mediainfo or not transferinfo: + logger.info(f"关键信息不存在!") + return + if not transferinfo.target_item.path: + logger.info(f"2") + return + if not transferinfo.target_item.path.exists(): + logger.info(f"3") + return + if mediainfo.type != MediaType.TV: + logger.info(f"{transferinfo.target_item.path} 不是电视剧,跳过分类处理") + return + except Exception as e: + logger.info(f"目录异常:{str(e)}") logger.info("开始整理!") # 加锁 with lock: From 2283f88bbd00215bad5c75460744d7f4eb93eeb6 Mon Sep 17 00:00:00 2001 From: LongShengWen Date: Wed, 20 Nov 2024 09:32:03 +0800 Subject: [PATCH 184/218] =?UTF-8?q?=E7=9F=AD=E5=89=A7=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E8=B0=83=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 2 +- plugins.v2/playletcategory_v2/__init__.py | 82 ++++++++++++----------- 2 files changed, 43 insertions(+), 41 deletions(-) diff --git a/package.v2.json b/package.v2.json index 250f6bd..7e209b8 100644 --- a/package.v2.json +++ b/package.v2.json @@ -256,7 +256,7 @@ "name": "短剧自动分类", "description": "网络短剧自动整理到独立的分类目录。", "labels": "文件整理", - "version": "2.7", + "version": "2.8", "icon": "Amule_A.png", "author": "longqiuyu", "level": 1, diff --git a/plugins.v2/playletcategory_v2/__init__.py b/plugins.v2/playletcategory_v2/__init__.py index eb1a761..afdfa11 100644 --- a/plugins.v2/playletcategory_v2/__init__.py +++ b/plugins.v2/playletcategory_v2/__init__.py @@ -25,7 +25,7 @@ class PlayletCategory_v2(_PluginBase): # 插件图标 plugin_icon = "Amule_A.png" # 插件版本 - plugin_version = "2.7" + plugin_version = "2.8" # 插件作者 plugin_author = "longqiuyu" # 作者主页 @@ -213,52 +213,53 @@ class PlayletCategory_v2(_PluginBase): event_data = event.event_data mediainfo: MediaInfo = event_data.get("mediainfo") transferinfo: TransferInfo = event_data.get("transferinfo") + logger.info(transferinfo.target_item) + logger.info(transferinfo.target_item.path) if not mediainfo or not transferinfo: - logger.info(f"关键信息不存在!") return if not transferinfo.target_item.path: - logger.info(f"2") + logger.debug(f"文件路径不存在:{transferinfo.target_item.path}") return - if not transferinfo.target_item.path.exists(): - logger.info(f"3") + target_path = Path(transferinfo.target_item.path) + if not target_path.exists(): + logger.debug(f"文件路径不存在:{target_path}") return if mediainfo.type != MediaType.TV: - logger.info(f"{transferinfo.target_item.path} 不是电视剧,跳过分类处理") + logger.info(f"{target_path} 不是电视剧,跳过分类处理") return - except Exception as e: - logger.info(f"目录异常:{str(e)}") - logger.info("开始整理!") - # 加锁 - with lock: - file_list = transferinfo.target_item.file_list_new or [] - # 过滤掉不存在的文件 - file_list = [file for file in file_list if Path(file).exists()] - if not file_list: - logger.warn(f"{transferinfo.target_item.path} 无文件,跳过分类处理") - return - logger.info(f"开始处理 {transferinfo.target_item.path} 短剧分类,共有 {len(file_list)} 个文件") - # 从文件列表中随机抽取3个文件 - if len(file_list) > 3: - check_files = random.choices(file_list, k=3) - else: - check_files = file_list - # 计算文件时长,有任意文件时长大于单集时长则不处理 - need_category = True - for file in check_files: - duration = self.__get_duration(file) - if duration > float(self._episode_duration): - logger.info( - f"{file} 时长 {duration} 分钟,大于单集时长 {self._episode_duration} 分钟,不需要分类处理") - need_category = False - break + logger.info("开始整理!") + # 加锁 + with lock: + file_list = transferinfo.target_item.file_list_new or [] + # 过滤掉不存在的文件 + file_list = [file for file in file_list if Path(file).exists()] + if not file_list: + logger.warn(f"{target_path} 无文件,跳过分类处理") + return + logger.info(f"开始处理 {target_path} 短剧分类,共有 {len(file_list)} 个文件") + # 从文件列表中随机抽取3个文件 + if len(file_list) > 3: + check_files = random.choices(file_list, k=3) else: - logger.info(f"{file} 时长:{duration} 分钟") - if need_category: - logger.info(f"{transferinfo.target_item.path} 需要分类处理,开始移动文件...") - self.__move_files(target_path=transferinfo.target_item.path) - logger.info(f"{transferinfo.target_item.path} 短剧分类处理完成") - else: - logger.info(f"{transferinfo.target_item.path} 不是短剧,无需分类处理") + check_files = file_list + # 计算文件时长,有任意文件时长大于单集时长则不处理 + need_category = True + for file in check_files: + duration = self.__get_duration(file) + if duration > float(self._episode_duration): + logger.info(f"{file} 时长 {duration} 分钟,大于单集时长 {self._episode_duration} 分钟,不需要分类处理") + need_category = False + break + else: + logger.info(f"{file} 时长:{duration} 分钟") + if need_category: + logger.info(f"{target_path} 需要分类处理,开始移动文件...") + self.__move_files(target_path=target_path) + logger.info(f"{target_path} 短剧分类处理完成") + else: + logger.info(f"{target_path} 不是短剧,无需分类处理") + except Exception as e: + logger.info(f"短剧分类异常:{str(e)}") @staticmethod def __get_duration(video_path: str) -> float: @@ -287,14 +288,15 @@ class PlayletCategory_v2(_PluginBase): """ logger.info(f"target_path: {target_path}") if not target_path.exists(): - logger.info(f"4") return if target_path.is_file(): target_path = target_path.parent # 剧集的根目录 tv_path = target_path.parent + logger.info(f"{tv_path}") # 新的文件目录 new_path = Path(self._category_dir) / tv_path.name + logger.info(f"{new_path}") if not new_path.exists(): # 移动目录 try: From 4dedbdd743122d886d2ea5f92154a078a3b1754e Mon Sep 17 00:00:00 2001 From: LongShengWen Date: Wed, 20 Nov 2024 09:41:35 +0800 Subject: [PATCH 185/218] =?UTF-8?q?=E7=9F=AD=E5=89=A7=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E8=B0=83=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 2 +- plugins.v2/playletcategory_v2/__init__.py | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/package.v2.json b/package.v2.json index 7e209b8..0d30663 100644 --- a/package.v2.json +++ b/package.v2.json @@ -256,7 +256,7 @@ "name": "短剧自动分类", "description": "网络短剧自动整理到独立的分类目录。", "labels": "文件整理", - "version": "2.8", + "version": "2.9", "icon": "Amule_A.png", "author": "longqiuyu", "level": 1, diff --git a/plugins.v2/playletcategory_v2/__init__.py b/plugins.v2/playletcategory_v2/__init__.py index afdfa11..751b02d 100644 --- a/plugins.v2/playletcategory_v2/__init__.py +++ b/plugins.v2/playletcategory_v2/__init__.py @@ -11,6 +11,7 @@ from app.core.event import eventmanager, Event from app.log import logger from app.plugins import _PluginBase from app.schemas import TransferInfo +from app.schemas.file import FileItem from app.schemas.types import EventType, MediaType, NotificationType from app.utils.system import SystemUtils @@ -25,7 +26,7 @@ class PlayletCategory_v2(_PluginBase): # 插件图标 plugin_icon = "Amule_A.png" # 插件版本 - plugin_version = "2.8" + plugin_version = "2.9" # 插件作者 plugin_author = "longqiuyu" # 作者主页 @@ -230,7 +231,9 @@ class PlayletCategory_v2(_PluginBase): logger.info("开始整理!") # 加锁 with lock: - file_list = transferinfo.target_item.file_list_new or [] + target_item: FileItem = transferinfo.target_item + logger.debug(f"target_item: {target_item}") + file_list = target_item.file_list_new or [] # 过滤掉不存在的文件 file_list = [file for file in file_list if Path(file).exists()] if not file_list: From 911891d4569fa192ef1d6704e69563eb848c47fb Mon Sep 17 00:00:00 2001 From: LongShengWen Date: Wed, 20 Nov 2024 11:01:09 +0800 Subject: [PATCH 186/218] =?UTF-8?q?=E7=9F=AD=E5=89=A7=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E8=B0=83=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 2 +- plugins.v2/playletcategory_v2/__init__.py | 17 ++++++++--------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/package.v2.json b/package.v2.json index 0d30663..a463ad4 100644 --- a/package.v2.json +++ b/package.v2.json @@ -256,7 +256,7 @@ "name": "短剧自动分类", "description": "网络短剧自动整理到独立的分类目录。", "labels": "文件整理", - "version": "2.9", + "version": "2.10", "icon": "Amule_A.png", "author": "longqiuyu", "level": 1, diff --git a/plugins.v2/playletcategory_v2/__init__.py b/plugins.v2/playletcategory_v2/__init__.py index 751b02d..4476f2e 100644 --- a/plugins.v2/playletcategory_v2/__init__.py +++ b/plugins.v2/playletcategory_v2/__init__.py @@ -26,7 +26,7 @@ class PlayletCategory_v2(_PluginBase): # 插件图标 plugin_icon = "Amule_A.png" # 插件版本 - plugin_version = "2.9" + plugin_version = "2.10" # 插件作者 plugin_author = "longqiuyu" # 作者主页 @@ -214,14 +214,14 @@ class PlayletCategory_v2(_PluginBase): event_data = event.event_data mediainfo: MediaInfo = event_data.get("mediainfo") transferinfo: TransferInfo = event_data.get("transferinfo") - logger.info(transferinfo.target_item) - logger.info(transferinfo.target_item.path) + logger.info(transferinfo.target_diritem) + logger.info(transferinfo.target_diritem.path) if not mediainfo or not transferinfo: return - if not transferinfo.target_item.path: - logger.debug(f"文件路径不存在:{transferinfo.target_item.path}") + if not transferinfo.target_diritem.path: + logger.debug(f"文件路径不存在:{transferinfo.target_diritem.path}") return - target_path = Path(transferinfo.target_item.path) + target_path = Path(transferinfo.target_diritem.path) if not target_path.exists(): logger.debug(f"文件路径不存在:{target_path}") return @@ -231,9 +231,8 @@ class PlayletCategory_v2(_PluginBase): logger.info("开始整理!") # 加锁 with lock: - target_item: FileItem = transferinfo.target_item - logger.debug(f"target_item: {target_item}") - file_list = target_item.file_list_new or [] + file_list = transferinfo.file_list_new or [] + logger.debug(f"target_item: {file_list}") # 过滤掉不存在的文件 file_list = [file for file in file_list if Path(file).exists()] if not file_list: From 455d58b3b532bb9100680f42337d5b47d9ff48fd Mon Sep 17 00:00:00 2001 From: LongShengWen Date: Wed, 20 Nov 2024 11:25:25 +0800 Subject: [PATCH 187/218] =?UTF-8?q?=E7=9F=AD=E5=89=A7=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E8=B0=83=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 2 +- plugins.v2/playletcategory_v2/__init__.py | 25 +++++++++++------------ 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/package.v2.json b/package.v2.json index a463ad4..aa705d4 100644 --- a/package.v2.json +++ b/package.v2.json @@ -256,7 +256,7 @@ "name": "短剧自动分类", "description": "网络短剧自动整理到独立的分类目录。", "labels": "文件整理", - "version": "2.10", + "version": "2.11", "icon": "Amule_A.png", "author": "longqiuyu", "level": 1, diff --git a/plugins.v2/playletcategory_v2/__init__.py b/plugins.v2/playletcategory_v2/__init__.py index 4476f2e..e1f18e2 100644 --- a/plugins.v2/playletcategory_v2/__init__.py +++ b/plugins.v2/playletcategory_v2/__init__.py @@ -26,7 +26,7 @@ class PlayletCategory_v2(_PluginBase): # 插件图标 plugin_icon = "Amule_A.png" # 插件版本 - plugin_version = "2.10" + plugin_version = "2.11" # 插件作者 plugin_author = "longqiuyu" # 作者主页 @@ -203,12 +203,12 @@ class PlayletCategory_v2(_PluginBase): """ 根据事件实时刮削剧集组信息 """ - logger.info(f"触发短剧分类!") + logger.debug(f"触发短剧分类!") if not event: - logger.info(f"短剧分类异常:{event}") + logger.debug(f"短剧分类异常:{event}") return if not self.get_state(): - logger.info(f"短剧分类异常:{event}") + logger.debug(f"短剧分类插件配置不完整!") return try: event_data = event.event_data @@ -228,11 +228,9 @@ class PlayletCategory_v2(_PluginBase): if mediainfo.type != MediaType.TV: logger.info(f"{target_path} 不是电视剧,跳过分类处理") return - logger.info("开始整理!") # 加锁 with lock: file_list = transferinfo.file_list_new or [] - logger.debug(f"target_item: {file_list}") # 过滤掉不存在的文件 file_list = [file for file in file_list if Path(file).exists()] if not file_list: @@ -290,37 +288,38 @@ class PlayletCategory_v2(_PluginBase): """ logger.info(f"target_path: {target_path}") if not target_path.exists(): + logger.warning(f"目标路径 {target_path} 不存在,跳过处理。") return if target_path.is_file(): target_path = target_path.parent # 剧集的根目录 - tv_path = target_path.parent + tv_path = target_path logger.info(f"{tv_path}") # 新的文件目录 - new_path = Path(self._category_dir) / tv_path.name + new_path = Path(self._category_dir) / target_path.name logger.info(f"{new_path}") if not new_path.exists(): # 移动目录 try: - shutil.move(tv_path, new_path) + shutil.move(target_path, new_path) except Exception as e: logger.error(f"移动文件失败:{e}") return else: # 遍历目录下的所有文件,并移动到目的目录 - for file in tv_path.iterdir(): + for file in target_path.iterdir(): if file.is_file(): try: # 相对路径 - relative_path = file.relative_to(tv_path) + relative_path = file.relative_to(target_path) shutil.move(file, new_path / relative_path) except Exception as e: logger.error(f"移动文件失败:{e}") return # 删除空目录 - if not SystemUtils.list_files(tv_path, extensions=settings.RMT_MEDIAEXT + settings.DOWNLOAD_TMPEXT): + if not SystemUtils.list_files(target_path, extensions=settings.RMT_MEDIAEXT + settings.DOWNLOAD_TMPEXT): try: - shutil.rmtree(tv_path, ignore_errors=True) + shutil.rmtree(target_path, ignore_errors=True) except Exception as e: logger.error(f"删除空目录失败:{e}") From 12952785d867e25b4d973dd8bf012f4b06580c7f Mon Sep 17 00:00:00 2001 From: LongShengWen Date: Wed, 20 Nov 2024 12:38:13 +0800 Subject: [PATCH 188/218] =?UTF-8?q?=E7=9F=AD=E5=89=A7=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E8=B0=83=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 2 +- plugins.v2/playletcategory_v2/__init__.py | 50 +++++++++++++++-------- 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/package.v2.json b/package.v2.json index aa705d4..4491144 100644 --- a/package.v2.json +++ b/package.v2.json @@ -256,7 +256,7 @@ "name": "短剧自动分类", "description": "网络短剧自动整理到独立的分类目录。", "labels": "文件整理", - "version": "2.11", + "version": "2.12", "icon": "Amule_A.png", "author": "longqiuyu", "level": 1, diff --git a/plugins.v2/playletcategory_v2/__init__.py b/plugins.v2/playletcategory_v2/__init__.py index e1f18e2..b3f16d2 100644 --- a/plugins.v2/playletcategory_v2/__init__.py +++ b/plugins.v2/playletcategory_v2/__init__.py @@ -26,7 +26,7 @@ class PlayletCategory_v2(_PluginBase): # 插件图标 plugin_icon = "Amule_A.png" # 插件版本 - plugin_version = "2.11" + plugin_version = "2.12" # 插件作者 plugin_author = "longqiuyu" # 作者主页 @@ -212,25 +212,28 @@ class PlayletCategory_v2(_PluginBase): return try: event_data = event.event_data - mediainfo: MediaInfo = event_data.get("mediainfo") - transferinfo: TransferInfo = event_data.get("transferinfo") + media_info: MediaInfo = event_data.get("mediainfo") + transfer_info: TransferInfo = event_data.get("transferinfo") logger.info(transferinfo.target_diritem) logger.info(transferinfo.target_diritem.path) - if not mediainfo or not transferinfo: + if not media_info or not transfer_info: return - if not transferinfo.target_diritem.path: - logger.debug(f"文件路径不存在:{transferinfo.target_diritem.path}") + if not transfer_info.success: + logger.debug(f"整理失败不做处理!") return - target_path = Path(transferinfo.target_diritem.path) + if not transfer_info.target_diritem.path: + logger.debug(f"文件路径不存在:{transfer_info.target_diritem.path}") + return + target_path = Path(transfer_info.target_diritem.path) if not target_path.exists(): logger.debug(f"文件路径不存在:{target_path}") return - if mediainfo.type != MediaType.TV: + if media_info.type != MediaType.TV: logger.info(f"{target_path} 不是电视剧,跳过分类处理") return # 加锁 with lock: - file_list = transferinfo.file_list_new or [] + file_list = transfer_info.file_list_new or [] # 过滤掉不存在的文件 file_list = [file for file in file_list if Path(file).exists()] if not file_list: @@ -254,8 +257,11 @@ class PlayletCategory_v2(_PluginBase): logger.info(f"{file} 时长:{duration} 分钟") if need_category: logger.info(f"{target_path} 需要分类处理,开始移动文件...") - self.__move_files(target_path=target_path) - logger.info(f"{target_path} 短剧分类处理完成") + result = self.__move_files(target_path=target_path) + if result: + logger.info(f"{target_path} 短剧分类处理完成") + else: + logger.info(f"{target_path} 短剧分类移动失败!") else: logger.info(f"{target_path} 不是短剧,无需分类处理") except Exception as e: @@ -281,7 +287,7 @@ class PlayletCategory_v2(_PluginBase): # 获取视频时长(秒),转换为分钟 return round(float(output) / 60, 1) - def __move_files(self, target_path: Path): + def __move_files(self, target_path: Path) -> bool: """ 移动文件到分类目录 :param target_path: 电视剧时为季的目录 @@ -289,7 +295,7 @@ class PlayletCategory_v2(_PluginBase): logger.info(f"target_path: {target_path}") if not target_path.exists(): logger.warning(f"目标路径 {target_path} 不存在,跳过处理。") - return + return False if target_path.is_file(): target_path = target_path.parent # 剧集的根目录 @@ -304,18 +310,29 @@ class PlayletCategory_v2(_PluginBase): shutil.move(target_path, new_path) except Exception as e: logger.error(f"移动文件失败:{e}") - return + return False else: # 遍历目录下的所有文件,并移动到目的目录 for file in target_path.iterdir(): + logger.info("f{file}") if file.is_file(): try: # 相对路径 relative_path = file.relative_to(target_path) - shutil.move(file, new_path / relative_path) + logger.info(f"relative_path:{to_path}") + to_path = new_path / relative_path + logger.info(f"to_path:{to_path}") + shutil.move(file, to_path) except Exception as e: logger.error(f"移动文件失败:{e}") - return + return False + else: + # 整季移动 + try: + shutil.move(file, new_path) + except Exception as e: + logger.error(f"移动文件失败:{e}") + return False # 删除空目录 if not SystemUtils.list_files(target_path, extensions=settings.RMT_MEDIAEXT + settings.DOWNLOAD_TMPEXT): try: @@ -330,6 +347,7 @@ class PlayletCategory_v2(_PluginBase): title="【短剧自动分类】", text=f"已将 {tv_path.name} 分类到 {self._category_dir} 目录", ) + return True def stop_service(self): """ From c66c9be10de3f7cb6c3ede3589b6f212e66fb57a Mon Sep 17 00:00:00 2001 From: LongShengWen Date: Wed, 20 Nov 2024 12:45:53 +0800 Subject: [PATCH 189/218] =?UTF-8?q?=E7=9F=AD=E5=89=A7=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E8=B0=83=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 2 +- plugins.v2/playletcategory_v2/__init__.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/package.v2.json b/package.v2.json index 4491144..edca0c6 100644 --- a/package.v2.json +++ b/package.v2.json @@ -256,7 +256,7 @@ "name": "短剧自动分类", "description": "网络短剧自动整理到独立的分类目录。", "labels": "文件整理", - "version": "2.12", + "version": "2.13", "icon": "Amule_A.png", "author": "longqiuyu", "level": 1, diff --git a/plugins.v2/playletcategory_v2/__init__.py b/plugins.v2/playletcategory_v2/__init__.py index b3f16d2..95f694c 100644 --- a/plugins.v2/playletcategory_v2/__init__.py +++ b/plugins.v2/playletcategory_v2/__init__.py @@ -26,7 +26,7 @@ class PlayletCategory_v2(_PluginBase): # 插件图标 plugin_icon = "Amule_A.png" # 插件版本 - plugin_version = "2.12" + plugin_version = "2.13" # 插件作者 plugin_author = "longqiuyu" # 作者主页 @@ -214,8 +214,7 @@ class PlayletCategory_v2(_PluginBase): event_data = event.event_data media_info: MediaInfo = event_data.get("mediainfo") transfer_info: TransferInfo = event_data.get("transferinfo") - logger.info(transferinfo.target_diritem) - logger.info(transferinfo.target_diritem.path) + logger.debug(transfer_info) if not media_info or not transfer_info: return if not transfer_info.success: From b26e2bdd46bd394436652b394daaa0cb207eaa9f Mon Sep 17 00:00:00 2001 From: LongShengWen Date: Wed, 20 Nov 2024 13:02:21 +0800 Subject: [PATCH 190/218] =?UTF-8?q?=E7=9F=AD=E5=89=A7=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E8=B0=83=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 2 +- plugins.v2/playletcategory_v2/__init__.py | 17 ++++++++--------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/package.v2.json b/package.v2.json index edca0c6..12f844a 100644 --- a/package.v2.json +++ b/package.v2.json @@ -256,7 +256,7 @@ "name": "短剧自动分类", "description": "网络短剧自动整理到独立的分类目录。", "labels": "文件整理", - "version": "2.13", + "version": "2.14", "icon": "Amule_A.png", "author": "longqiuyu", "level": 1, diff --git a/plugins.v2/playletcategory_v2/__init__.py b/plugins.v2/playletcategory_v2/__init__.py index 95f694c..711d4bf 100644 --- a/plugins.v2/playletcategory_v2/__init__.py +++ b/plugins.v2/playletcategory_v2/__init__.py @@ -26,7 +26,7 @@ class PlayletCategory_v2(_PluginBase): # 插件图标 plugin_icon = "Amule_A.png" # 插件版本 - plugin_version = "2.13" + plugin_version = "2.14" # 插件作者 plugin_author = "longqiuyu" # 作者主页 @@ -157,7 +157,7 @@ class PlayletCategory_v2(_PluginBase): 'props': { 'model': 'delay', 'label': '入库延迟时间(秒)', - 'placeholder': '' + 'placeholder': '使用刮削尽量设置大一些' } } ] @@ -214,7 +214,7 @@ class PlayletCategory_v2(_PluginBase): event_data = event.event_data media_info: MediaInfo = event_data.get("mediainfo") transfer_info: TransferInfo = event_data.get("transferinfo") - logger.debug(transfer_info) + # logger.debug(transfer_info) if not media_info or not transfer_info: return if not transfer_info.success: @@ -291,7 +291,7 @@ class PlayletCategory_v2(_PluginBase): 移动文件到分类目录 :param target_path: 电视剧时为季的目录 """ - logger.info(f"target_path: {target_path}") + logger.debug(f"target_path: {target_path}") if not target_path.exists(): logger.warning(f"目标路径 {target_path} 不存在,跳过处理。") return False @@ -299,10 +299,9 @@ class PlayletCategory_v2(_PluginBase): target_path = target_path.parent # 剧集的根目录 tv_path = target_path - logger.info(f"{tv_path}") # 新的文件目录 new_path = Path(self._category_dir) / target_path.name - logger.info(f"{new_path}") + logger.debug(f"{new_path}") if not new_path.exists(): # 移动目录 try: @@ -313,14 +312,14 @@ class PlayletCategory_v2(_PluginBase): else: # 遍历目录下的所有文件,并移动到目的目录 for file in target_path.iterdir(): - logger.info("f{file}") + logger.debug(f"{file}") if file.is_file(): try: # 相对路径 relative_path = file.relative_to(target_path) - logger.info(f"relative_path:{to_path}") + logger.debug(f"relative_path:{to_path}") to_path = new_path / relative_path - logger.info(f"to_path:{to_path}") + logger.debug(f"to_path:{to_path}") shutil.move(file, to_path) except Exception as e: logger.error(f"移动文件失败:{e}") From c1c2a6c8106cdd27704f55eee9a930f14468308f Mon Sep 17 00:00:00 2001 From: LongShengWen Date: Wed, 20 Nov 2024 13:09:48 +0800 Subject: [PATCH 191/218] =?UTF-8?q?=E7=9F=AD=E5=89=A7=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E8=B0=83=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 2 +- plugins.v2/playletcategory_v2/__init__.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/package.v2.json b/package.v2.json index 12f844a..d502a49 100644 --- a/package.v2.json +++ b/package.v2.json @@ -256,7 +256,7 @@ "name": "短剧自动分类", "description": "网络短剧自动整理到独立的分类目录。", "labels": "文件整理", - "version": "2.14", + "version": "2.15", "icon": "Amule_A.png", "author": "longqiuyu", "level": 1, diff --git a/plugins.v2/playletcategory_v2/__init__.py b/plugins.v2/playletcategory_v2/__init__.py index 711d4bf..b1244e1 100644 --- a/plugins.v2/playletcategory_v2/__init__.py +++ b/plugins.v2/playletcategory_v2/__init__.py @@ -1,4 +1,5 @@ import random +import time import shutil import subprocess import threading @@ -26,7 +27,7 @@ class PlayletCategory_v2(_PluginBase): # 插件图标 plugin_icon = "Amule_A.png" # 插件版本 - plugin_version = "2.14" + plugin_version = "2.15" # 插件作者 plugin_author = "longqiuyu" # 作者主页 @@ -230,6 +231,9 @@ class PlayletCategory_v2(_PluginBase): if media_info.type != MediaType.TV: logger.info(f"{target_path} 不是电视剧,跳过分类处理") return + if self._delay > 0: + # 进行延迟 + time.sleep(self._delay) # 加锁 with lock: file_list = transfer_info.file_list_new or [] From d8cede10bf7e0407c154f88dc0c4026eff12f898 Mon Sep 17 00:00:00 2001 From: thsrite Date: Wed, 20 Nov 2024 14:37:08 +0800 Subject: [PATCH 192/218] =?UTF-8?q?fix=20=E4=BF=AE=E5=A4=8D=E7=A7=8D?= =?UTF-8?q?=E5=AD=90=E6=A0=A1=E9=AA=8C=E6=9C=8D=E5=8A=A1=E6=9C=AA=E7=94=9F?= =?UTF-8?q?=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 3 ++- plugins.v2/iyuuautoseed/__init__.py | 15 +++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package.v2.json b/package.v2.json index ed288c0..ddbd6a8 100644 --- a/package.v2.json +++ b/package.v2.json @@ -195,11 +195,12 @@ "name": "IYUU自动辅种", "description": "基于IYUU官方Api实现自动辅种。", "labels": "做种,IYUU", - "version": "2.1", + "version": "2.2", "icon": "IYUU.png", "author": "jxxghp", "level": 2, "history": { + "v2.2": "修复种子校验服务未生效", "v2.1": "调整IYUU最新域名", "v2.0": "兼容MoviePilot V2 版本" } diff --git a/plugins.v2/iyuuautoseed/__init__.py b/plugins.v2/iyuuautoseed/__init__.py index 5d1ffec..130da1c 100644 --- a/plugins.v2/iyuuautoseed/__init__.py +++ b/plugins.v2/iyuuautoseed/__init__.py @@ -33,7 +33,7 @@ class IYUUAutoSeed(_PluginBase): # 插件图标 plugin_icon = "IYUU.png" # 插件版本 - plugin_version = "2.1" + plugin_version = "2.2" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -140,15 +140,8 @@ class IYUUAutoSeed(_PluginBase): run_date=datetime.now( tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3) ) - # 关闭一次性开关 self._onlyonce = False - if self._scheduler.get_jobs(): - # 追加种子校验服务 - self._scheduler.add_job(self.check_recheck, 'interval', minutes=3) - # 启动服务 - self._scheduler.print_jobs() - self._scheduler.start() if self._clearcache: # 关闭清除缓存开关 @@ -156,6 +149,12 @@ class IYUUAutoSeed(_PluginBase): # 保存配置 self.__update_config() + # 追加种子校验服务 + self._scheduler.add_job(self.check_recheck, 'interval', minutes=3) + # 启动服务 + self._scheduler.print_jobs() + self._scheduler.start() + @property def service_infos(self) -> Optional[Dict[str, ServiceInfo]]: """ From 103dba71f85d29bb704498c85486c6adb269c485 Mon Sep 17 00:00:00 2001 From: LongShengWen Date: Wed, 20 Nov 2024 15:10:55 +0800 Subject: [PATCH 193/218] =?UTF-8?q?=E7=9F=AD=E5=89=A7=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E8=B0=83=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 2 +- plugins.v2/playletcategory_v2/__init__.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.v2.json b/package.v2.json index d502a49..2747cc7 100644 --- a/package.v2.json +++ b/package.v2.json @@ -256,7 +256,7 @@ "name": "短剧自动分类", "description": "网络短剧自动整理到独立的分类目录。", "labels": "文件整理", - "version": "2.15", + "version": "2.16", "icon": "Amule_A.png", "author": "longqiuyu", "level": 1, diff --git a/plugins.v2/playletcategory_v2/__init__.py b/plugins.v2/playletcategory_v2/__init__.py index b1244e1..a62d7bd 100644 --- a/plugins.v2/playletcategory_v2/__init__.py +++ b/plugins.v2/playletcategory_v2/__init__.py @@ -27,7 +27,7 @@ class PlayletCategory_v2(_PluginBase): # 插件图标 plugin_icon = "Amule_A.png" # 插件版本 - plugin_version = "2.15" + plugin_version = "2.16" # 插件作者 plugin_author = "longqiuyu" # 作者主页 @@ -41,7 +41,7 @@ class PlayletCategory_v2(_PluginBase): _enabled = False _notify = True - _delay = 0 + _delay: int = 0 _category_dir = "" _episode_duration = 8 From ddf0504b28463bcb0aed5a78fe365e1d6f42028b Mon Sep 17 00:00:00 2001 From: LongShengWen Date: Wed, 20 Nov 2024 15:23:16 +0800 Subject: [PATCH 194/218] =?UTF-8?q?=E7=9F=AD=E5=89=A7=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E8=B0=83=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 2 +- plugins.v2/playletcategory_v2/__init__.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.v2.json b/package.v2.json index 2747cc7..63c51a3 100644 --- a/package.v2.json +++ b/package.v2.json @@ -256,7 +256,7 @@ "name": "短剧自动分类", "description": "网络短剧自动整理到独立的分类目录。", "labels": "文件整理", - "version": "2.16", + "version": "2.17", "icon": "Amule_A.png", "author": "longqiuyu", "level": 1, diff --git a/plugins.v2/playletcategory_v2/__init__.py b/plugins.v2/playletcategory_v2/__init__.py index a62d7bd..d2e6751 100644 --- a/plugins.v2/playletcategory_v2/__init__.py +++ b/plugins.v2/playletcategory_v2/__init__.py @@ -27,7 +27,7 @@ class PlayletCategory_v2(_PluginBase): # 插件图标 plugin_icon = "Amule_A.png" # 插件版本 - plugin_version = "2.16" + plugin_version = "2.17" # 插件作者 plugin_author = "longqiuyu" # 作者主页 @@ -231,9 +231,9 @@ class PlayletCategory_v2(_PluginBase): if media_info.type != MediaType.TV: logger.info(f"{target_path} 不是电视剧,跳过分类处理") return - if self._delay > 0: + if int(self._delay) > 0: # 进行延迟 - time.sleep(self._delay) + time.sleep(int(self._delay)) # 加锁 with lock: file_list = transfer_info.file_list_new or [] From 8b36f75126bca0265cb574bf30806eb1fca4268f Mon Sep 17 00:00:00 2001 From: LongShengWen Date: Wed, 20 Nov 2024 15:37:37 +0800 Subject: [PATCH 195/218] =?UTF-8?q?=E7=9F=AD=E5=89=A7=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E8=A7=86=E9=A2=91V2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- package.v2.json | 6 +++--- .../__init__.py | 11 +++++------ 3 files changed, 9 insertions(+), 10 deletions(-) rename plugins.v2/{playletcategory_v2 => playletcategory}/__init__.py (98%) diff --git a/package.json b/package.json index c81332d..c9eb20a 100644 --- a/package.json +++ b/package.json @@ -653,7 +653,7 @@ "icon": "Amule_A.png", "author": "jxxghp", "level": 1, - "v2": true, + // "v2": true, "history": { "v2.0": "适配新的目录结构变化,短剧分类名称调整为配置目录路径,升级后需要重新调整设置后才能使用。" } diff --git a/package.v2.json b/package.v2.json index 63c51a3..855a0e4 100644 --- a/package.v2.json +++ b/package.v2.json @@ -252,13 +252,13 @@ "v2.0": "兼容MoviePilot V2" } }, - "PlayletCategory_v2": { + "PlayletCategory": { "name": "短剧自动分类", "description": "网络短剧自动整理到独立的分类目录。", "labels": "文件整理", - "version": "2.17", + "version": "2.1", "icon": "Amule_A.png", - "author": "longqiuyu", + "author": "jxxghp,longqiuyu", "level": 1, "history": { "v2.1": "兼容MoviePilot V2", diff --git a/plugins.v2/playletcategory_v2/__init__.py b/plugins.v2/playletcategory/__init__.py similarity index 98% rename from plugins.v2/playletcategory_v2/__init__.py rename to plugins.v2/playletcategory/__init__.py index d2e6751..109d4e0 100644 --- a/plugins.v2/playletcategory_v2/__init__.py +++ b/plugins.v2/playletcategory/__init__.py @@ -19,7 +19,7 @@ from app.utils.system import SystemUtils lock = threading.Lock() -class PlayletCategory_v2(_PluginBase): +class PlayletCategory(_PluginBase): # 插件名称 plugin_name = "短剧自动分类" # 插件描述 @@ -27,13 +27,13 @@ class PlayletCategory_v2(_PluginBase): # 插件图标 plugin_icon = "Amule_A.png" # 插件版本 - plugin_version = "2.17" + plugin_version = "2.1" # 插件作者 - plugin_author = "longqiuyu" + plugin_author = "jxxghp,longqiuyu" # 作者主页 - author_url = "https://github.com/LongShengWen" + author_url = "https://github.com/jxxghp" # 插件配置项ID前缀 - plugin_config_prefix = "playletcategory_v2_" + plugin_config_prefix = "playletcategory_" # 加载顺序 plugin_order = 29 # 可使用的用户级别 @@ -215,7 +215,6 @@ class PlayletCategory_v2(_PluginBase): event_data = event.event_data media_info: MediaInfo = event_data.get("mediainfo") transfer_info: TransferInfo = event_data.get("transferinfo") - # logger.debug(transfer_info) if not media_info or not transfer_info: return if not transfer_info.success: From 754ee90a1389ca7f690ee880fe7ed95bd3350d31 Mon Sep 17 00:00:00 2001 From: LongShengWen Date: Wed, 20 Nov 2024 15:37:58 +0800 Subject: [PATCH 196/218] =?UTF-8?q?=E7=9F=AD=E5=89=A7=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E8=A7=86=E9=A2=91V2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index c9eb20a..0871bda 100644 --- a/package.json +++ b/package.json @@ -653,7 +653,6 @@ "icon": "Amule_A.png", "author": "jxxghp", "level": 1, - // "v2": true, "history": { "v2.0": "适配新的目录结构变化,短剧分类名称调整为配置目录路径,升级后需要重新调整设置后才能使用。" } From 4f8825c3838008294e348b4136484229bb526066 Mon Sep 17 00:00:00 2001 From: LongShengWen Date: Wed, 20 Nov 2024 15:40:28 +0800 Subject: [PATCH 197/218] =?UTF-8?q?=E7=9F=AD=E5=89=A7=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E8=A7=86=E9=A2=91V2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins.v2/chinesesubfinder/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins.v2/chinesesubfinder/__init__.py b/plugins.v2/chinesesubfinder/__init__.py index 71a3b6e..eb80ff7 100644 --- a/plugins.v2/chinesesubfinder/__init__.py +++ b/plugins.v2/chinesesubfinder/__init__.py @@ -23,8 +23,7 @@ class ChineseSubFinder(_PluginBase): # 插件版本 plugin_version = "2.0" # 插件作者 - # plugin_author = "jxxghp" - plugin_author = "longqiuyu" + plugin_author = "jxxghp" # 作者主页 author_url = "https://github.com/jxxghp" # 插件配置项ID前缀 From e5ac383636083f9a8ef978a048e7d14950953ae9 Mon Sep 17 00:00:00 2001 From: Alex Liu Date: Thu, 21 Nov 2024 00:41:14 +0800 Subject: [PATCH 198/218] =?UTF-8?q?feat(CleanInvalidSeed):=20=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=20MP=20v2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 12 + plugins.v2/cleaninvalidseed/__init__.py | 1002 +++++++++++++++++++++++ 2 files changed, 1014 insertions(+) create mode 100644 plugins.v2/cleaninvalidseed/__init__.py diff --git a/package.v2.json b/package.v2.json index ed288c0..fa1a9d1 100644 --- a/package.v2.json +++ b/package.v2.json @@ -251,5 +251,17 @@ "history": { "v2.0": "兼容MoviePilot V2" } + }, + "CleanInvalidSeed": { + "name": "清理QB无效做种", + "description": "清理已经被站点删除的种子及对应源文件,仅支持QB", + "labels": "Qbittorrent", + "version": "2.0", + "icon": "clean_a.png", + "author": "DzAvril", + "level": 1, + "history": { + "v2.0": "适配 MoviePilot V2" + } } } \ No newline at end of file diff --git a/plugins.v2/cleaninvalidseed/__init__.py b/plugins.v2/cleaninvalidseed/__init__.py new file mode 100644 index 0000000..8f2c820 --- /dev/null +++ b/plugins.v2/cleaninvalidseed/__init__.py @@ -0,0 +1,1002 @@ +import glob +import os +import shutil +import time +from datetime import datetime, timedelta +from pathlib import Path + +import pytz +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from app.utils.string import StringUtils +from app.schemas.types import EventType +from app.schemas import ServiceInfo +from app.core.event import eventmanager, Event + +from app.core.config import settings +from app.plugins import _PluginBase +from typing import Any, List, Dict, Tuple, Optional +from app.log import logger +from app.schemas import NotificationType +from app.helper.downloader import DownloaderHelper + +class CleanInvalidSeed(_PluginBase): + # 插件名称 + plugin_name = "清理QB无效做种" + # 插件描述 + plugin_desc = "清理已经被站点删除的种子及源文件,仅支持QB" + # 插件图标 + plugin_icon = "clean_a.png" + # 插件版本 + plugin_version = "2.0" + # 插件作者 + plugin_author = "DzAvril" + # 作者主页 + author_url = "https://github.com/DzAvril" + # 插件配置项ID前缀 + plugin_config_prefix = "cleaninvalidseed" + # 加载顺序 + plugin_order = 1 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled = False + _cron = None + _notify = False + _onlyonce = False + _detect_invalid_files = False + _delete_invalid_files = False + _delete_invalid_torrents = False + _notify_all = False + _label_only = False + _label = "" + _download_dirs = "" + _exclude_keywords = "" + _exclude_categories = "" + _exclude_labels = "" + _more_logs = False + # 定时器 + _scheduler: Optional[BackgroundScheduler] = None + _error_msg = [ + "torrent not registered with this tracker", + "Torrent not registered with this tracker", + "torrent banned", + "err torrent banned", + ] + _custom_error_msg = "" + + def init_plugin(self, config: dict = None): + self.downloader_helper = DownloaderHelper() + # 停止现有任务 + self.stop_service() + + if config: + self._enabled = config.get("enabled") + self._cron = config.get("cron") + self._notify = config.get("notify") + self._onlyonce = config.get("onlyonce") + self._delete_invalid_torrents = config.get("delete_invalid_torrents") + self._delete_invalid_files = config.get("delete_invalid_files") + self._detect_invalid_files = config.get("detect_invalid_files") + self._notify_all = config.get("notify_all") + self._label_only = config.get("label_only") + self._label = config.get("label") + self._download_dirs = config.get("download_dirs") + self._exclude_keywords = config.get("exclude_keywords") + self._exclude_categories = config.get("exclude_categories") + self._exclude_labels = config.get("exclude_labels") + self._custom_error_msg = config.get("custom_error_msg") + self._more_logs = config.get("more_logs") + self._downloaders = config.get("downloaders") + + # 加载模块 + if self._onlyonce: + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + logger.info(f"清理无效种子服务启动,立即运行一次") + self._scheduler.add_job( + func=self.clean_invalid_seed, + trigger="date", + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + + timedelta(seconds=3), + name="清理无效种子", + ) + # 关闭一次性开关 + self._onlyonce = False + self._update_config() + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def get_state(self) -> bool: + return self._enabled + + def _update_config(self): + self.update_config( + { + "onlyonce": False, + "cron": self._cron, + "enabled": self._enabled, + "notify": self._notify, + "delete_invalid_torrents": self._delete_invalid_torrents, + "delete_invalid_files": self._delete_invalid_files, + "detect_invalid_files": self._detect_invalid_files, + "notify_all": self._notify_all, + "label_only": self._label_only, + "label": self._label, + "download_dirs": self._download_dirs, + "exclude_keywords": self._exclude_keywords, + "exclude_categories": self._exclude_categories, + "exclude_labels": self._exclude_labels, + "custom_error_msg": self._custom_error_msg, + "more_logs": self._more_logs, + "downloaders": self._downloaders, + } + ) + + @property + def service_info(self) -> Optional[ServiceInfo]: + """ + 服务信息 + """ + if not self._downloaders: + logger.warning("尚未配置下载器,请检查配置") + return None + + services = self.downloader_helper.get_services(name_filters=self._downloaders) + + if not services: + logger.warning("获取下载器实例失败,请检查配置") + return None + + active_services = {} + for service_name, service_info in services.items(): + if service_info.instance.is_inactive(): + logger.warning(f"下载器 {service_name} 未连接,请检查配置") + elif not self.check_is_qb(service_info): + logger.warning(f"不支持的下载器类型 {service_name},仅支持QB,请检查配置") + else: + active_services[service_name] = service_info + + if not active_services: + logger.warning("没有已连接的下载器,请检查配置") + return None + + return active_services + + def check_is_qb(self, service_info) -> bool: + """ + 检查下载器类型是否为 qbittorrent 或 transmission + """ + if self.downloader_helper.is_downloader(service_type="qbittorrent", service=service_info): + return True + + return False + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + """ + 定义远程控制命令 + :return: 命令关键字、事件、描述、附带数据 + """ + return [ + { + "cmd": "/detect_invalid_torrents", + "event": EventType.PluginAction, + "desc": "检测无效做种", + "category": "QB", + "data": {"action": "detect_invalid_torrents"}, + }, + { + "cmd": "/delete_invalid_torrents", + "event": EventType.PluginAction, + "desc": "清理无效做种", + "category": "QB", + "data": {"action": "delete_invalid_torrents"}, + }, + { + "cmd": "/detect_invalid_files", + "event": EventType.PluginAction, + "desc": "检测无效源文件", + "category": "QB", + "data": {"action": "detect_invalid_files"}, + }, + { + "cmd": "/delete_invalid_files", + "event": EventType.PluginAction, + "desc": "清理无效源文件", + "category": "QB", + "data": {"action": "delete_invalid_files"}, + }, + { + "cmd": "/toggle_notify_all", + "event": EventType.PluginAction, + "desc": "QB清理插件切换全量通知", + "category": "QB", + "data": {"action": "toggle_notify_all"}, + }, + ] + + @eventmanager.register(EventType.PluginAction) + def handle_commands(self, event: Event): + if event: + event_data = event.event_data + if event_data: + if not ( + event_data.get("action") == "detect_invalid_torrents" + or event_data.get("action") == "delete_invalid_torrents" + or event_data.get("action") == "detect_invalid_files" + or event_data.get("action") == "delete_invalid_files" + or event_data.get("action") == "toggle_notify_all" + ): + return + self.post_message( + channel=event.event_data.get("channel"), + title="开始执行远程命令...", + userid=event.event_data.get("user"), + ) + old_delete_invalid_torrents = self._delete_invalid_torrents + old_detect_invalid_files = self._detect_invalid_files + old_delete_invalid_files = self._delete_invalid_files + if event_data.get("action") == "detect_invalid_torrents": + logger.info("收到远程命令,开始检测无效做种") + self._delete_invalid_torrents = False + self._detect_invalid_files = False + self._delete_invalid_files = False + self.clean_invalid_seed() + elif event_data.get("action") == "delete_invalid_torrents": + logger.info("收到远程命令,开始清理无效做种") + self._delete_invalid_torrents = True + self._detect_invalid_files = False + self._delete_invalid_files = False + self.clean_invalid_seed() + elif event_data.get("action") == "detect_invalid_files": + logger.info("收到远程命令,开始检测无效源文件") + self._delete_invalid_files = False + self.detect_invalid_files() + elif event_data.get("action") == "delete_invalid_files": + logger.info("收到远程命令,开始清理无效源文件") + self._delete_invalid_files = True + self.detect_invalid_files() + elif event_data.get("action") == "toggle_notify_all": + self._notify_all = not self._notify_all + self._update_config() + if self._notify_all: + self.post_message( + channel=event.event_data.get("channel"), + title="已开启全量通知", + userid=event.event_data.get("user"), + ) + else: + self.post_message( + channel=event.event_data.get("channel"), + title="已关闭全量通知", + userid=event.event_data.get("user"), + ) + return + else: + logger.error("收到未知远程命令") + return + self._delete_invalid_torrents = old_delete_invalid_torrents + self._detect_invalid_files = old_detect_invalid_files + self._delete_invalid_files = old_delete_invalid_files + self.post_message( + channel=event.event_data.get("channel"), + title="远程命令执行完成!", + userid=event.event_data.get("user"), + ) + + 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 and self._cron: + return [ + { + "id": "CleanInvalidSeed", + "name": "清理QB无效做种", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self.clean_invalid_seed, + "kwargs": {}, + } + ] + + def get_all_torrents(self, service): + downloader_name = service.name + downloader_obj = service.instance + all_torrents, error = downloader_obj.get_torrents() + + if error: + logger.error(f"获取下载器:{downloader_name}种子失败: {error}") + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【清理QB无效做种】", + text=f"获取下载器:{downloader_name}种子失败,请检查下载器配置", + ) + return [] + + if not all_torrents: + logger.warning(f"下载器:{downloader_name}") + if self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【清理QB无效做种】", + text=f"下载器:{downloader_name}中没有种子", + ) + return [] + return all_torrents + + def clean_invalid_seed(self): + for service in self.service_info.values(): + downloader_name = service.name + downloader_obj = service.instance + if not downloader_obj: + logger.error(f"{self.LOG_TAG} 获取下载器失败 {downloader_name}") + continue + logger.info(f"开始清理 {downloader_name} 无效做种...") + all_torrents = self.get_all_torrents(service) + temp_invalid_torrents = [] + # tracker未工作,但暂时不能判定为失效做种,需人工判断 + tracker_not_working_torrents = [] + working_tracker_set = set() + exclude_categories = ( + self._exclude_categories.split("\n") if self._exclude_categories else [] + ) + exclude_labels = ( + self._exclude_labels.split("\n") if self._exclude_labels else [] + ) + custom_msgs = ( + self._custom_error_msg.split("\n") if self._custom_error_msg else [] + ) + error_msgs = self._error_msg + custom_msgs + # 第一轮筛选出所有未工作的种子 + for torrent in all_torrents: + trackers = torrent.trackers + is_invalid = True + is_tracker_working = False + for tracker in trackers: + if tracker.get("tier") == -1: + continue + tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1] + # 有一个tracker工作即为有效做种 + if (tracker.get("status") == 2) or (tracker.get("status") == 3): + is_tracker_working = True + + if not ( + (tracker.get("status") == 4) and (tracker.get("msg") in error_msgs) + ): + is_invalid = False + working_tracker_set.add(tracker_domian) + + if self._more_logs: + logger.info(f"处理 [{torrent.name}] tracker [{tracker_domian}]: 分类: [{torrent.category}], 标签: [{torrent.tags}], 状态: [{tracker.get('status')}], msg: [{tracker.get('msg')}], is_invalid: [{is_invalid}], is_working: [{is_tracker_working}]") + if is_invalid: + temp_invalid_torrents.append(torrent) + elif not is_tracker_working: + # 排除已暂停的种子 + if not torrent.state_enum.is_paused: + tracker_not_working_torrents.append(torrent) + + logger.info(f"初筛共有{len(temp_invalid_torrents)}个无效做种") + # 第二轮筛选出tracker有正常工作种子而当前种子未工作的,避免因临时关站或tracker失效导致误删的问题 + # 失效做种但通过种子分类排除的种子 + invalid_torrents_exclude_categories = [] + # 失效做种但通过种子标签排除的种子 + invalid_torrents_exclude_labels = [] + # 将invalid_torrents基本信息保存起来,在种子被删除后依然可以打印这些信息 + invalid_torrent_tuple_list = [] + deleted_torrent_tuple_list = [] + for torrent in temp_invalid_torrents: + trackers = torrent.trackers + for tracker in trackers: + if tracker.get("tier") == -1: + continue + tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1] + if tracker_domian in working_tracker_set: + # tracker是正常的,说明该种子是无效的 + invalid_torrent_tuple_list.append( + ( + torrent.name, + torrent.category, + torrent.tags, + torrent.size, + tracker_domian, + tracker.msg, + ) + ) + if self._delete_invalid_torrents or self._label_only: + # 检查种子分类和标签是否排除 + is_excluded = False + if torrent.category in exclude_categories: + is_excluded = True + invalid_torrents_exclude_categories.append(torrent) + torrent_labels = [ + tag.strip() for tag in torrent.tags.split(",") + ] + for label in torrent_labels: + if label in exclude_labels: + is_excluded = True + invalid_torrents_exclude_labels.append(torrent) + if not is_excluded: + if self._label_only: + # 仅标记 + downloader_obj.set_torrents_tag(ids=torrent.get("hash"), tags=[self._label if self._label != "" else "无效做种"]) + else: + # 只删除种子不删除文件,以防其它站点辅种 + downloader_obj.delete_torrents(False, torrent.get("hash")) + # 标记已处理种子信息 + deleted_torrent_tuple_list.append( + ( + torrent.name, + torrent.category, + torrent.tags, + torrent.size, + tracker_domian, + tracker.msg, + ) + ) + break + invalid_msg = f"检测到{len(invalid_torrent_tuple_list)}个失效做种\n" + tracker_not_working_msg = f"检测到{len(tracker_not_working_torrents)}个tracker未工作做种,请检查种子状态\n" + + if self._label_only or self._delete_invalid_torrents: + if self._label_only: + deleted_msg = f"标记了{len(deleted_torrent_tuple_list)}个失效种子\n" + else: + deleted_msg = f"删除了{len(deleted_torrent_tuple_list)}个失效种子\n" + if len(exclude_categories) != 0: + exclude_categories_msg = f"分类排除{len(invalid_torrents_exclude_categories)}个失效种子未删除,请手动处理\n" + if len(exclude_labels) != 0: + exclude_labels_msg = f"标签排除{len(invalid_torrents_exclude_labels)}个失效种子未删除,请手动处理\n" + for index in range(len(invalid_torrent_tuple_list)): + torrent = invalid_torrent_tuple_list[index] + invalid_msg += f"{index + 1}. {torrent[0]},分类:{torrent[1]},标签:{torrent[2]}, 大小:{StringUtils.str_filesize(torrent[3])},Trackers: {torrent[4]}:{torrent[5]}\n" + + for index in range(len(tracker_not_working_torrents)): + torrent = tracker_not_working_torrents[index] + trackers = torrent.trackers + tracker_msg = "" + for tracker in trackers: + if tracker.get("tier") == -1: + continue + tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1] + tracker_msg += f" {tracker_domian}:{tracker.msg} " + tracker_not_working_msg += f"{index + 1}. {torrent.name},分类:{torrent.category},标签:{torrent.tags}, 大小:{StringUtils.str_filesize(torrent.size)},Trackers: {tracker_msg}\n" + + for index in range(len(invalid_torrents_exclude_categories)): + torrent = invalid_torrents_exclude_categories[index] + trackers = torrent.trackers + tracker_msg = "" + for tracker in trackers: + if tracker.get("tier") == -1: + continue + tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1] + tracker_msg += f" {tracker_domian}:{tracker.msg} " + exclude_categories_msg += f"{index + 1}. {torrent.name},分类:{torrent.category},标签:{torrent.tags}, 大小:{StringUtils.str_filesize(torrent.size)},Trackers: {tracker_msg}\n" + + for index in range(len(invalid_torrents_exclude_labels)): + torrent = invalid_torrents_exclude_labels[index] + trackers = torrent.trackers + tracker_msg = "" + for tracker in trackers: + if tracker.get("tier") == -1: + continue + tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1] + tracker_msg += f" {tracker_domian}:{tracker.msg} " + exclude_labels_msg += f"{index + 1}. {torrent.name},分类:{torrent.category},标签:{torrent.tags}, 大小:{StringUtils.str_filesize(torrent.size)},Trackers: {tracker_msg}\n" + + for index in range(len(deleted_torrent_tuple_list)): + torrent = deleted_torrent_tuple_list[index] + deleted_msg += f"{index + 1}. {torrent[0]},分类:{torrent[1]},标签:{torrent[2]}, 大小:{StringUtils.str_filesize(torrent[3])},Trackers: {torrent[4]}:{torrent[5]}\n" + + # 日志 + logger.info(invalid_msg) + logger.info(tracker_not_working_msg) + if self._delete_invalid_torrents: + logger.info(deleted_msg) + if len(exclude_categories) != 0: + logger.info(exclude_categories_msg) + if len(exclude_labels) != 0: + logger.info(exclude_labels_msg) + # 通知 + if self._notify: + invalid_msg = invalid_msg.replace("_", "\_") + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【清理无效做种】", + text=invalid_msg, + ) + if self._notify_all: + tracker_not_working_msg = tracker_not_working_msg.replace("_", "\_") + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【清理无效做种】", + text=tracker_not_working_msg, + ) + if self._label_only or self._delete_invalid_torrents: + deleted_msg = deleted_msg.replace("_", "\_") + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【清理无效做种】", + text=deleted_msg, + ) + if self._notify_all: + exclude_categories_msg = exclude_categories_msg.replace("_", "\_") + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【清理无效做种】", + text=exclude_categories_msg, + ) + exclude_labels_msg = exclude_labels_msg.replace("_", "\_") + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【清理无效做种】", + text=exclude_labels_msg, + ) + logger.info("检测无效做种任务结束") + if self._detect_invalid_files: + self.detect_invalid_files() + + def detect_invalid_files(self): + logger.info("开始检测未做种的无效源文件") + + all_torrents = [] + + for service in self.service_info.values(): + downloader_name = service.name + downloader_obj = service.instance + if not downloader_obj: + logger.error(f"{self.LOG_TAG} 获取下载器失败 {downloader_name}") + continue + all_torrents += self.get_all_torrents(service) + + source_path_map = {} + source_paths = [] + total_size = 0 + deleted_file_cnt = 0 + exclude_key_words = ( + self._exclude_keywords.split("\n") if self._exclude_keywords else [] + ) + if not self._download_dirs: + logger.error("未配置下载目录,无法检测未做种无效源文件") + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【检测无效源文件】", + text="未配置下载目录,无法检测未做种无效源文件", + ) + return + for path in self._download_dirs.split("\n"): + mp_path, qb_path = path.split(":") + source_path_map[mp_path] = qb_path + source_paths.append(mp_path) + # 所有做种源文件路径 + content_path_set = set() + for torrent in all_torrents: + content_path_set.add(torrent.content_path) + + message = "检测未做种无效源文件:\n" + for source_path_str in source_paths: + source_path = Path(source_path_str) + # 判断source_path是否存在 + if not source_path.exists(): + logger.error(f"{source_path} 不存在,无法检测未做种无效源文件") + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【检测无效源文件】", + text=f"{source_path} 不存在,无法检测未做种无效源文件", + ) + continue + source_files = [] + # 获取source_path下的所有文件包括文件夹 + for file in source_path.iterdir(): + source_files.append(file) + for source_file in source_files: + skip = False + for key_word in exclude_key_words: + if key_word in source_file.name: + logger.info(f"{str(source_file)}命中关键字{key_word},不做处理") + skip = True + break + if skip: + continue + # 将mp_path替换成 qb_path + qb_path = (str(source_file)).replace( + source_path_str, source_path_map[source_path_str] + ) + # todo: 优化性能 + is_exist = False + for content_path in content_path_set: + if qb_path in content_path: + is_exist = True + break + + if not is_exist: + deleted_file_cnt += 1 + message += f"{deleted_file_cnt}. {str(source_file)}\n" + total_size += self.get_size(source_file) + if self._delete_invalid_files: + if source_file.is_file(): + source_file.unlink() + elif source_file.is_dir(): + shutil.rmtree(source_file) + + message += f"检测到{deleted_file_cnt}个未做种的无效源文件,共占用{StringUtils.str_filesize(total_size)}空间。\n" + if self._delete_invalid_files: + message += f"***已删除无效源文件,释放{StringUtils.str_filesize(total_size)}空间!***\n" + logger.info(message) + if self._notify: + message = message.replace("_", "\_") + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【清理无效做种】", + text=message, + ) + logger.info("检测无效源文件任务结束") + + def get_size(self, path: Path): + total_size = 0 + if path.is_file(): + return path.stat().st_size + # rglob 方法用于递归遍历所有文件和目录 + for entry in path.rglob("*"): + if entry.is_file(): + total_size += entry.stat().st_size + return total_size + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + return [ + { + "component": "VForm", + "content": [ + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "enabled", + "label": "启用插件", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "notify", + "label": "开启通知", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "onlyonce", + "label": "立即运行一次", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "delete_invalid_torrents", + "label": "删除无效种子(确认无误后再开启)", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "detect_invalid_files", + "label": "检测无效源文件", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "delete_invalid_files", + "label": "删除无效源文件(确认无误后再开启)", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "notify_all", + "label": "全量通知", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "label_only", + "label": "仅标记模式(开启后不会删种)", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "more_logs", + "label": "打印更多日志", + }, + } + ], + }, + ], + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': True, + 'chips': True, + 'clearable': True, + 'model': 'downloaders', + 'label': '请选择下载器', + 'items': [{"title": config.name, "value": config.name} + for config in self.downloader_helper.get_configs().values()] + } + } + ] + } + ] + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": { "cols": 12, "md": 6 }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "cron", + "label": "执行周期", + }, + } + ], + }, + { + "component": "VCol", + "props": { "cols": 12, "md": 6 }, + "content": [ + { + "component": "VTextField", + "props": { + "model": "label", + "label": "增加标签", + "placeholder": "仅标记模式下生效,给待处理的种子打标签", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "download_dirs", + "label": "下载目录映射", + "rows": 2, + "placeholder": "填写要监控的源文件目录,并设置MP和QB的目录映射关系,如/mp/download:/qb/download,多个目录请换行", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "props": {"style": {"margin-top": "0px"}}, + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "exclude_keywords", + "label": "过滤删源文件关键字", + "rows": 2, + "placeholder": "多个关键字请换行,仅针对删除源文件", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "exclude_categories", + "label": "过滤删种分类", + "rows": 2, + "placeholder": "多个分类请换行,仅针对删除种子", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "exclude_labels", + "label": "过滤删种标签", + "rows": 2, + "placeholder": "多个标签请换行,仅针对删除种子", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "custom_error_msg", + "label": "自定义无效做种tracker错误信息", + "rows": 5, + "placeholder": "填入想要清理的种子的tracker错误信息,如'skipping tracker announce (unreachable)',多个信息请换行", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": { + "cols": 12, + }, + "content": [ + { + "component": "VAlert", + "props": { + "type": "error", + "variant": "tonal", + "text": "谨慎起见删除种子/源文件功能做了开关,请确认无误后再开启删除功能", + }, + } + ], + }, + { + "component": "VCol", + "props": { + "cols": 12, + }, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "下载目录映射填入源文件根目录,并设置MP和QB的目录映射关系。如某种子下载的源文件A存放路径为/qb/download/A,则目录映射填入/mp/download:/qb/download,多个目录请换行。注意映射目录不要有多余的'/'", + }, + } + ], + }, + ], + }, + ], + } + ], { + "enabled": False, + "notify": False, + "download_dirs": "", + "delete_invalid_torrents": False, + "delete_invalid_files": False, + "detect_invalid_files": False, + "notify_all": False, + "onlyonce": False, + "cron": "0 0 * * *", + "label_only": False, + "label": "", + "more_logs": False, + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) From 01219da72770a8cb72cd93a47d05bcf2404077a2 Mon Sep 17 00:00:00 2001 From: Alex Liu Date: Fri, 22 Nov 2024 15:35:21 +0800 Subject: [PATCH 199/218] =?UTF-8?q?fix:=20=E7=AB=99=E7=82=B9=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E7=BB=9F=E8=AE=A1=E7=99=BD=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 3 ++- plugins.v2/sitestatistic/__init__.py | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/package.v2.json b/package.v2.json index 0b317bf..ca83565 100644 --- a/package.v2.json +++ b/package.v2.json @@ -3,11 +3,12 @@ "name": "站点数据统计", "description": "站点统计数据图表。", "labels": "站点,仪表板", - "version": "1.1", + "version": "1.2", "icon": "statistic.png", "author": "lightolly,jxxghp", "level": 2, "history": { + "v1.2": "继续修复增量数据统计问题", "v1.1": "修复增量数据统计问题", "v1.0": "MoviePilot V2 版本站点数据统计插件" } diff --git a/plugins.v2/sitestatistic/__init__.py b/plugins.v2/sitestatistic/__init__.py index b2bd3a2..e623005 100644 --- a/plugins.v2/sitestatistic/__init__.py +++ b/plugins.v2/sitestatistic/__init__.py @@ -32,7 +32,7 @@ class SiteStatistic(_PluginBase): # 插件图标 plugin_icon = "statistic.png" # 插件版本 - plugin_version = "1.1" + plugin_version = "1.2" # 插件作者 plugin_author = "lightolly,jxxghp" # 作者主页 @@ -246,6 +246,18 @@ class SiteStatistic(_PluginBase): return value.isdigit() return False + def __to_numeric(value: any) -> int: + """ + 将值转换为整数 + """ + if isinstance(value, str): + return int(float(value)) + elif isinstance(value, float) or isinstance(value, int): + return int(value) + else: + logger.error(f'数据类型转换错误 ({value})') + return 0 + def __sub_data(d1: dict, d2: dict) -> dict: """ 计算两个字典相同Key值的差值(如果值为数字),返回新字典 @@ -254,7 +266,7 @@ class SiteStatistic(_PluginBase): return {} if not d2: return d1 - d = {k: d1.get(k) - d2.get(k) for k in d1 + d = {k: __to_numeric(d1.get(k)) - __to_numeric(d2.get(k)) for k in d1 if k in d2 and __is_digit(d1.get(k)) and __is_digit(d2.get(k))} # 把小于0的数据变成0 for k, v in d.items(): From 9c9c42a690bb6610c63e05d11fd2a78ab098567c Mon Sep 17 00:00:00 2001 From: DzAvril Date: Fri, 22 Nov 2024 16:20:30 +0800 Subject: [PATCH 200/218] Update zvideohelper to v1.4 --- package.json | 3 ++- plugins/zvideohelper/DoubanHelper.py | 2 +- plugins/zvideohelper/__init__.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 0871bda..1812a97 100644 --- a/package.json +++ b/package.json @@ -835,12 +835,13 @@ "name": "极影视助手", "description": "极影视功能扩展", "labels": "媒体库", - "version": "1.3", + "version": "1.4", "icon": "zvideo.png", "author": "DzAvril", "level": 1, "v2": true, "history": { + "v1.4": "修复请求失败后返回值数量不正确的问题", "v1.3": "降低对豆瓣接口的请求频率", "v1.2": "修复无法获取豆瓣评分的问题", "v1.1": "支持将极影视评分修改为豆瓣评分", diff --git a/plugins/zvideohelper/DoubanHelper.py b/plugins/zvideohelper/DoubanHelper.py index e144a38..a8ca05e 100644 --- a/plugins/zvideohelper/DoubanHelper.py +++ b/plugins/zvideohelper/DoubanHelper.py @@ -75,7 +75,7 @@ class DoubanHelper: response = RequestUtils(headers=self.headers).get_res(url) if not response.status_code == 200: logger.error(f"搜索 {title} 失败 状态码:{response.status_code}") - return None + return None, None, None # self.headers["Cookie"] = response.cookies soup = BeautifulSoup(response.text.encode('utf-8'), 'lxml') title_divs = soup.find_all("div", class_="title") diff --git a/plugins/zvideohelper/__init__.py b/plugins/zvideohelper/__init__.py index 2da1933..f2a455e 100644 --- a/plugins/zvideohelper/__init__.py +++ b/plugins/zvideohelper/__init__.py @@ -31,7 +31,7 @@ class ZvideoHelper(_PluginBase): # 插件图标 plugin_icon = "zvideo.png" # 插件版本 - plugin_version = "1.3" + plugin_version = "1.4" # 插件作者 plugin_author = "DzAvril" # 作者主页 From de6e8069e853308b9dcdcafc232b0da05124dc1b Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 23 Nov 2024 10:10:07 +0800 Subject: [PATCH 201/218] MoviePilotUpdateNotify V2 --- package.json | 1 - package.v2.json | 12 + plugins.v2/moviepilotupdatenotify/__init__.py | 375 ++++++++++++++++++ 3 files changed, 387 insertions(+), 1 deletion(-) create mode 100644 plugins.v2/moviepilotupdatenotify/__init__.py diff --git a/package.json b/package.json index 1812a97..da6ff03 100644 --- a/package.json +++ b/package.json @@ -464,7 +464,6 @@ "icon": "Moviepilot_A.png", "author": "thsrite", "level": 1, - "v2": true, "history": { "v1.4": "兼容更新内容带版本号的情况", "v1.3": "增加前端版本更新检查,需要主程序升级至v1.8.4+版本" diff --git a/package.v2.json b/package.v2.json index ca83565..7c552eb 100644 --- a/package.v2.json +++ b/package.v2.json @@ -278,5 +278,17 @@ "v2.1": "兼容MoviePilot V2", "v2.0": "适配新的目录结构变化,短剧分类名称调整为配置目录路径,升级后需要重新调整设置后才能使用。" } + }, + "MoviePilotUpdateNotify": { + "name": "MoviePilot更新推送", + "description": "MoviePilot推送release更新通知、自动重启。", + "labels": "消息通知,自动更新", + "version": "2.0", + "icon": "Moviepilot_A.png", + "author": "thsrite", + "level": 1, + "history": { + "v2.0": "兼容MoviePilot V2" + } } } \ No newline at end of file diff --git a/plugins.v2/moviepilotupdatenotify/__init__.py b/plugins.v2/moviepilotupdatenotify/__init__.py new file mode 100644 index 0000000..3841ce0 --- /dev/null +++ b/plugins.v2/moviepilotupdatenotify/__init__.py @@ -0,0 +1,375 @@ +import datetime +import re + +import pytz +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from app.chain.system import SystemChain +from app.core.config import settings +from app.plugins import _PluginBase +from typing import Any, List, Dict, Tuple, Optional +from app.log import logger +from app.schemas import NotificationType +from app.utils.http import RequestUtils +from app.utils.system import SystemUtils + + +class MoviePilotUpdateNotify(_PluginBase): + # 插件名称 + plugin_name = "MoviePilot更新推送" + # 插件描述 + plugin_desc = "MoviePilot推送release更新通知、自动重启。" + # 插件图标 + plugin_icon = "Moviepilot_A.png" + # 插件版本 + plugin_version = "2.0" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "moviepilotupdatenotify_" + # 加载顺序 + plugin_order = 25 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled = False + # 任务执行间隔 + _cron = None + _restart = False + _notify = False + _update_types = [] + + # 定时器 + _scheduler: Optional[BackgroundScheduler] = None + + def init_plugin(self, config: dict = None): + # 停止现有任务 + self.stop_service() + + if config: + self._enabled = config.get("enabled") + self._cron = config.get("cron") + self._restart = config.get("restart") + self._notify = config.get("notify") + self._update_types = config.get("update_types") or [] + + def __check_update(self): + """ + 检查MoviePilot更新 + """ + # 检查后端更新 + server_update = self.__check_server_update() if self._update_types and "后端" in self._update_types else False + + # 检查前端更新 + front_update = self.__check_front_update() if self._update_types and "前端" in self._update_types else False + + # 自动重启 + if (server_update or front_update) and self._restart: + logger.info("开始执行自动重启…") + SystemUtils.restart() + + def __check_server_update(self): + """ + 检查后端更新 + """ + release_version, description, update_time = self.__get_backend_latest() + if not release_version: + logger.error("后端最新版本获取失败") + return False + + # 本地版本 + local_version = SystemChain().get_server_local_version() + if local_version and release_version <= local_version: + logger.info(f"当前后端版本:{local_version} 远程版本:{release_version} 停止运行") + return False + + logger.info(f"发现MoviePilot后端更新:{release_version} {description} {update_time}") + + # 推送更新消息 + self.__notify_update(update_time=update_time, + release_version=release_version, + description=description, + mtype="后端") + + return True + + def __check_front_update(self): + """ + 检查前端更新 + """ + release_version, description, update_time = self.__get_front_latest() + if not release_version: + logger.error("前端最新版本获取失败") + return False + + # 本地版本 + local_version = SystemChain().get_frontend_version() + if local_version and release_version <= local_version: + logger.info(f"当前前端版本:{local_version} 远程版本:{release_version} 停止运行") + return False + + logger.info(f"发现MoviePilot前端更新:{release_version} {description} {update_time}") + + # 推送更新消息 + self.__notify_update(update_time=update_time, + release_version=release_version, + description=description, + mtype="前端") + + return True + + def __notify_update(self, update_time, release_version, description, mtype): + """ + 推送更新消息 + """ + # 推送更新消息 + if self._notify: + # 将时间字符串转为datetime对象 + dt = datetime.datetime.strptime(update_time, "%Y-%m-%dT%H:%M:%SZ") + # 设置时区 + timezone = pytz.timezone(settings.TZ) + dt = dt.replace(tzinfo=timezone) + # 将datetime对象转换为带时区的字符串 + update_time = dt.strftime("%Y-%m-%d %H:%M:%S") + if not description.startswith(release_version): + description = f"{release_version}\n\n{description}" + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【MoviePilot{mtype}更新通知】", + text=f"{description}\n\n{update_time}") + + @staticmethod + def __get_latest_version(repo_url: str) -> Optional[dict]: + """ + 获取最新版本 + """ + # 获取所有发布的版本列表 + response = RequestUtils( + proxies=settings.PROXY, + headers=settings.GITHUB_HEADERS + ).get_res(repo_url) + if response: + v2_releases = [r for r in response.json() if re.match(r"^v2\.", r['tag_name'])] + if not v2_releases: + logger.warn("未获取到最新版本号!") + return None + # 找到最新的v2版本 + latest_v2 = sorted(v2_releases, key=lambda s: list(map(int, re.findall(r'\d+', s['tag_name']))))[-1] + logger.info(f"获取到最新版本:{latest_v2}") + return latest_v2 + else: + logger.error("无法获取版本信息,请检查网络连接或GitHub API请求。") + return None + + def __get_backend_latest(self) -> Tuple[str, str, str]: + """ + 获取最新版本 + """ + result = self.__get_latest_version("https://api.github.com/repos/jxxghp/MoviePilot/releases") + if result: + return result['tag_name'], result['body'], result['published_at'] + return None, None, None + + def __get_front_latest(self): + """ + 获取前端最新版本 + """ + result = self.__get_latest_version("https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases") + if result: + return result['tag_name'], result['body'], result['published_at'] + return None, None, None + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + 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 and self._cron: + return [ + { + "id": "MoviePilotUpdateNotify", + "name": "MoviePilot更新检查服务", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self.__check_update, + "kwargs": {} + } + ] + return [] + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'restart', + 'label': '自动重启', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '发送通知', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '检查周期', + 'placeholder': '5位cron表达式' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': True, + 'chips': True, + 'model': 'update_types', + 'label': '更新类型', + 'items': [ + { + "title": "后端", + "vale": "后端" + }, + { + "title": "前端", + "vale": "前端" + } + ] + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '如要开启自动重启,请确认MOVIEPILOT_AUTO_UPDATE设置为true,重启即更新。' + } + } + ] + }, + ] + } + ] + } + ], { + "enabled": False, + "restart": False, + "notify": False, + "cron": "0 9 * * *", + "update_types": ["后端", "前端"] + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) From 2ba41befefdee1f412a435da31547a7932b9e7e9 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 23 Nov 2024 10:37:35 +0800 Subject: [PATCH 202/218] =?UTF-8?q?SiteStatistic=20=E8=BF=9C=E7=A8=8B?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E5=91=BD=E4=BB=A4=E7=A7=BB=E6=A4=8D=E5=88=B0?= =?UTF-8?q?=E4=B8=BB=E7=A8=8B=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 3 ++- plugins.v2/sitestatistic/__init__.py | 34 ++-------------------------- 2 files changed, 4 insertions(+), 33 deletions(-) diff --git a/package.v2.json b/package.v2.json index 7c552eb..8f1d22f 100644 --- a/package.v2.json +++ b/package.v2.json @@ -3,11 +3,12 @@ "name": "站点数据统计", "description": "站点统计数据图表。", "labels": "站点,仪表板", - "version": "1.2", + "version": "1.3", "icon": "statistic.png", "author": "lightolly,jxxghp", "level": 2, "history": { + "v1.3": "远程刷新命令移植到主程序", "v1.2": "继续修复增量数据统计问题", "v1.1": "修复增量数据统计问题", "v1.0": "MoviePilot V2 版本站点数据统计插件" diff --git a/plugins.v2/sitestatistic/__init__.py b/plugins.v2/sitestatistic/__init__.py index e623005..7b663f8 100644 --- a/plugins.v2/sitestatistic/__init__.py +++ b/plugins.v2/sitestatistic/__init__.py @@ -32,7 +32,7 @@ class SiteStatistic(_PluginBase): # 插件图标 plugin_icon = "statistic.png" # 插件版本 - plugin_version = "1.2" + plugin_version = "1.3" # 插件作者 plugin_author = "lightolly,jxxghp" # 作者主页 @@ -80,19 +80,7 @@ class SiteStatistic(_PluginBase): @staticmethod def get_command() -> List[Dict[str, Any]]: - """ - 定义远程控制命令 - :return: 命令关键字、事件、描述、附带数据 - """ - return [{ - "cmd": "/site_statistic", - "event": EventType.PluginAction, - "desc": "站点数据统计", - "category": "站点", - "data": { - "action": "site_statistic" - } - }] + pass def get_api(self) -> List[Dict[str, Any]]: """ @@ -901,24 +889,6 @@ class SiteStatistic(_PluginBase): def stop_service(self): pass - @eventmanager.register(EventType.PluginAction) - def refresh(self, event: Optional[Event] = None): - """ - 刷新站点数据 - """ - if event: - event_data = event.event_data - if not event_data or event_data.get("action") != "site_statistic": - return - logger.info("收到命令,开始刷新站点数据 ...") - self.post_message(channel=event.event_data.get("channel"), - title="开始刷新站点数据 ...", - userid=event.event_data.get("user")) - SiteChain().refresh_userdatas() - if event: - self.post_message(channel=event.event_data.get("channel"), - title="站点数据刷新完成!", userid=event.event_data.get("user")) - def refresh_by_domain(self, domain: str, apikey: str) -> schemas.Response: """ 刷新一个站点数据,可由API调用 From fd31f9bc9b51e4e0b0bc6d42bf22caf0b59fc116 Mon Sep 17 00:00:00 2001 From: ramen <1205925392@qq.com> Date: Sat, 23 Nov 2024 12:09:47 +0800 Subject: [PATCH 203/218] =?UTF-8?q?add:=20#560=E5=8F=AF=E4=BB=A5=E4=BB=8E?= =?UTF-8?q?=E6=8C=87=E5=AE=9Aurl=E8=8E=B7=E5=8F=96ip=20finx:=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E4=B8=8D=E4=BD=BF=E7=94=A8cc=E6=97=B6cookie=E5=A4=B1?= =?UTF-8?q?=E6=95=88=E8=BF=87=E5=BF=AB=EF=BC=8Cv1=E5=8F=AF=E5=90=8C?= =?UTF-8?q?=E6=97=B6=E9=85=8D=E7=BD=AE=E5=A4=9A=E4=B8=AA=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E6=96=B9=E5=BC=8F=EF=BC=8Cserver=E9=85=B1=E5=8F=AF=E4=BB=A5?= =?UTF-8?q?=E5=B0=86=E6=96=87=E6=9C=AC=E5=8F=91=E9=80=81=E5=88=B0server3,?= =?UTF-8?q?=E4=BA=8C=E7=BB=B4=E7=A0=81=E7=BB=99=E6=9C=8D=E5=8A=A1=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 +- plugins/dynamicwechat/__init__.py | 255 +++++++++++++++---------- plugins/dynamicwechat/notify_helper.py | 135 +++++++------ 3 files changed, 234 insertions(+), 159 deletions(-) diff --git a/package.json b/package.json index da6ff03..440c381 100644 --- a/package.json +++ b/package.json @@ -861,12 +861,13 @@ "name": "动态企微可信IP", "description": "修改企微应用可信IP,支持Srever酱等第三方通知。验证码以?结尾发送到企业微信应用", "labels": "消息通知", - "version": "1.5.1", + "version": "1.5.2", "icon": "Wecom_A.png", "author": "RamenRa", "level": 2, "v2": true, "history": { + "v1.5.2": "可以从指定url获取ip,修复不使用cc时cookie失效过快,v1可配置第三方为备用通知,server酱可以将文本发送到server3,二维码给服务号", "v1.5.1": "修复v2微信通知,可以指定微信通知ID", "v1.5.0": "支持企微应用通知和第Serve酱等第三方推送。按要求修改插件名称", "v1.4.1": "完善面板说明", diff --git a/plugins/dynamicwechat/__init__.py b/plugins/dynamicwechat/__init__.py index aa16cfc..2b977f4 100644 --- a/plugins/dynamicwechat/__init__.py +++ b/plugins/dynamicwechat/__init__.py @@ -18,9 +18,10 @@ from app.core.event import eventmanager, Event from app.helper.cookiecloud import CookieCloudHelper from app.log import logger from app.plugins import _PluginBase +from app.schemas.types import EventType from app.plugins.dynamicwechat.update_help import PyCookieCloud from app.plugins.dynamicwechat.notify_helper import MySender -from app.schemas.types import EventType + class DynamicWeChat(_PluginBase): @@ -31,7 +32,7 @@ class DynamicWeChat(_PluginBase): # 插件图标 plugin_icon = "Wecom_A.png" # 插件版本 - plugin_version = "1.5.1" + plugin_version = "1.5.2" # 插件作者 plugin_author = "RamenRa" # 作者主页 @@ -61,6 +62,8 @@ class DynamicWeChat(_PluginBase): _is_special_upload = False # 聚合通知 _my_send = None + # 保存cookie + _saved_cookie = None # 通知方式token/api _notification_token = '' @@ -104,6 +107,7 @@ class DynamicWeChat(_PluginBase): version = settings.VERSION_FLAG # V2 else: version = "v1" + def init_plugin(self, config: dict = None): # 清空配置 self._notification_token = '' @@ -113,9 +117,7 @@ class DynamicWeChat(_PluginBase): self._local_scan = False self._input_id_list = '' self._cookie_header = "" - self._current_ip_address = self.get_ip_from_url(random.choice(self._ip_urls)) self._settings_file_path = self.get_data_path() / "settings.json" - # self._cookie_lifetime = PyCookieCloud.load_cookie_lifetime() if config: self._enabled = config.get("enabled") self._notification_token = config.get("notification_token") @@ -134,6 +136,10 @@ class DynamicWeChat(_PluginBase): self._my_send = MySender(self._notification_token) if not self._my_send.init_success: # 没有输入通知方式,不通知 self._my_send = None + if "||" in self._input_id_list: + parts = self._input_id_list.split("||", 1) + self._ip_urls = parts[1].split(",") + self._current_ip_address = self.get_ip_from_url(random.choice(self._ip_urls)) # 停止现有任务 self.stop_service() if (self._enabled or self._onlyonce) and self._input_id_list: @@ -273,10 +279,15 @@ class DynamicWeChat(_PluginBase): self.__update_config() logger.info("----------------------本次任务结束----------------------") else: - logger.warning("cookie已失效请及时更新,不检测公网IP") + logger.warning("cookie已失效请及时更新,本次不检查公网IP") def CheckIP(self): - retry_urls = random.sample(self._ip_urls, len(self._ip_urls)) + if "||" in self._input_id_list: + parts = self._input_id_list.split("||", 1) + ip_urls = parts[1].split(",") + else: + ip_urls = self._ip_urls + retry_urls = random.sample(ip_urls, len(ip_urls)) ip_address = None for url in retry_urls: @@ -290,10 +301,6 @@ class DynamicWeChat(_PluginBase): logger.error("获取IP失败 不操作IP") return False - if self._forced_update: - logger.info("强制更新IP") - self._current_ip_address = ip_address - return True elif not self._ip_changed: # 上次修改IP失败 logger.info("上次IP修改IP没有成功 继续尝试修改IP") self._current_ip_address = ip_address @@ -373,8 +380,6 @@ class DynamicWeChat(_PluginBase): logger.debug(str(e)) return None, None - - def ChangeIP(self): logger.info("开始请求企业微信管理更改可信IP") try: @@ -416,8 +421,8 @@ class DynamicWeChat(_PluginBase): else: self._ip_changed = False browser.close() - except Exception as e: + self._ip_changed = False logger.error(f"更改可信IP失败: {e}") finally: pass @@ -432,34 +437,50 @@ class DynamicWeChat(_PluginBase): return logger.info("使用二维码登录成功,开始刷新cookie") try: - if self._cc_server.check_connection(): - current_url = page.url - current_cookies = context.cookies(current_url) # 通过 context 获取 cookies - if current_cookies is None: - logger.error("无法获取当前 cookies") - return - formatted_cookies = {} - for cookie in current_cookies: - domain = cookie.get('domain') # 使用 get() 方法避免 KeyError - if domain is None: - continue # 跳过没有 domain 的 cookie - - if domain not in formatted_cookies: - formatted_cookies[domain] = [] - formatted_cookies[domain].append(cookie) - flag = self._cc_server.update_cookie(formatted_cookies) - if flag: - logger.info("更新 CookieCloud 成功") - else: - logger.error("更新 CookieCloud 失败") - else: + if not self._cc_server.check_connection(): logger.error("连接 CookieCloud 失败", self._server) - except Exception as e: - logger.error(f"更新 cookie 发生错误: {e}") - else: - logger.error("CookieCloud没有启用或配置错误, 不刷新cookie") + return + current_url = page.url + current_cookies = context.cookies(current_url) # 通过 context 获取 cookies + if current_cookies is None: + logger.error("无法获取当前 cookies") + return + self._saved_cookie = current_cookies + formatted_cookies = {} + for cookie in current_cookies: + domain = cookie.get('domain') # 使用 get() 方法避免 KeyError + if domain is None: + continue # 跳过没有 domain 的 cookie - def get_cookie(self): # 只有从CookieCloud获取cookie成功才返回True + if domain not in formatted_cookies: + formatted_cookies[domain] = [] + formatted_cookies[domain].append(cookie) + flag = self._cc_server.update_cookie(formatted_cookies) + if flag: + logger.info("更新 CookieCloud 成功") + self._cookie_valid = True + else: + logger.error("更新 CookieCloud 失败") + + except Exception as e: + logger.error(f"CookieCloud更新 cookie 发生错误: {e}") + else: + try: + current_url = page.url + current_cookies = context.cookies(current_url) # 通过 context 获取 cookies + if current_cookies is None: + logger.error("更新本地 Cookie失败") + return + else: + logger.info("更新本地 Cookie成功") + self._saved_cookie = current_cookies # 保存 + self._cookie_valid = True + except Exception as e: + logger.error(f"更新本地 cookie 发生错误: {e}") + + def get_cookie(self): + if self._saved_cookie and self._cookie_valid: + return self._saved_cookie try: cookie_header = '' if self._use_cookiecloud: @@ -500,34 +521,60 @@ class DynamicWeChat(_PluginBase): return cookies def refresh_cookie(self): # 保活 - if self._use_cookiecloud: - try: - with sync_playwright() as p: - browser = p.chromium.launch(headless=True, args=['--lang=zh-CN']) - context = browser.new_context() - cookie = self.get_cookie() - if cookie: - context.add_cookies(cookie) + try: + with sync_playwright() as p: + browser = p.chromium.launch(headless=True, args=['--lang=zh-CN']) + context = browser.new_context() + cookie_used = False + if self._saved_cookie: + # logger.info("尝试使用本地保存的 cookie") + context.add_cookies(self._saved_cookie) page = context.new_page() page.goto(self._wechatUrl) time.sleep(3) - if not self.check_login_status(page, task='refresh_cookie'): - self._cookie_valid = False - if self._my_send: - result = self._my_send.send(title="cookie已失效,请及时更新", - content="请在企业微信应用发送/push_qr,让插件推送二维码。如果是使用微信通知请确保公网IP还没有变动", - image=None, force_send=False) # 标题,内容,图片,是否强制发送 - if result: - logger.info(f"cookie失效通知发送失败,原因:{result}") - else: + if self.check_login_status(page, task='refresh_cookie'): + # logger.info("本地保存的 cookie 有效") self._cookie_valid = True - if self._my_send: - self._my_send.reset_limit() - PyCookieCloud.increase_cookie_lifetime(self._settings_file_path, 1200) - self._cookie_lifetime = PyCookieCloud.load_cookie_lifetime(self._settings_file_path) - browser.close() - except Exception as e: - logger.error(f"cookie校验失败:{e}") + cookie_used = True + else: + # logger.warning("本地保存的 cookie 无效") + self._cookie_valid = False + self._saved_cookie = None # 清空无效的 cookie + + if not cookie_used and self._use_cookiecloud: + # logger.info("尝试从CookieCloud 获取新的 cookie") + cookie = self.get_cookie() + if cookie: + context.add_cookies(cookie) + page = context.new_page() + page.goto(self._wechatUrl) + time.sleep(3) + if self.check_login_status(page, task='refresh_cookie'): + # logger.info("新获取的 cookie 有效") + self._cookie_valid = True + self._saved_cookie = context.cookies() # 保存有效的 cookie + else: + # logger.warning("新获取的 cookie 无效") + self._cookie_valid = False + self._saved_cookie = None # 清空无效的 cookie + if self._my_send: + result = self._my_send.send( + title="cookie已失效,请及时更新", + content="请在企业微信应用发送/push_qr, 如有验证码以'?'结束发送到企业微信应用。 如果是使用’微信通知‘请确保公网IP还没有变动", + image=None, force_send=False + ) + if result: + logger.info(f"cookie失效通知发送失败,原因:{result}") + if self._cookie_valid: + if self._my_send: + self._my_send.reset_limit() + PyCookieCloud.increase_cookie_lifetime(self._settings_file_path, 1200) + self._cookie_lifetime = PyCookieCloud.load_cookie_lifetime(self._settings_file_path) + browser.close() + except Exception as e: + self._cookie_valid = False + self._saved_cookie = None # 异常时清空 cookie + logger.error(f"cookie 校验过程中发生异常: {e}") # def check_login_status(self, page, task): @@ -590,45 +637,45 @@ class DynamicWeChat(_PluginBase): "//div[contains(@class, 'js_show_ipConfig_dialog')]//a[contains(@class, '_mod_card_operationLink') and text()='配置']", "配置") ] - if self._input_id_list: - id_list = self._input_id_list.split(",") - app_urls = [f"{bash_url}{app_id.strip()}" for app_id in id_list] - for app_url in app_urls: - page.goto(app_url) # 打开应用详情页 - app_id = app_url.split("/")[-1] - time.sleep(2) - # 依次点击每个按钮 - for xpath, name in buttons: - # 等待按钮出现并可点击 - try: - button = page.wait_for_selector(xpath, timeout=5000) # 等待按钮可点击 - button.click() - # logger.info(f"已点击 '{name}' 按钮") - page.wait_for_selector('textarea.js_ipConfig_textarea', timeout=5000) - # logger.info(f"已找到文本框") - input_area = page.locator('textarea.js_ipConfig_textarea') - confirm = page.locator('.js_ipConfig_confirmBtn') - input_area.fill(self._current_ip_address) # 填充 IP 地址 - confirm.click() # 点击确认按钮 - time.sleep(3) # 等待处理 - self._ip_changed = True - except Exception as e: - logger.error(f"未能找打开{app_url}或点击 '{name}' 按钮异常: {e}") - self._ip_changed = False - if "disabled" in str(e): - logger.info(f"应用{app_id} 已被禁用,可能是没有设置接收api") - if self._ip_changed: - logger.info(f"应用: {app_id} 输入IP:" + self._current_ip_address) - ip_parts = self._current_ip_address.split('.') - masked_ip = f"{ip_parts[0]}.{len(ip_parts[1]) * '*'}.{len(ip_parts[2]) * '*'}.{ip_parts[3]}" - if self._my_send: - result = self._my_send.send(title="更新可信IP成功", - content='应用: ' + app_id + ' 输入IP:' + masked_ip, - force_send=True, diy_channel="WeChat") - return + if "||" in self._input_id_list: + parts = self._input_id_list.split("||", 1) + input_id_list = parts[0] else: - logger.error("未找到应用id,修改IP失败") - return + input_id_list = self._input_id_list + id_list = input_id_list.split(",") + app_urls = [f"{bash_url}{app_id.strip()}" for app_id in id_list] + for app_url in app_urls: + page.goto(app_url) # 打开应用详情页 + app_id = app_url.split("/")[-1] + time.sleep(2) + # 依次点击每个按钮 + for xpath, name in buttons: + # 等待按钮出现并可点击 + try: + button = page.wait_for_selector(xpath, timeout=5000) # 等待按钮可点击 + button.click() + # logger.info(f"已点击 '{name}' 按钮") + page.wait_for_selector('textarea.js_ipConfig_textarea', timeout=5000) + # logger.info(f"已找到文本框") + input_area = page.locator('textarea.js_ipConfig_textarea') + confirm = page.locator('.js_ipConfig_confirmBtn') + input_area.fill(self._current_ip_address) # 填充 IP 地址 + confirm.click() # 点击确认按钮 + time.sleep(3) # 等待处理 + self._ip_changed = True + except Exception as e: + logger.error(f"未能找打开{app_url}或点击 '{name}' 按钮异常: {e}") + self._ip_changed = False + if "disabled" in str(e): + logger.info(f"应用{app_id} 已被禁用,可能是没有设置接收api") + if self._ip_changed: + logger.info(f"应用: {app_id} 输入IP:" + self._current_ip_address) + ip_parts = self._current_ip_address.split('.') + masked_ip = f"{ip_parts[0]}.{len(ip_parts[1]) * '*'}.{len(ip_parts[2]) * '*'}.{ip_parts[3]}" + if self._my_send: + result = self._my_send.send(title="更新可信IP成功", + content='应用: ' + app_id + ' 输入IP:' + masked_ip, + force_send=True, diy_channel="WeChat") def __update_config(self): """ @@ -885,7 +932,7 @@ class DynamicWeChat(_PluginBase): if self._qr_code_image is None: img_component = { "component": "div", - "text": "登录二维码都会在此展示,二维码有6秒延时,过期时间仅对应‘本地扫码功能’", + "text": "登录二维码都会在此展示,二维码有6秒延时。 [适用于Docker版]", "props": { "style": { "fontSize": "22px", @@ -1018,6 +1065,7 @@ class DynamicWeChat(_PluginBase): if result: logger.info(f"远程推送任务: 二维码发送失败,原因:{result}") browser.close() + logger.info("----------------------本次任务结束----------------------") return logger.info("远程推送任务: 二维码已经发送,等待用户 90 秒内扫码登录") # logger.info("远程推送任务: 如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301?") @@ -1032,6 +1080,7 @@ class DynamicWeChat(_PluginBase): else: logger.warning("远程推送任务: 未找到二维码") browser.close() + logger.info("----------------------本次任务结束----------------------") except Exception as e: logger.error(f"远程推送任务: 推送二维码失败: {e}") @@ -1063,8 +1112,6 @@ class DynamicWeChat(_PluginBase): if self.text[:6].isdigit() and len(self.text) == 7: self._verification_code = self.text[:6] logger.info(f"收到验证码:{self._verification_code}") - # else: - # logger.info(f"收到消息:{self.text}") def get_service(self) -> List[Dict[str, Any]]: """ diff --git a/plugins/dynamicwechat/notify_helper.py b/plugins/dynamicwechat/notify_helper.py index 6c70a5d..da1985e 100644 --- a/plugins/dynamicwechat/notify_helper.py +++ b/plugins/dynamicwechat/notify_helper.py @@ -1,89 +1,112 @@ import re import requests from app.modules.wechat import WeChat -from app.schemas.types import NotificationType +from app.schemas.types import NotificationType,MessageChannel class MySender: def __init__(self, token=None, func=None): - self.token = token - self.channel = self.send_channel() if token else None # 初始化时确定发送渠道 - self.first_text_sent = False # 记录是否已发送过纯文本消息 - self.init_success = bool(token) # 标识初始化成功 - self.post_message_func = func # V2微信模式的 post_message 方法 + self.tokens = token.split('||') if token and '||' in token else [token] if token else [] + self.channels = [MySender._detect_channel(t) for t in self.tokens] + self.current_index = 0 # 当前使用的 token 和 channel 的索引 + self.first_text_sent = False # 是否已发送过纯文本消息 + self.init_success = bool(self.tokens) # 标识初始化是否成功 + self.post_message_func = func # V2 微信模式的 post_message 方法 - def send_channel(self): - if "WeChat" in self.token: + @staticmethod + def _detect_channel(token): + """根据 token 确定通知渠道""" + if "WeChat" in token: return "WeChat" - letters_only = ''.join(re.findall(r'[A-Za-z]', self.token)) - if self.token.lower().startswith("sct".lower()): + letters_only = ''.join(re.findall(r'[A-Za-z]', token)) + if token.lower().startswith("sct"): return "ServerChan" elif letters_only.isupper(): return "AnPush" else: return "PushPlus" - # 标题,内容,图片,是否强制发送 def send(self, title, content=None, image=None, force_send=False, diy_channel=None): + """发送消息""" if not self.init_success: - return # 如果初始化失败,直接返回 + return + # 对纯文本消息进行限制 if not image and not force_send: if self.first_text_sent: return - else: - self.first_text_sent = True + self.first_text_sent = True - # # 如果是 V2 微信通知直接处理 - if self.channel == "WeChat" and self.post_message_func: - return self.send_v2_wechat(title, content, image) + # 如果指定了自定义通道,直接尝试发送 + if diy_channel: + return self._try_send(title, content, image, diy_channel) - try: - if not diy_channel: - channel = self.channel - else: - channel = diy_channel + # 尝试按顺序发送,直到成功或遍历所有通道 + for i in range(len(self.tokens)): + token = self.tokens[self.current_index] + channel = self.channels[self.current_index] + try: + result = self._try_send(title, content, image, channel, token) + if result is None: # 成功时返回 None + return + except Exception as e: + # 打印错误日志或处理错误 + return f"{channel} 通知错误: {e}" + # 切换到下一个通道 + self.current_index = (self.current_index + 1) % len(self.tokens) + return f"所有的通知方式都发送失败" - if channel == "WeChat": - return MySender.send_wechat(title, content, image, self.token) - elif channel == "ServerChan": - return self.send_serverchan(title, content, image) - elif channel == "AnPush": - return self.send_anpush(title, content, image) - elif channel == "PushPlus": - return self.send_pushplus(title, content, image) - else: - return "Unknown channel" - except Exception as e: - return f"Error occurred: {str(e)}" + def _try_send(self, title, content, image, channel, token=None): + """尝试使用指定通道发送消息""" + if channel == "WeChat" and self.post_message_func: + return self._send_v2_wechat(title, content, image, token) + elif channel == "WeChat": + return self._send_wechat(title, content, image, token) + elif channel == "ServerChan": + return self._send_serverchan(title, content, image) + elif channel == "AnPush": + return self._send_anpush(title, content, image) + elif channel == "PushPlus": + return self._send_pushplus(title, content, image) + else: + raise ValueError(f"Unknown channel: {channel}") @staticmethod - def send_wechat(title, content, image, token): + def _send_wechat(title, content, image, token): wechat = WeChat() - if ',' in token: + if token and ',' in token: channel, actual_userid = token.split(',', 1) else: actual_userid = None if image: send_status = wechat.send_msg(title='企业微信登录二维码', image=image, link=image, userid=actual_userid) else: - send_status = wechat.send_msg(title=title, text=content) + send_status = wechat.send_msg(title=title, text=content, userid=actual_userid) if send_status is None: return "微信通知发送错误" return None - def send_serverchan(self, title, content, image): - if self.token.startswith('sctp'): - match = re.match(r'sctp(\d+)t', self.token) + def _send_serverchan(self, title, content, image): + tmp_tokens = self.tokens[self.current_index] + token = tmp_tokens + if ',' in tmp_tokens: + before_comma, after_comma = tmp_tokens.split(',', 1) + if before_comma.startswith('sctp') and image: + token = after_comma # 图片发到公众号 + else: + token = before_comma # 发到 server3 + + if token.startswith('sctp'): + match = re.match(r'sctp(\d+)t', token) if match: num = match.group(1) - url = f'https://{num}.push.ft07.com/send/{self.token}.send' + url = f'https://{num}.push.ft07.com/send/{token}.send' else: - raise ValueError('Invalid sendkey format for sctp') + return '错误的Server3 Sendkey' else: - url = f'https://sctapi.ftqq.com/{self.token}.send' + url = f'https://sctapi.ftqq.com/{token}.send' params = {'title': title, 'desp': f'![img]({image})' if image else content} headers = {'Content-Type': 'application/json;charset=utf-8'} @@ -93,11 +116,12 @@ class MySender: return f"Server酱通知错误: {result.get('message')}" return None - def send_anpush(self, title, content, image): - if ',' in self.token: - channel, token = self.token.split(',', 1) + def _send_anpush(self, title, content, image): + token = self.tokens[self.current_index] # 获取当前通道对应的 token + if ',' in token: + channel, token = token.split(',', 1) else: - return + return "可能AnPush 没有配置消息通道ID" url = f"https://api.anpush.com/push/{token}" payload = { "title": title, @@ -114,8 +138,9 @@ class MySender: return "AnPush 消息通道未找到" return None - def send_pushplus(self, title, content, image): - pushplus_url = f"http://www.pushplus.plus/send/{self.token}" + def _send_pushplus(self, title, content, image): + token = self.tokens[self.current_index] # 获取当前通道对应的 token + pushplus_url = f"http://www.pushplus.plus/send/{token}" # PushPlus发送逻辑 data = { "title": title, @@ -128,12 +153,14 @@ class MySender: return f"PushPlus send failed: {result.get('msg')}" return None - def send_v2_wechat(self, title, content, image): + def _send_v2_wechat(self, title, content, image, token): """V2 微信通知发送""" - if not self.token or ',' not in self.token: - return '没有指定V2微信用户ID' - channel, actual_userid = self.token.split(',', 1) + if token and ',' in token: + channel, actual_userid = token.split(',', 1) + else: + actual_userid = None self.post_message_func( + channel=MessageChannel.Wechat, mtype=NotificationType.Plugin, title=title, text=content, @@ -141,7 +168,7 @@ class MySender: link=image, userid=actual_userid ) - return None + return None # 由于self.post_message()了None外,没有其他返回值。无法判断是否发送成功,V2直接默认成功 def reset_limit(self): """解除限制,允许再次发送纯文本消息""" From 70dc24cb2ab2e2479428f9fb66cfe42dc81a1f90 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 24 Nov 2024 16:57:18 +0800 Subject: [PATCH 204/218] =?UTF-8?q?SiteStatistic=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=88=B7=E6=96=B0=E6=97=B6=E5=8F=91=E9=80=81?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 3 +- plugins.v2/sitestatistic/__init__.py | 97 ++++++++++++++++++++++++---- 2 files changed, 85 insertions(+), 15 deletions(-) diff --git a/package.v2.json b/package.v2.json index 8f1d22f..afe1549 100644 --- a/package.v2.json +++ b/package.v2.json @@ -3,11 +3,12 @@ "name": "站点数据统计", "description": "站点统计数据图表。", "labels": "站点,仪表板", - "version": "1.3", + "version": "1.4", "icon": "statistic.png", "author": "lightolly,jxxghp", "level": 2, "history": { + "v1.4": "支持数据刷新时发送消息通知", "v1.3": "远程刷新命令移植到主程序", "v1.2": "继续修复增量数据统计问题", "v1.1": "修复增量数据统计问题", diff --git a/plugins.v2/sitestatistic/__init__.py b/plugins.v2/sitestatistic/__init__.py index 7b663f8..8196bfd 100644 --- a/plugins.v2/sitestatistic/__init__.py +++ b/plugins.v2/sitestatistic/__init__.py @@ -1,22 +1,18 @@ -import time import warnings from datetime import datetime, timedelta from threading import Lock from typing import Optional, Any, List, Dict, Tuple -import pytz -from apscheduler.schedulers.background import BackgroundScheduler - from app import schemas from app.chain.site import SiteChain from app.core.config import settings -from app.core.event import Event, eventmanager +from app.core.event import eventmanager, Event from app.db.models.siteuserdata import SiteUserData from app.db.site_oper import SiteOper from app.helper.sites import SitesHelper from app.log import logger from app.plugins import _PluginBase -from app.schemas.types import EventType +from app.schemas.types import EventType, NotificationType from app.utils.string import StringUtils warnings.filterwarnings("ignore", category=FutureWarning) @@ -32,7 +28,7 @@ class SiteStatistic(_PluginBase): # 插件图标 plugin_icon = "statistic.png" # 插件版本 - plugin_version = "1.3" + plugin_version = "1.4" # 插件作者 plugin_author = "lightolly,jxxghp" # 作者主页 @@ -47,14 +43,16 @@ class SiteStatistic(_PluginBase): # 配置属性 siteoper = None siteshelper = None + sitechain = None _enabled: bool = False _onlyonce: bool = False _dashboard_type: str = "today" - _scheduler = None + _notify_type = "" def init_plugin(self, config: dict = None): self.siteoper = SiteOper() self.siteshelper = SitesHelper() + self.sitechain = SiteChain() # 停止现有任务 self.stop_service() @@ -64,15 +62,11 @@ class SiteStatistic(_PluginBase): self._enabled = config.get("enabled") self._onlyonce = config.get("onlyonce") self._dashboard_type = config.get("dashboard_type") or "today" + self._notify_type = config.get("notify_type") or "" if self._onlyonce: config["onlyonce"] = False - self._scheduler = BackgroundScheduler(timezone=settings.TZ) - self._scheduler.add_job(self.refresh, "date", - run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), - name="站点数据统计服务") - self._scheduler.print_jobs() - self._scheduler.start() + self.sitechain.refresh_userdatas() self.update_config(config=config) def get_state(self) -> bool: @@ -171,6 +165,27 @@ class SiteStatistic(_PluginBase): } } ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'notify_type', + 'label': '数据刷新时发送通知', + 'items': [ + {'title': '不发送', 'value': ''}, + {'title': '今日增量数据', 'value': 'inc'}, + {'title': '累计全量数据', 'value': 'all'} + ] + } + } + ] } ] } @@ -182,6 +197,60 @@ class SiteStatistic(_PluginBase): "dashboard_type": 'today' } + @eventmanager.register(EventType.SiteRefreshed) + def send_msg(self, _: Event): + """ + 站点数据刷新事件时发送消息 + """ + if not self._notify_type: + return + # 获取站点数据 + today, today_data, yesterday_data = self.__get_data() + # 转换为字典 + today_data_dict = {data.name: data for data in today_data} + yesterday_data_dict = {data.name: data for data in yesterday_data} + # 消息内容 + messages = {} + # 总上传 + incUploads = 0 + # 总下载 + incDownloads = 0 + # 今天的日期 + today_date = datetime.now().strftime("%Y-%m-%d") + + for rand, site in enumerate(today_data_dict.keys()): + upload = int(today_data_dict[site].upload or 0) + download = int(today_data_dict[site].download or 0) + updated_date = today_data_dict[site].updated_day + + if self._notify_type == "inc" and yesterday_data_dict.get(site): + upload -= int(yesterday_data[site].get("upload") or 0) + download -= int(yesterday_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 += 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: + 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(sorted_messages)) + def __get_data(self) -> Tuple[str, List[SiteUserData], List[SiteUserData]]: """ 获取今天的日期、今天的站点数据、昨天的站点数据 From 18ffe9c6bd87da6bf9b5db6ebd26b6fb90db226a Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 24 Nov 2024 18:50:48 +0800 Subject: [PATCH 205/218] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20=5F=5Finit=5F=5F.p?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins.v2/sitestatistic/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins.v2/sitestatistic/__init__.py b/plugins.v2/sitestatistic/__init__.py index 8196bfd..894c065 100644 --- a/plugins.v2/sitestatistic/__init__.py +++ b/plugins.v2/sitestatistic/__init__.py @@ -198,12 +198,14 @@ class SiteStatistic(_PluginBase): } @eventmanager.register(EventType.SiteRefreshed) - def send_msg(self, _: Event): + def send_msg(self, event: Event): """ 站点数据刷新事件时发送消息 """ if not self._notify_type: return + if event.event_data.get('site_id') != "*": + return # 获取站点数据 today, today_data, yesterday_data = self.__get_data() # 转换为字典 From 78b195573e8a8f4974cee9ab512b53d90abac836 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 24 Nov 2024 19:48:08 +0800 Subject: [PATCH 206/218] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20=5F=5Finit=5F=5F.p?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins.v2/sitestatistic/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins.v2/sitestatistic/__init__.py b/plugins.v2/sitestatistic/__init__.py index 894c065..e0f1651 100644 --- a/plugins.v2/sitestatistic/__init__.py +++ b/plugins.v2/sitestatistic/__init__.py @@ -28,7 +28,7 @@ class SiteStatistic(_PluginBase): # 插件图标 plugin_icon = "statistic.png" # 插件版本 - plugin_version = "1.4" + plugin_version = "1.4.1" # 插件作者 plugin_author = "lightolly,jxxghp" # 作者主页 From 7f12b5fc901144c2a4cecf0548d771a2455cfa31 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 24 Nov 2024 19:48:28 +0800 Subject: [PATCH 207/218] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20package.v2.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.v2.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.v2.json b/package.v2.json index afe1549..ee9be68 100644 --- a/package.v2.json +++ b/package.v2.json @@ -3,12 +3,12 @@ "name": "站点数据统计", "description": "站点统计数据图表。", "labels": "站点,仪表板", - "version": "1.4", + "version": "1.4.1", "icon": "statistic.png", "author": "lightolly,jxxghp", "level": 2, "history": { - "v1.4": "支持数据刷新时发送消息通知", + "v1.4.1": "支持数据刷新时发送消息通知", "v1.3": "远程刷新命令移植到主程序", "v1.2": "继续修复增量数据统计问题", "v1.1": "修复增量数据统计问题", From 02250dbaa178ca517c8b68a9a1e888928f220cd5 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Mon, 25 Nov 2024 19:59:01 +0800 Subject: [PATCH 208/218] feat(MediaServerMsg): add independent media server msg control --- plugins.v2/mediaservermsg/__init__.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/plugins.v2/mediaservermsg/__init__.py b/plugins.v2/mediaservermsg/__init__.py index f927b6f..c99ea22 100644 --- a/plugins.v2/mediaservermsg/__init__.py +++ b/plugins.v2/mediaservermsg/__init__.py @@ -92,6 +92,13 @@ class MediaServerMsg(_PluginBase): return active_services + def service_info(self, name: str) -> Optional[ServiceInfo]: + """ + 服务信息 + """ + service_infos = self.service_infos() or {} + return service_infos.get(name) + def get_state(self) -> bool: return self._enabled @@ -261,6 +268,18 @@ class MediaServerMsg(_PluginBase): logger.info(f"未开启 {event_info.event} 类型的消息通知") return + if not self.service_infos(): + logger.info(f"未开启任一媒体服务器的消息通知") + return + + if event_info.server_name and not self.service_info(name=event_info.server_name): + logger.info(f"未开启媒体服务器 {event_info.server_name} 的消息通知") + return + + if event_info.channel and not self.service_infos(type_filter=event_info.channel): + logger.info(f"未开启媒体服务器类型 {event_info.channel} 的消息通知") + return + expiring_key = f"{event_info.item_id}-{event_info.client}-{event_info.user_name}" # 过滤停止播放重复消息 if str(event_info.event) == "playback.stop" and expiring_key in self._webhook_msg_keys.keys(): From a53385a9dae9ddfa918b4ea573ff76de76412e08 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Mon, 25 Nov 2024 19:59:50 +0800 Subject: [PATCH 209/218] feat(MediaServerMsg): v1.5 --- package.v2.json | 3 ++- plugins.v2/mediaservermsg/__init__.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/package.v2.json b/package.v2.json index ee9be68..0fd8c0a 100644 --- a/package.v2.json +++ b/package.v2.json @@ -69,11 +69,12 @@ "name": "媒体库服务器通知", "description": "发送Emby/Jellyfin/Plex服务器的播放、入库等通知消息。", "labels": "消息通知,媒体库", - "version": "1.4", + "version": "1.5", "icon": "mediaplay.png", "author": "jxxghp", "level": 1, "history": { + "v1.5": "支持独立控制媒体服务器通知", "v1.4": "MoviePilot V2 版本媒体库服务器通知插件" } }, diff --git a/plugins.v2/mediaservermsg/__init__.py b/plugins.v2/mediaservermsg/__init__.py index c99ea22..80e4105 100644 --- a/plugins.v2/mediaservermsg/__init__.py +++ b/plugins.v2/mediaservermsg/__init__.py @@ -18,7 +18,7 @@ class MediaServerMsg(_PluginBase): # 插件图标 plugin_icon = "mediaplay.png" # 插件版本 - plugin_version = "1.4" + plugin_version = "1.5" # 插件作者 plugin_author = "jxxghp" # 作者主页 From 5330b9d6fb8a8e4996e3640815c255c39e04a33f Mon Sep 17 00:00:00 2001 From: thsrite Date: Tue, 26 Nov 2024 10:37:22 +0800 Subject: [PATCH 210/218] fix AutoBackup --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 440c381..52f998f 100644 --- a/package.json +++ b/package.json @@ -287,7 +287,6 @@ "icon": "Time_machine_B.png", "author": "thsrite", "level": 1, - "v2": true, "history": { "v1.3": "去除已废弃的环境变量引用", "v1.2": "增强API安全性" From c17ba7f5f5138e1cdbbf6b4889835c94179949da Mon Sep 17 00:00:00 2001 From: cikezhu <604054726@qq.com> Date: Tue, 26 Nov 2024 14:51:20 +0800 Subject: [PATCH 211/218] =?UTF-8?q?fix=20=E6=97=A0=E6=B3=95=E8=8E=B7?= =?UTF-8?q?=E5=8F=96=E5=AD=A30=E7=9A=84=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 +- plugins/episodegroupmeta/__init__.py | 128 +++++++++++++-------------- 2 files changed, 66 insertions(+), 65 deletions(-) diff --git a/package.json b/package.json index f73f643..7ad7314 100644 --- a/package.json +++ b/package.json @@ -527,12 +527,13 @@ "name": "TMDB剧集组刮削", "description": "从TMDB剧集组刮削季集的实际顺序。", "labels": "刮削", - "version": "2.5", + "version": "2.6", "icon": "Element_A.png", "author": "叮叮当", "level": 1, "v2": true, "history": { + "v2.6": "修复无法获取媒体库中季0的问题", "v2.5": "修复当媒体服务器中剧集的季不完整时会中断的问题", "v2.3": "修复v2版本无法读取媒体库的问题", "v2.2": "修复v2版本无法读取数据的问题", diff --git a/plugins/episodegroupmeta/__init__.py b/plugins/episodegroupmeta/__init__.py index e2f919b..20c7b6c 100644 --- a/plugins/episodegroupmeta/__init__.py +++ b/plugins/episodegroupmeta/__init__.py @@ -6,12 +6,9 @@ import importlib.util import sys from pathlib import Path from typing import Any, List, Dict, Tuple, Optional, Union - from pydantic import BaseModel from requests import RequestException - from app import schemas -from app.chain.mediaserver import MediaServerChain from app.core.config import settings from app.core.event import eventmanager, Event from app.core.meta import MetaBase @@ -27,8 +24,6 @@ from app.utils.http import RequestUtils from app.db.models import PluginData class ExistMediaInfo(BaseModel): - # 类型 电影、电视剧 - type: Optional[schemas.MediaType] # 季, 集 groupep: Optional[Dict[int, list]] = {} # 集在媒体服务器的ID @@ -51,7 +46,7 @@ class EpisodeGroupMeta(_PluginBase): # 主题色 plugin_color = "#098663" # 插件版本 - plugin_version = "2.5" + plugin_version = "2.6" # 插件作者 plugin_author = "叮叮当" # 作者主页 @@ -660,38 +655,51 @@ class EpisodeGroupMeta(_PluginBase): return False # 获取全部可用的媒体服务器, 兼容v2 service_infos = self.service_infos() - if self.mediaserver_helper == None: + relust_bool = False + if self.mediaserver_helper is None: # v1版本 单一媒体服务器的方式 - _existsinfo = self.chain.media_exists(mediainfo=mediainfo) - if not _existsinfo: - self.log_warn(f"{mediainfo.title_year} 无可用的媒体服务器") - return False - # 存在媒体服务器 - existsinfo: ExistMediaInfo = self.__media_exists(mediainfo=mediainfo, existsinfo=_existsinfo) - if not existsinfo or not existsinfo.itemid: - self.log_warn(f"{mediainfo.title_year} 在媒体库中不存在") - return False - return self.__start_rt_mediaserver(mediainfo=mediainfo, existsinfo=existsinfo, episode_groups=episode_groups, group_id=group_id) + server_list = ["emby", "jellyfin", "plex"] + # 遍历所有媒体服务器 + for server in server_list: + self.log_info(f"正在查询媒体服务器: {server}") + existsinfo: ExistMediaInfo = self.__media_exists( + mediainfo=mediainfo, + server=server, + server_type=server) + if not existsinfo or not existsinfo.itemid: + self.log_warn(f"{mediainfo.title_year} 在媒体库 {server} 中不存在") + continue + elif not existsinfo.groupep: + self.log_warn(f"{mediainfo.title_year} 在媒体库 {server} 中没有数据") + continue + else: + self.log_info(f"{mediainfo.title_year} 在媒体库 {existsinfo.server} 中找到了这些季集:{existsinfo.groupep}") + _bool = self.__start_rt_mediaserver(mediainfo=mediainfo, existsinfo=existsinfo, episode_groups=episode_groups, group_id=group_id) + relust_bool = relust_bool or _bool else: # v2版本 遍历所有媒体服务器的方式 if not service_infos: self.log_warn(f"{mediainfo.title_year} 无可用的媒体服务器") return False # 遍历媒体服务器 - relust_bool = False for name, info in service_infos.items(): self.log_info(f"正在查询媒体服务器: ({info.type}){name}") - _existsinfo = self.chain.media_exists(mediainfo=mediainfo, server=name) - if not _existsinfo: - self.log_warn(f"{mediainfo.title_year} 在 ({info.type}){name} 媒体服务器中不存在") - continue - existsinfo: ExistMediaInfo = self.__media_exists(mediainfo=mediainfo, existsinfo=_existsinfo, mediaserver_instance=info.instance) + existsinfo: ExistMediaInfo = self.__media_exists( + mediainfo=mediainfo, + server=name, + server_type=info.type, + mediaserver_instance=info.instance) if not existsinfo or not existsinfo.itemid: self.log_warn(f"{mediainfo.title_year} 在 ({info.type}){name} 媒体服务器中不存在") continue + elif not existsinfo.groupep: + self.log_warn(f"{mediainfo.title_year} 在 ({info.type}){name} 媒体服务器中没有数据") + continue + else: + self.log_info(f"{mediainfo.title_year} 在媒体库 ({existsinfo.server_type}){existsinfo.server} 中找到了这些季集:{existsinfo.groupep}") _bool = self.__start_rt_mediaserver(mediainfo=mediainfo, existsinfo=existsinfo, episode_groups=episode_groups, group_id=group_id, mediaserver_instance=info.instance) relust_bool = relust_bool or _bool - return relust_bool + return relust_bool def __start_rt_mediaserver(self, mediainfo: schemas.MediaInfo, @@ -733,7 +741,7 @@ class EpisodeGroupMeta(_PluginBase): order = groups.get("order") # 剧集组中的集列表 episodes = groups.get("episodes") - if not order or not episodes or len(episodes) == 0: + if order is None or not episodes or len(episodes) == 0: continue # 进行集数匹配, 确定剧集组信息 ep = existsinfo.groupep.get(order) @@ -804,14 +812,12 @@ class EpisodeGroupMeta(_PluginBase): if item not in list: list.append(item) - def __media_exists(self, mediainfo: schemas.MediaInfo, existsinfo: schemas.ExistMediaInfo, mediaserver_instance: Any = None) -> ExistMediaInfo: + def __media_exists(self, mediainfo: schemas.MediaInfo, server: str, server_type: str, mediaserver_instance: Any = None) -> ExistMediaInfo: """ - 根据媒体信息,返回剧集列表与剧集ID列表 + 根据媒体信息,返回是否存在于指定媒体服务器中,剧集列表与剧集ID列表 :param mediainfo: 媒体信息 :return: 剧集列表与剧集ID列表 """ - # fix v1版本对比v2版本, 属性含义发生变化, 代码做兼容处理 - server_type = existsinfo.server_type if hasattr(existsinfo, 'server_type') else existsinfo.server def __emby_media_exists(): # 获取系列id @@ -834,7 +840,7 @@ class EpisodeGroupMeta(_PluginBase): not mediainfo.year or str(res_item.get('ProductionYear')) == str(mediainfo.year)): item_id = res_item.get('Id') except Exception as e: - self.log_error(f"媒体服务器 ({server_type}){existsinfo.server} 发生了错误, 连接Items出错:" + str(e)) + self.log_error(f"媒体服务器 ({server_type}){server} 发生了错误, 连接Items出错:" + str(e)) if not item_id: return None # 验证tmdbid是否相同 @@ -854,10 +860,10 @@ class EpisodeGroupMeta(_PluginBase): group_id = {} for res_item in res_items: season_index = res_item.get("ParentIndexNumber") - if not season_index: + if season_index is None: continue episode_index = res_item.get("IndexNumber") - if not episode_index: + if episode_index is None: continue if season_index not in group_ep: group_ep[season_index] = [] @@ -874,12 +880,11 @@ class EpisodeGroupMeta(_PluginBase): itemid=item_id, groupep=group_ep, groupid=group_id, - type=existsinfo.type, server_type=server_type, - server=existsinfo.server, + server=server, ) except Exception as e: - self.log_error(f"媒体服务器 ({server_type}){existsinfo.server} 发生了错误, 连接Shows/Id/Episodes出错:{str(e)}") + self.log_error(f"媒体服务器 ({server_type}){server} 发生了错误, 连接Shows/Id/Episodes出错:{str(e)}") return None def __jellyfin_media_exists(): @@ -898,7 +903,7 @@ class EpisodeGroupMeta(_PluginBase): not mediainfo.year or str(res_item.get('ProductionYear')) == str(mediainfo.year)): item_id = res_item.get('Id') except Exception as e: - self.log_error(f"媒体服务器 ({server_type}){existsinfo.server} 发生了错误, 连接Items出错:" + str(e)) + self.log_error(f"媒体服务器 ({server_type}){server} 发生了错误, 连接Items出错:" + str(e)) if not item_id: return None # 验证tmdbid是否相同 @@ -910,7 +915,7 @@ class EpisodeGroupMeta(_PluginBase): return None try: res_json = instance.get_data( - "[HOST]emby/Shows/%s/Episodes?Season=&IsMissing=false&api_key=[APIKEY]" % item_id) + "[HOST]Shows/%s/Episodes?Season=&IsMissing=false&api_key=[APIKEY]" % item_id) if res_json: tv_item = res_json.json() res_items = tv_item.get("Items") @@ -918,10 +923,10 @@ class EpisodeGroupMeta(_PluginBase): group_id = {} for res_item in res_items: season_index = res_item.get("ParentIndexNumber") - if not season_index: + if season_index is None: continue episode_index = res_item.get("IndexNumber") - if not episode_index: + if episode_index is None: continue if season_index not in group_ep: group_ep[season_index] = [] @@ -938,33 +943,29 @@ class EpisodeGroupMeta(_PluginBase): itemid=item_id, groupep=group_ep, groupid=group_id, - type=existsinfo.type, server_type=server_type, - server=existsinfo.server, + server=server, ) except Exception as e: - self.log_error(f"媒体服务器 ({server_type}){existsinfo.server} 发生了错误, 连接Shows/Id/Episodes出错:{str(e)}") + self.log_error(f"媒体服务器 ({server_type}){server} 发生了错误, 连接Shows/Id/Episodes出错:{str(e)}") return None def __plex_media_exists(): try: - instance = mediaserver_instance or self.plex.get_plex() + instance = mediaserver_instance or self.plex _plex = instance.get_plex() if not _plex: return None - if existsinfo.itemid: - videos = _plex.fetchItem(existsinfo.itemid) - else: - # 根据标题和年份模糊搜索,该结果不够准确 - videos = _plex.library.search(title=mediainfo.title, - year=mediainfo.year, - libtype="show") - if (not videos - and mediainfo.original_title - and str(mediainfo.original_title) != str(mediainfo.title)): - videos = _plex.library.search(title=mediainfo.original_title, - year=mediainfo.year, - libtype="show") + # 根据标题和年份模糊搜索,该结果不够准确 + videos = _plex.library.search(title=mediainfo.title, + year=mediainfo.year, + libtype="show") + if (not videos + and mediainfo.original_title + and str(mediainfo.original_title) != str(mediainfo.title)): + videos = _plex.library.search(title=mediainfo.original_title, + year=mediainfo.year, + libtype="show") if not videos: return None if isinstance(videos, list): @@ -979,10 +980,10 @@ class EpisodeGroupMeta(_PluginBase): group_id = {} for episode in episodes: season_index = episode.seasonNumber - if not season_index: + if season_index is None: continue episode_index = episode.index - if not episode_index: + if episode_index is None: continue episode_id = episode.key if not episode_id: @@ -1002,12 +1003,11 @@ class EpisodeGroupMeta(_PluginBase): itemid=videos.key, groupep=group_ep, groupid=group_id, - type=existsinfo.type, server_type=server_type, - server=existsinfo.server, + server=server, ) except Exception as e: - self.log_error(f"媒体服务器 ({server_type}){existsinfo.server} 发生了错误, 连接Shows/Id/Episodes出错:{str(e)}") + self.log_error(f"媒体服务器 ({server_type}){server} 发生了错误, 连接Shows/Id/Episodes出错:{str(e)}") return None def __get_ids(guids: List[Any]) -> dict: @@ -1304,7 +1304,7 @@ class EpisodeGroupMeta(_PluginBase): """ 服务信息 """ - if self.mediaserver_helper == None: + if self.mediaserver_helper is None: # 动态载入媒体服务器帮助类 module_name = "app.helper.mediaserver" spec = importlib.util.find_spec(module_name) @@ -1315,8 +1315,8 @@ class EpisodeGroupMeta(_PluginBase): if hasattr(module, 'MediaServerHelper'): self.log_info(f"v2版本初始化媒体库类") self.mediaserver_helper = module.MediaServerHelper() - if self.mediaserver_helper == None: - if self.emby == None: + if self.mediaserver_helper is None: + if self.emby is None: self.log_info(f"v1版本初始化媒体库类") self.emby = Emby() self.plex = Plex() From e001b9498135affe466388018f53fabf5e745737 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Thu, 5 Dec 2024 10:26:10 +0800 Subject: [PATCH 212/218] feat(BrushFlow): support auto skip for NexusPHP --- plugins.v2/brushflow/__init__.py | 51 ++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/plugins.v2/brushflow/__init__.py b/plugins.v2/brushflow/__init__.py index 1c602dd..83edc9f 100644 --- a/plugins.v2/brushflow/__init__.py +++ b/plugins.v2/brushflow/__init__.py @@ -6,7 +6,7 @@ import threading import time from datetime import datetime, timedelta from typing import Any, List, Dict, Tuple, Optional, Union, Set -from urllib.parse import urlparse, parse_qs, unquote +from urllib.parse import urlparse, parse_qs, unquote, parse_qsl, urlencode, urlunparse import pytz from app.helper.sites import SitesHelper @@ -1943,7 +1943,7 @@ class BrushFlow(_PluginBase): """ siteinfo = self.site_oper.get(siteid) if not siteinfo: - logger.warn(f"站点不存在:{siteid}") + logger.warning(f"站点不存在:{siteid}") return True logger.info(f"开始获取站点 {siteinfo.name} 的新种子 ...") @@ -1995,7 +1995,7 @@ class BrushFlow(_PluginBase): # 添加下载任务 hash_string = self.__download(torrent=torrent) if not hash_string: - logger.warn(f"{torrent.title} 添加刷流任务失败!") + logger.warning(f"{torrent.title} 添加刷流任务失败!") continue # 触发刷流下载时间并保存任务信息 @@ -2222,7 +2222,7 @@ class BrushFlow(_PluginBase): """ if not passed: if not torrent: - logger.warn(f"没有通过前置刷流条件校验,原因:{reason}") + logger.warning(f"没有通过前置刷流条件校验,原因:{reason}") else: logger.debug(f"种子没有通过刷流条件校验,原因:{reason} 种子:{torrent.title}|{torrent.description}") @@ -2247,7 +2247,7 @@ class BrushFlow(_PluginBase): downloader = self.downloader seeding_torrents, error = downloader.get_torrents() if error: - logger.warn("连接下载器出错,将在下个时间周期重试") + logger.warning("连接下载器出错,将在下个时间周期重试") return seeding_torrents_dict = {self.__get_hash(torrent): torrent for torrent in seeding_torrents} @@ -3006,6 +3006,30 @@ class BrushFlow(_PluginBase): return data return None + def __reset_download_url(self, torrent_url, site_id): + """ + 处理下载地址 + """ + try: + # 检查 torrent_url 是否为有效的下载 URL,并且 site 是 NexusPHP + if not torrent_url or torrent_url.startswith("magnet") or not self.__is_nexusphp(site_id): + return torrent_url + + # 解析 URL + parsed_url = urlparse(torrent_url) + + # 如果 URL 中已有查询参数,使用 urlencode 进行拼接 + query_params = dict(parse_qsl(parsed_url.query)) + query_params["letdown"] = "1" + + # 重新构造带有新参数的 URL + new_query = urlencode(query_params) + new_url = urlunparse(parsed_url._replace(query=new_query)) + return new_url + except Exception as e: + logger.error(f"Error while resetting downloader URL for torrent: {torrent_url}. Error: {str(e)}") + return torrent_url + def __download(self, torrent: TorrentInfo) -> Optional[str]: """ 添加下载任务 @@ -3039,6 +3063,7 @@ class BrushFlow(_PluginBase): logger.error(f"获取下载链接失败:{torrent.title}") return None + torrent_content = self.__reset_download_url(torrent_url=torrent_content, site_id=torrent.site) downloader = self.downloader if not downloader: return None @@ -3467,7 +3492,7 @@ class BrushFlow(_PluginBase): torrents = downloader.get_downloading_torrents(tags=brush_config.brush_tag) if torrents is None: - logger.warn("获取下载数量失败,可能是下载器连接发生异常") + logger.warning("获取下载数量失败,可能是下载器连接发生异常") return 0 return len(torrents) @@ -3813,3 +3838,17 @@ class BrushFlow(_PluginBase): # 当找不到对应的站点信息时,返回一个默认值 return 0, domain + + def __is_nexusphp(self, site_id): + """ + 是否NexusPHP站点 + """ + indexers = self.sites_helper.get_indexers() + if not indexers: + return False + + site = next((item for item in indexers if item.get("id") == site_id), None) + if not site: + return False + + return site.get("schema", "").startswith("Nexus") From 961c425edddf4cdbd31d9e8aeed1fc363ae7e8b7 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Thu, 5 Dec 2024 10:27:33 +0800 Subject: [PATCH 213/218] feat(BrushFlow): v4.0 --- package.v2.json | 6 +++--- plugins.v2/brushflow/__init__.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.v2.json b/package.v2.json index 0fd8c0a..6392ad9 100644 --- a/package.v2.json +++ b/package.v2.json @@ -19,13 +19,13 @@ "name": "站点刷流", "description": "自动托管刷流,将会提高对应站点的访问频率。", "labels": "刷流,仪表板", - "version": "3.9.2", + "version": "4.0", "icon": "brush.jpg", "author": "jxxghp,InfinityPacer", "level": 2, "history": { - "v3.9": "MoviePilot V2 版本站点刷流插件", - "v3.9.1": "修复兼容性问题" + "v4.0": "NexusPHP 站点支持自动跳过下载提示页", + "v3.9": "MoviePilot V2 版本站点刷流插件" } }, "AutoSignIn": { diff --git a/plugins.v2/brushflow/__init__.py b/plugins.v2/brushflow/__init__.py index 83edc9f..1e45f6c 100644 --- a/plugins.v2/brushflow/__init__.py +++ b/plugins.v2/brushflow/__init__.py @@ -246,7 +246,7 @@ class BrushFlow(_PluginBase): # 插件图标 plugin_icon = "brush.jpg" # 插件版本 - plugin_version = "3.9.2" + plugin_version = "4.0" # 插件作者 plugin_author = "jxxghp,InfinityPacer" # 作者主页 From e0a92fd22e42e6b97c243c819f9fd71de9bc7a90 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sat, 7 Dec 2024 00:28:52 +0800 Subject: [PATCH 214/218] fix(BrushFlow): skip site download tips based on site --- plugins.v2/brushflow/__init__.py | 41 ++++++++++++++++---------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/plugins.v2/brushflow/__init__.py b/plugins.v2/brushflow/__init__.py index 1e45f6c..cc22d9d 100644 --- a/plugins.v2/brushflow/__init__.py +++ b/plugins.v2/brushflow/__init__.py @@ -74,6 +74,7 @@ class BrushConfig: self.active_time_range = config.get("active_time_range") self.qb_category = config.get("qb_category") self.site_hr_active = config.get("site_hr_active", False) + self.site_skip_tips = config.get("site_skip_tips", False) self.brush_tag = "刷流" # 站点独立配置 @@ -115,7 +116,8 @@ class BrushConfig: "save_path", "proxy_delete", "qb_category", - "site_hr_active" + "site_hr_active", + "site_skip_tips" # 当新增支持字段时,仅在此处添加字段名 } try: @@ -180,7 +182,8 @@ class BrushConfig: "save_path": "/downloads/site1", "proxy_delete": false, "qb_category": "刷流", - "site_hr_active": true + "site_hr_active": true, + "site_skip_tips": true }]""" return desc + config @@ -3006,13 +3009,22 @@ class BrushFlow(_PluginBase): return data return None - def __reset_download_url(self, torrent_url, site_id): + def __reset_download_url(self, torrent_url, site_id) -> str: """ 处理下载地址 """ try: # 检查 torrent_url 是否为有效的下载 URL,并且 site 是 NexusPHP - if not torrent_url or torrent_url.startswith("magnet") or not self.__is_nexusphp(site_id): + if not torrent_url or torrent_url.startswith("magnet"): + return torrent_url + + indexers = self.sites_helper.get_indexers() + if not indexers: + return torrent_url + + unsupported_sites = {"天空"} + site = next((item for item in indexers if item.get("id") == site_id), None) + if site.get("name") in unsupported_sites or not site.get("schema", "").startswith("Nexus"): return torrent_url # 解析 URL @@ -3024,7 +3036,7 @@ class BrushFlow(_PluginBase): # 重新构造带有新参数的 URL new_query = urlencode(query_params) - new_url = urlunparse(parsed_url._replace(query=new_query)) + new_url = str(urlunparse(parsed_url._replace(query=new_query))) return new_url except Exception as e: logger.error(f"Error while resetting downloader URL for torrent: {torrent_url}. Error: {str(e)}") @@ -3063,7 +3075,10 @@ class BrushFlow(_PluginBase): logger.error(f"获取下载链接失败:{torrent.title}") return None - torrent_content = self.__reset_download_url(torrent_url=torrent_content, site_id=torrent.site) + if brush_config.site_skip_tips: + torrent_content = self.__reset_download_url(torrent_url=torrent_content, site_id=torrent.site) + logger.debug(f"站点 {torrent.site_name} 已启用自动跳过提示,种子下载地址更新为 {torrent_content}") + downloader = self.downloader if not downloader: return None @@ -3838,17 +3853,3 @@ class BrushFlow(_PluginBase): # 当找不到对应的站点信息时,返回一个默认值 return 0, domain - - def __is_nexusphp(self, site_id): - """ - 是否NexusPHP站点 - """ - indexers = self.sites_helper.get_indexers() - if not indexers: - return False - - site = next((item for item in indexers if item.get("id") == site_id), None) - if not site: - return False - - return site.get("schema", "").startswith("Nexus") From 69a459e48003bae19d58a48ba8299442f2a5d541 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sat, 7 Dec 2024 00:31:52 +0800 Subject: [PATCH 215/218] fix(BrushFlow): v4.0.1 --- package.v2.json | 3 ++- plugins.v2/brushflow/__init__.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/package.v2.json b/package.v2.json index 6392ad9..45d2514 100644 --- a/package.v2.json +++ b/package.v2.json @@ -19,11 +19,12 @@ "name": "站点刷流", "description": "自动托管刷流,将会提高对应站点的访问频率。", "labels": "刷流,仪表板", - "version": "4.0", + "version": "4.0.1", "icon": "brush.jpg", "author": "jxxghp,InfinityPacer", "level": 2, "history": { + "v4.0.1": "NexusPHP 站点支持自动跳过下载提示页调整为站点独立配置项", "v4.0": "NexusPHP 站点支持自动跳过下载提示页", "v3.9": "MoviePilot V2 版本站点刷流插件" } diff --git a/plugins.v2/brushflow/__init__.py b/plugins.v2/brushflow/__init__.py index cc22d9d..08876ad 100644 --- a/plugins.v2/brushflow/__init__.py +++ b/plugins.v2/brushflow/__init__.py @@ -249,7 +249,7 @@ class BrushFlow(_PluginBase): # 插件图标 plugin_icon = "brush.jpg" # 插件版本 - plugin_version = "4.0" + plugin_version = "4.0.1" # 插件作者 plugin_author = "jxxghp,InfinityPacer" # 作者主页 From 9fad5c094ae1646b9e087c0cacc92472ae9b107a Mon Sep 17 00:00:00 2001 From: ramen <1205925392@qq.com> Date: Wed, 11 Dec 2024 21:43:58 +0800 Subject: [PATCH 216/218] =?UTF-8?q?=E5=BF=BD=E7=95=A5=E5=9B=A0=E7=BD=91?= =?UTF-8?q?=E7=BB=9C=E6=B3=A2=E5=8A=A8=E5=AF=BC=E8=87=B4=E8=8E=B7=E5=8F=96?= =?UTF-8?q?ip=E9=94=99=E8=AF=AF=E3=80=82=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E7=9A=84=E7=B1=BB=E5=90=88=E5=B9=B6=E4=B8=BAhelper.py=E3=80=82?= =?UTF-8?q?=E5=90=8E=E7=BB=AD=E6=A0=B8=E5=BF=83=E5=8A=9F=E8=83=BD=E6=B2=A1?= =?UTF-8?q?=E9=97=AE=E9=A2=98=E5=B0=86=E4=B8=8D=E5=86=8D=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 8 +- plugins/dynamicwechat/__init__.py | 322 +++++++++--------- .../{notify_helper.py => helper.py} | 131 ++++++- plugins/dynamicwechat/update_help.py | 121 ------- 4 files changed, 292 insertions(+), 290 deletions(-) rename plugins/dynamicwechat/{notify_helper.py => helper.py} (61%) delete mode 100644 plugins/dynamicwechat/update_help.py diff --git a/package.json b/package.json index 3e0b13d..f27986c 100644 --- a/package.json +++ b/package.json @@ -861,20 +861,18 @@ "name": "动态企微可信IP", "description": "修改企微应用可信IP,支持Srever酱等第三方通知。验证码以?结尾发送到企业微信应用", "labels": "消息通知", - "version": "1.5.2", + "version": "1.6.0", "icon": "Wecom_A.png", "author": "RamenRa", "level": 2, "v2": true, "history": { + "v1.6.0": "忽略因网络波动导致获取ip错误。自定义的类合并为helper.py。后续核心功能没问题将不再更新", "v1.5.2": "可以从指定url获取ip,修复不使用cc时cookie失效过快,v1可配置第三方为备用通知,server酱可以将文本发送到server3,二维码给服务号", "v1.5.1": "修复v2微信通知,可以指定微信通知ID", "v1.5.0": "支持企微应用通知和第Serve酱等第三方推送。按要求修改插件名称", "v1.4.1": "完善面板说明", - "v1.4.0": "修复强制更改IP时配置面板延时过长的问题。庆祝v2进入正式版,显示了一个没用的参数", - "v1.3.1": "修正一些逻辑判断,修改ip成功会通知一次", - "v1.3.0": "兼容v2,操作cookie前检查一次CookieCloud", - "v1.2.0": "远程命令/push_qr,立即推送一次二维码到pushplus。添加<本地扫码刷新cookie>" + "v1.4.0": "修复强制更改IP时配置面板延时过长的问题。庆祝v2进入正式版,显示了一个没用的参数" } }, "SyncCookieCloud": { diff --git a/plugins/dynamicwechat/__init__.py b/plugins/dynamicwechat/__init__.py index 2b977f4..c6d9dd5 100644 --- a/plugins/dynamicwechat/__init__.py +++ b/plugins/dynamicwechat/__init__.py @@ -19,20 +19,19 @@ from app.helper.cookiecloud import CookieCloudHelper from app.log import logger from app.plugins import _PluginBase from app.schemas.types import EventType -from app.plugins.dynamicwechat.update_help import PyCookieCloud -from app.plugins.dynamicwechat.notify_helper import MySender +from app.plugins.dynamicwechat.helper import PyCookieCloud, MySender class DynamicWeChat(_PluginBase): # 插件名称 plugin_name = "动态企微可信IP" # 插件描述 - plugin_desc = "修改企微应用可信IP,详细说明查看'作者主页',支持第三方通知。验证码以?结尾发送到企业微信应用" + plugin_desc = "修改企微应用可信IP,详细说明查看'作者主页',支持第三方通知。验证码以?结尾发送到企业微信应用" # 插件图标 plugin_icon = "Wecom_A.png" # 插件版本 - plugin_version = "1.5.2" + plugin_version = "1.6.0" # 插件作者 plugin_author = "RamenRa" # 作者主页 @@ -111,6 +110,7 @@ class DynamicWeChat(_PluginBase): def init_plugin(self, config: dict = None): # 清空配置 self._notification_token = '' + self._cron = '*/10 * * * *' self._ip_changed = True self._forced_update = False self._use_cookiecloud = True @@ -134,12 +134,9 @@ class DynamicWeChat(_PluginBase): self._my_send = MySender(self._notification_token, func=self.post_message) else: self._my_send = MySender(self._notification_token) - if not self._my_send.init_success: # 没有输入通知方式,不通知 + if not self._my_send.init_success: # 没有输入通知方式,不通知 self._my_send = None - if "||" in self._input_id_list: - parts = self._input_id_list.split("||", 1) - self._ip_urls = parts[1].split(",") - self._current_ip_address = self.get_ip_from_url(random.choice(self._ip_urls)) + _, self._current_ip_address = self.get_ip_from_url(self._input_id_list) # 停止现有任务 self.stop_service() if (self._enabled or self._onlyonce) and self._input_id_list: @@ -147,20 +144,24 @@ class DynamicWeChat(_PluginBase): self._scheduler = BackgroundScheduler(timezone=settings.TZ) # 运行一次定时服务 if self._onlyonce: - logger.info("立即检测公网IP") - self._scheduler.add_job(func=self.check, trigger='date', - run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), - name="检测公网IP") # 添加任务 + if not self._forced_update or not self._local_scan: + # logger.info("立即检测公网IP") + self._scheduler.add_job(func=self.check, trigger='date', + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="检测公网IP") # 添加任务 # 关闭一次性开关 self._onlyonce = False if self._forced_update: - self._scheduler.add_job(func=self.forced_change, trigger='date', - run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), - name="强制更新公网IP") # 添加任务 + if not self._local_scan: + logger.info("使用Cookie,强制更新公网IP") + self._scheduler.add_job(func=self.forced_change, trigger='date', + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="强制更新公网IP") # 添加任务 self._forced_update = False if self._local_scan: + logger.info("使用本地扫码登陆") self._scheduler.add_job(func=self.local_scanning, trigger='date', run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), name="本地扫码登陆") # 添加任务 @@ -181,6 +182,17 @@ class DynamicWeChat(_PluginBase): self._scheduler.start() self.__update_config() + def _send_cookie_false(self): + self._cookie_valid = False + if self._my_send: + result = self._my_send.send( + title="cookie已失效,请及时更新", + content="请在企业微信应用发送/push_qr, 如有验证码以'?'结束发送到企业微信应用。 如果使用’微信通知‘请确保公网IP还没有变动", + image=None, force_send=False + ) + if result: + logger.info(f"cookie失效通知发送失败,原因:{result}") + @eventmanager.register(EventType.PluginAction) def forced_change(self, event: Event = None): """ @@ -208,6 +220,7 @@ class DynamicWeChat(_PluginBase): self.click_app_management_buttons(page) else: logger.error("cookie失效,强制修改IP失败:请使用'本地扫码修改IP'") + self._cookie_valid = False browser.close() except Exception as err: logger.error(f"强制修改IP失败:{err}") @@ -226,7 +239,6 @@ class DynamicWeChat(_PluginBase): event_data = event.event_data if not event_data or event_data.get("action") != "dynamicwechat": return - try: with sync_playwright() as p: browser = p.chromium.launch(headless=True, args=['--lang=zh-CN']) @@ -238,7 +250,7 @@ class DynamicWeChat(_PluginBase): current_time = datetime.now() future_time = current_time + timedelta(seconds=110) self._future_timestamp = int(future_time.timestamp()) - logger.info("请重新进入插件面板扫码! 每20秒检查登录状态,最大尝试5次") + logger.info("请重新进入插件面板扫码! 每20秒检查登录状态,最大尝试5次") max_attempts = 5 attempt = 0 while attempt < max_attempts: @@ -252,7 +264,7 @@ class DynamicWeChat(_PluginBase): else: logger.info("用户可能没有扫码或登录失败") else: - logger.error("未找到二维码,任务结束") + logger.error("未找到二维码,任务结束") logger.info("----------------------本次任务结束----------------------") browser.close() except Exception as e: @@ -279,30 +291,20 @@ class DynamicWeChat(_PluginBase): self.__update_config() logger.info("----------------------本次任务结束----------------------") else: - logger.warning("cookie已失效请及时更新,本次不检查公网IP") + logger.warning("cookie已失效请及时更新,本次不检查公网IP") def CheckIP(self): - if "||" in self._input_id_list: - parts = self._input_id_list.split("||", 1) - ip_urls = parts[1].split(",") - else: - ip_urls = self._ip_urls - retry_urls = random.sample(ip_urls, len(ip_urls)) - ip_address = None - - for url in retry_urls: - ip_address = self.get_ip_from_url(url) - if ip_address != "获取IP失败" and ip_address: - logger.info(f"IP获取成功: {url}: {ip_address}") - break + url, ip_address = self.get_ip_from_url(self._input_id_list) + if url and ip_address: + logger.info(f"IP获取成功: {url}: {ip_address}") # 如果所有 URL 请求失败 - if ip_address == "获取IP失败" or not ip_address: - logger.error("获取IP失败 不操作IP") + if ip_address == "获取IP失败" or not url: + logger.error("获取IP失败 不操作可信IP") return False elif not self._ip_changed: # 上次修改IP失败 - logger.info("上次IP修改IP没有成功 继续尝试修改IP") + logger.info("上次IP修改IP失败 继续尝试修改IP") self._current_ip_address = ip_address return True @@ -310,48 +312,54 @@ class DynamicWeChat(_PluginBase): if ip_address != self._current_ip_address: logger.info("检测到IP变化") self._current_ip_address = ip_address - # self._ip_changed = False return True else: return False def try_connect_cc(self): - if self._use_cookiecloud: - if settings.COOKIECLOUD_KEY and settings.COOKIECLOUD_PASSWORD: # 使用设置里的cookieCloud - if settings.COOKIECLOUD_ENABLE_LOCAL: - self._cc_server = PyCookieCloud(url=self._server, uuid=settings.COOKIECLOUD_KEY, - password=settings.COOKIECLOUD_PASSWORD) - logger.info("使用内建CookieCloud服务器") - else: # 使用设置里的cookieCloud - self._cc_server = PyCookieCloud(url=settings.COOKIECLOUD_HOST, uuid=settings.COOKIECLOUD_KEY, - password=settings.COOKIECLOUD_PASSWORD) - logger.info("使用自定义CookieCloud服务器") - if not self._cc_server.check_connection(): - self._cc_server = None - logger.error("没有可用的CookieCloud服务器") - else: # 未设置cookieCloud - self._cc_server = None - logger.error("没有配置CookieCloud的用户KEY和PASSWORD") + if not self._use_cookiecloud: # 不使用CookieCloud + self._cc_server = None + return + if not settings.COOKIECLOUD_KEY or not settings.COOKIECLOUD_PASSWORD: # 没有设置key和password + self._cc_server = None + logger.error("没有配置CookieCloud的用户KEY和PASSWORD") + return + if settings.COOKIECLOUD_ENABLE_LOCAL: + self._cc_server = PyCookieCloud(url=self._server, uuid=settings.COOKIECLOUD_KEY, + password=settings.COOKIECLOUD_PASSWORD) + logger.info("使用内建CookieCloud服务器") + else: # 使用设置里的cookieCloud + self._cc_server = PyCookieCloud(url=settings.COOKIECLOUD_HOST, uuid=settings.COOKIECLOUD_KEY, + password=settings.COOKIECLOUD_PASSWORD) + logger.info("使用自定义CookieCloud服务器") + if not self._cc_server.check_connection(): + self._cc_server = None + logger.error("没有可用的CookieCloud服务器") - def get_ip_from_url(self, url): - try: - # 发送 GET 请求 - response = requests.get(url) - # 检查响应状态码是否为 200 - if response.status_code == 200: - # 解析响应 JSON 数据并获取 IP 地址 - ip_address = re.search(self._ip_pattern, response.text) - if ip_address: - return ip_address.group() - else: - return "获取IP失败" - else: - return "获取IP失败" - except Exception as e: - if "104" in str(e): - pass - else: - logger.warning(f"{url} 获取IP失败,Error: {e}") + def get_ip_from_url(self, input_data) -> (str, str): + # 根据输入解析 URL 列表 + if isinstance(input_data, str) and "||" in input_data: + _, url_list = input_data.split("||", 1) + urls = url_list.split(",") + elif isinstance(input_data, list): + urls = input_data + else: + urls = self._ip_urls + + # 随机化 URL 列表 + random.shuffle(urls) + + for url in urls: + try: + response = requests.get(url, timeout=3) + if response.status_code == 200: + ip_address = re.search(self._ip_pattern, response.text) + if ip_address: + return url, ip_address.group() # 返回匹配的 IP 地址 + except Exception as e: + if "104" not in str(e) or 'Read timed out' not in str(e): # 忽略网络波动,都失败会返回None, "获取IP失败" + logger.warning(f"{url} 获取IP失败, Error: {e}") + return None, "获取IP失败" def find_qrc(self, page): # 查找 iframe 元素并切换到它 @@ -395,30 +403,21 @@ class DynamicWeChat(_PluginBase): time.sleep(3) img_src, refuse_time = self.find_qrc(page) if img_src: - if self._my_send: - result = self._my_send.send(title="企业微信登录二维码", image=img_src) - if result: - logger.info(f"二维码发送失败,原因:{result}") - browser.close() - return - logger.info("二维码已经发送,等待用户 90 秒内扫码登录") - # logger.info("如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301?") - time.sleep(90) # 等待用户扫码 - login_status = self.check_login_status(page, "") - if login_status: - self._update_cookie(page, context) # 刷新cookie - self.click_app_management_buttons(page) - else: - self._ip_changed = False + if self._my_send: # 统一逻辑,只有用户发送'/push_qr'才会发生二维码 + self._ip_changed = False + self._send_cookie_false() + logger.info("已尝试发送cookie失效通知") else: self._ip_changed = False - logger.info("cookie已失效") + self._cookie_valid = False + logger.info("cookie已失效,且没有配置通知方式,本次修改可信IP失败") else: # 如果直接进入企业微信 logger.info("尝试cookie登录") - login_status = self.check_login_status(page, "") - if login_status: + if self.check_login_status(page, ""): self.click_app_management_buttons(page) else: + logger.info("发生了意料之外的错误,请附上配置信息到github反馈") + self._send_cookie_false() self._ip_changed = False browser.close() except Exception as e: @@ -435,7 +434,7 @@ class DynamicWeChat(_PluginBase): self.try_connect_cc() # 再尝试一次连接 if self._cc_server is None: return - logger.info("使用二维码登录成功,开始刷新cookie") + logger.info("使用二维码登录成功,开始刷新cookie") try: if not self._cc_server.check_connection(): logger.error("连接 CookieCloud 失败", self._server) @@ -443,7 +442,8 @@ class DynamicWeChat(_PluginBase): current_url = page.url current_cookies = context.cookies(current_url) # 通过 context 获取 cookies if current_cookies is None: - logger.error("无法获取当前 cookies") + logger.error("无法从内置浏览器获取 cookies") + self._cookie_valid = False return self._saved_cookie = current_cookies formatted_cookies = {} @@ -455,20 +455,25 @@ class DynamicWeChat(_PluginBase): if domain not in formatted_cookies: formatted_cookies[domain] = [] formatted_cookies[domain].append(cookie) - flag = self._cc_server.update_cookie(formatted_cookies) - if flag: + if self._cc_server.update_cookie(formatted_cookies): logger.info("更新 CookieCloud 成功") self._cookie_valid = True + self._is_special_upload = True else: + self._send_cookie_false() + self._is_special_upload = False logger.error("更新 CookieCloud 失败") except Exception as e: + self._send_cookie_false() + self._is_special_upload = False logger.error(f"CookieCloud更新 cookie 发生错误: {e}") else: try: current_url = page.url current_cookies = context.cookies(current_url) # 通过 context 获取 cookies if current_cookies is None: + self._send_cookie_false() logger.error("更新本地 Cookie失败") return else: @@ -476,6 +481,7 @@ class DynamicWeChat(_PluginBase): self._saved_cookie = current_cookies # 保存 self._cookie_valid = True except Exception as e: + self._send_cookie_false() logger.error(f"更新本地 cookie 发生错误: {e}") def get_cookie(self): @@ -483,28 +489,26 @@ class DynamicWeChat(_PluginBase): return self._saved_cookie try: cookie_header = '' - if self._use_cookiecloud: - cookies, msg = self._cookiecloud.download() - if not cookies: # CookieCloud获取cookie失败 - logger.error(f"CookieCloud获取cookie失败,失败原因:{msg}") - return - else: - for domain, cookie in cookies.items(): - if domain == ".work.weixin.qq.com": - cookie_header = cookie - if '_upload_type=A' in cookie: - self._is_special_upload = True - else: - self._is_special_upload = False - break - if cookie_header == '': - cookie_header = self._cookie_header - else: # 不使用CookieCloud + if not self._use_cookiecloud: return + cookies, msg = self._cookiecloud.download() + if not cookies: # CookieCloud获取cookie失败 + logger.error(f"CookieCloud获取cookie失败,失败原因:{msg}") + return + for domain, cookie in cookies.items(): + if domain == ".work.weixin.qq.com": + cookie_header = cookie + if '_upload_type=A' in cookie: + self._is_special_upload = True + else: + self._is_special_upload = False + break + if cookie_header == '': + cookie_header = self._cookie_header cookie = self.parse_cookie_header(cookie_header) return cookie except Exception as e: - logger.error(f"从CookieCloud获取cookie错误,错误原因:{e}") + logger.error(f"从CookieCloud获取cookie错误,错误原因:{e}") return @staticmethod @@ -544,27 +548,22 @@ class DynamicWeChat(_PluginBase): if not cookie_used and self._use_cookiecloud: # logger.info("尝试从CookieCloud 获取新的 cookie") cookie = self.get_cookie() - if cookie: - context.add_cookies(cookie) - page = context.new_page() - page.goto(self._wechatUrl) - time.sleep(3) - if self.check_login_status(page, task='refresh_cookie'): - # logger.info("新获取的 cookie 有效") - self._cookie_valid = True - self._saved_cookie = context.cookies() # 保存有效的 cookie - else: - # logger.warning("新获取的 cookie 无效") - self._cookie_valid = False - self._saved_cookie = None # 清空无效的 cookie - if self._my_send: - result = self._my_send.send( - title="cookie已失效,请及时更新", - content="请在企业微信应用发送/push_qr, 如有验证码以'?'结束发送到企业微信应用。 如果是使用’微信通知‘请确保公网IP还没有变动", - image=None, force_send=False - ) - if result: - logger.info(f"cookie失效通知发送失败,原因:{result}") + if not cookie: + self._send_cookie_false() + return + context.add_cookies(cookie) + page = context.new_page() + page.goto(self._wechatUrl) + time.sleep(3) + if self.check_login_status(page, task='refresh_cookie'): + # logger.info("新获取的 cookie 有效") + self._cookie_valid = True + self._saved_cookie = context.cookies() # 保存有效的 cookie + else: + # logger.warning("新获取的 cookie 无效") + self._send_cookie_false() + self._saved_cookie = None # 清空无效的 cookie + if self._cookie_valid: if self._my_send: self._my_send.reset_limit() @@ -572,7 +571,7 @@ class DynamicWeChat(_PluginBase): self._cookie_lifetime = PyCookieCloud.load_cookie_lifetime(self._settings_file_path) browser.close() except Exception as e: - self._cookie_valid = False + self._send_cookie_false() self._saved_cookie = None # 异常时清空 cookie logger.error(f"cookie 校验过程中发生异常: {e}") @@ -601,7 +600,7 @@ class DynamicWeChat(_PluginBase): if task == 'local_scanning': time.sleep(6) else: - logger.info("等待30秒,请将短信验证码请以'?'结束,发送到<企业微信应用> 如: 110301?") + logger.info("等待30秒,请将短信验证码请以'?'结束,发送到<企业微信应用> 如: 110301?") time.sleep(30) # 多等30秒 if self._verification_code: # logger.info("输入验证码:" + self._verification_code) @@ -620,14 +619,15 @@ class DynamicWeChat(_PluginBase): logger.error("未收到短信验证码") return False except Exception as e: - # logger.debug(str(e)) # 基于bug运行,请不要将错误输出到日志 - # try: # 没有登录成功,也没有短信验证码 + # logger.debug(str(e)) # 基于bug运行,请不要将错误输出到日志 + # try: # 没有登录成功,也没有短信验证码 if self.find_qrc( - page) and not task == 'refresh_cookie' and not task == 'local_scanning': # 延长任务找到的二维码不会被发送,所以不算用户没有扫码 + page) and not task == 'refresh_cookie' and not task == 'local_scanning': # 延长任务找到的二维码不会被发送,所以不算用户没有扫码 logger.warning(f"用户没有扫描二维码") return False def click_app_management_buttons(self, page): + self._cookie_valid = True bash_url = "https://work.weixin.qq.com/wework_admin/frame#apps/modApiApp/" # 按钮的选择器和名称 buttons = [ @@ -637,6 +637,7 @@ class DynamicWeChat(_PluginBase): "//div[contains(@class, 'js_show_ipConfig_dialog')]//a[contains(@class, '_mod_card_operationLink') and text()='配置']", "配置") ] + _, self._current_ip_address = self.get_ip_from_url(self._input_id_list) if "||" in self._input_id_list: parts = self._input_id_list.split("||", 1) input_id_list = parts[0] @@ -645,8 +646,12 @@ class DynamicWeChat(_PluginBase): id_list = input_id_list.split(",") app_urls = [f"{bash_url}{app_id.strip()}" for app_id in id_list] for app_url in app_urls: - page.goto(app_url) # 打开应用详情页 app_id = app_url.split("/")[-1] + if app_id.startswith("100000") and len(app_id) == 6: + self._ip_changed = False + logger.warning(f"请根据 https://github.com/RamenRa/MoviePilot-Plugins 的说明进行配置应用ID") + return + page.goto(app_url) # 打开应用详情页 time.sleep(2) # 依次点击每个按钮 for xpath, name in buttons: @@ -673,9 +678,9 @@ class DynamicWeChat(_PluginBase): ip_parts = self._current_ip_address.split('.') masked_ip = f"{ip_parts[0]}.{len(ip_parts[1]) * '*'}.{len(ip_parts[2]) * '*'}.{ip_parts[3]}" if self._my_send: - result = self._my_send.send(title="更新可信IP成功", - content='应用: ' + app_id + ' 输入IP:' + masked_ip, - force_send=True, diy_channel="WeChat") + self._my_send.send(title="更新可信IP成功", + content='应用: ' + app_id + ' 输入IP:' + masked_ip, + force_send=True, diy_channel="WeChat") def __update_config(self): """ @@ -700,7 +705,7 @@ class DynamicWeChat(_PluginBase): def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: """ - 拼装插件配置页面,只保留必要的配置项,并添加 token 配置。 + 拼装插件配置页面,只保留必要的配置项,并添加 token 配置。 """ return [ { @@ -851,7 +856,7 @@ class DynamicWeChat(_PluginBase): 'model': 'input_id_list', 'label': '[必填]应用ID', 'rows': 1, - 'placeholder': '输入应用ID,多个ID用英文逗号分隔。在企业微信应用页面URL末尾获取' + 'placeholder': '输入应用ID,多个ID用英文逗号分隔。在企业微信应用页面URL末尾获取' } } ] @@ -872,7 +877,7 @@ class DynamicWeChat(_PluginBase): 'props': { 'type': 'info', 'variant': 'tonal', - 'text': '建议启用内建或自定义CookieCloud。支持微信、Server酱等第三方通知,具体请查看作者主页' + 'text': '建议启用内建或自定义CookieCloud。支持微信、Server酱等第三方通知,具体请查看作者主页' } } ] @@ -892,7 +897,7 @@ class DynamicWeChat(_PluginBase): 'component': 'VAlert', 'props': { 'type': 'info', - 'text': 'Cookie失效时通知用户,用户使用/push_qr让插件推送二维码。使用第三方通知时填写对应Token/API' + 'text': 'Cookie失效时通知用户,用户使用/push_qr让插件推送二维码。使用第三方通知时填写对应Token/API' } } ] @@ -919,20 +924,20 @@ class DynamicWeChat(_PluginBase): # 判断二维码是否过期 if current_time > self._future_timestamp: - vaild_text = "二维码已过期" - color = "#ff0000" + vaild_text = "二维码已过期或没有扫码任务" + color = "#ff0000" if self._enabled else "#bbbbbb" self._qr_code_image = None else: - # 二维码有效,格式化过期时间为 年-月-日 时:分:秒 + # 二维码有效,格式化过期时间为 年-月-日 时:分:秒 expiration_time = datetime.fromtimestamp(self._future_timestamp).strftime('%Y-%m-%d %H:%M:%S') - vaild_text = f"二维码有效,过期时间: {expiration_time}" + vaild_text = f"二维码有效,过期时间: {expiration_time}" color = "#32CD32" - # 如果self._qr_code_image为None,返回提示信息 + # 如果self._qr_code_image为None,返回提示信息 if self._qr_code_image is None: img_component = { "component": "div", - "text": "登录二维码都会在此展示,二维码有6秒延时。 [适用于Docker版]", + "text": "登录二维码都会在此展示,二维码有6秒延时。 [适用于Docker版]", "props": { "style": { "fontSize": "22px", @@ -1063,17 +1068,16 @@ class DynamicWeChat(_PluginBase): if self._my_send: result = self._my_send.send("企业微信登录二维码", image=image_src) if result: - logger.info(f"远程推送任务: 二维码发送失败,原因:{result}") + logger.info(f"远程推送任务: 二维码发送失败,原因:{result}") browser.close() logger.info("----------------------本次任务结束----------------------") return - logger.info("远程推送任务: 二维码已经发送,等待用户 90 秒内扫码登录") - # logger.info("远程推送任务: 如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301?") + logger.info("远程推送任务: 二维码发送成功,等待用户 90 秒内扫码登录。V2'微信通知'的用户,此消息并不准确") + # logger.info("远程推送任务: 如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301?") time.sleep(90) - login_status = self.check_login_status(page, 'push_qr_code') - if login_status: + if self.check_login_status(page, 'push_qr_code'): self._update_cookie(page, context) # 刷新cookie - # logger.info("远程推送任务: 没有可用的CookieCloud服务器,只修改可信IP") + # logger.info("远程推送任务: 没有可用的CookieCloud服务器,只修改可信IP") self.click_app_management_buttons(page) else: logger.warning("远程推送任务: 没有找到可用的通知方式") @@ -1125,7 +1129,7 @@ class DynamicWeChat(_PluginBase): }] """ if self._enabled and self._cron: - logger.info(f"{self.plugin_name}定时服务启动,时间间隔 {self._cron} ") + # logger.info(f"{self.plugin_name}定时服务启动,时间间隔 {self._cron} ") return [{ "id": self.__class__.__name__, "name": f"{self.plugin_name}服务", diff --git a/plugins/dynamicwechat/notify_helper.py b/plugins/dynamicwechat/helper.py similarity index 61% rename from plugins/dynamicwechat/notify_helper.py rename to plugins/dynamicwechat/helper.py index da1985e..0df72d4 100644 --- a/plugins/dynamicwechat/notify_helper.py +++ b/plugins/dynamicwechat/helper.py @@ -3,6 +3,128 @@ import requests from app.modules.wechat import WeChat from app.schemas.types import NotificationType,MessageChannel +import os +import json +import requests +import base64 +import hashlib +from typing import Dict, Any +from Crypto import Random +from Crypto.Cipher import AES + + +def bytes_to_key(data: bytes, salt: bytes, output=48) -> bytes: + # 兼容v2 将bytes_to_key和encrypt导入 + assert len(salt) == 8, len(salt) + data += salt + key = hashlib.md5(data).digest() + final_key = key + while len(final_key) < output: + key = hashlib.md5(key + data).digest() + final_key += key + return final_key[:output] + + +def encrypt(message: bytes, passphrase: bytes) -> bytes: + """ + CryptoJS 加密原文 + + This is a modified copy of https://stackoverflow.com/questions/36762098/how-to-decrypt-password-from-javascript-cryptojs-aes-encryptpassword-passphras + """ + salt = Random.new().read(8) + key_iv = bytes_to_key(passphrase, salt, 32 + 16) + key = key_iv[:32] + iv = key_iv[32:] + aes = AES.new(key, AES.MODE_CBC, iv) + length = 16 - (len(message) % 16) + data = message + (chr(length) * length).encode() + return base64.b64encode(b"Salted__" + salt + aes.encrypt(data)) + + +class PyCookieCloud: + def __init__(self, url: str, uuid: str, password: str): + self.url: str = url + self.uuid: str = uuid + self.password: str = password + + def check_connection(self) -> bool: + """ + Test the connection to the CookieCloud server. + + :return: True if the connection is successful, False otherwise. + """ + try: + resp = requests.get(self.url, timeout=3) # 设置超时为3秒 + return resp.status_code == 200 + except Exception as e: + return False + + def update_cookie(self, formatted_cookies: Dict[str, Any]) -> bool: + """ + Update cookie data to CookieCloud. + + :param formatted_cookies: cookie value to update. + :return: if update success, return True, else return False. + """ + if '.work.weixin.qq.com' not in formatted_cookies: + formatted_cookies['.work.weixin.qq.com'] = [] + formatted_cookies['.work.weixin.qq.com'].append({ + 'name': '_upload_type', + 'value': 'A', + 'domain': '.work.weixin.qq.com', + 'path': '/', + 'expires': -1, + 'httpOnly': False, + 'secure': False, + 'sameSite': 'Lax' + }) + + cookie = {'cookie_data': formatted_cookies} + raw_data = json.dumps(cookie) + encrypted_data = encrypt(raw_data.encode('utf-8'), self.get_the_key().encode('utf-8')).decode('utf-8') + cookie_cloud_request = requests.post(self.url + '/update', + json={'uuid': self.uuid, 'encrypted': encrypted_data}) + if cookie_cloud_request.status_code == 200: + if cookie_cloud_request.json().get('action') == 'done': + return True + return False + + def get_the_key(self) -> str: + """ + Get the key used to encrypt and decrypt data. + + :return: the key. + """ + md5 = hashlib.md5() + md5.update((self.uuid + '-' + self.password).encode('utf-8')) + return md5.hexdigest()[:16] + + @staticmethod + def load_cookie_lifetime(settings_file: str = None): # 返回时间戳 单位秒 + if os.path.exists(settings_file): + with open(settings_file, 'r') as file: + settings = json.load(file) + return settings.get('_cookie_lifetime', 0) + else: + return 0 + + @staticmethod + def save_cookie_lifetime(settings_file, cookie_lifetime): # 传入时间戳 单位秒 + with open(settings_file, 'w') as file: + json.dump({'_cookie_lifetime': cookie_lifetime}, file) + + @staticmethod + def increase_cookie_lifetime(settings_file, seconds: int): + if os.path.exists(settings_file): + with open(settings_file, 'r') as file: + settings = json.load(file) + current_lifetime = settings.get('_cookie_lifetime', 0) + else: + current_lifetime = 0 + new_lifetime = current_lifetime + seconds + # 保存新的 _cookie_lifetime + PyCookieCloud.save_cookie_lifetime(settings_file, new_lifetime) + class MySender: def __init__(self, token=None, func=None): @@ -51,9 +173,7 @@ class MySender: if result is None: # 成功时返回 None return except Exception as e: - # 打印错误日志或处理错误 - return f"{channel} 通知错误: {e}" - # 切换到下一个通道 + pass # 忽略单个错误,继续尝试下一个通道 self.current_index = (self.current_index + 1) % len(self.tokens) return f"所有的通知方式都发送失败" @@ -90,13 +210,14 @@ class MySender: def _send_serverchan(self, title, content, image): tmp_tokens = self.tokens[self.current_index] - token = tmp_tokens if ',' in tmp_tokens: before_comma, after_comma = tmp_tokens.split(',', 1) if before_comma.startswith('sctp') and image: token = after_comma # 图片发到公众号 else: token = before_comma # 发到 server3 + else: + token = tmp_tokens if token.startswith('sctp'): match = re.match(r'sctp(\d+)t', token) @@ -156,7 +277,7 @@ class MySender: def _send_v2_wechat(self, title, content, image, token): """V2 微信通知发送""" if token and ',' in token: - channel, actual_userid = token.split(',', 1) + _, actual_userid = token.split(',', 1) else: actual_userid = None self.post_message_func( diff --git a/plugins/dynamicwechat/update_help.py b/plugins/dynamicwechat/update_help.py deleted file mode 100644 index e3cd6f7..0000000 --- a/plugins/dynamicwechat/update_help.py +++ /dev/null @@ -1,121 +0,0 @@ -import os -import json -import requests -import base64 -import hashlib -from typing import Dict, Any -from Crypto import Random -from Crypto.Cipher import AES - - -def bytes_to_key(data: bytes, salt: bytes, output=48) -> bytes: - # 兼容v2 将bytes_to_key和encrypt导入 - assert len(salt) == 8, len(salt) - data += salt - key = hashlib.md5(data).digest() - final_key = key - while len(final_key) < output: - key = hashlib.md5(key + data).digest() - final_key += key - return final_key[:output] - - -def encrypt(message: bytes, passphrase: bytes) -> bytes: - """ - CryptoJS 加密原文 - - This is a modified copy of https://stackoverflow.com/questions/36762098/how-to-decrypt-password-from-javascript-cryptojs-aes-encryptpassword-passphras - """ - salt = Random.new().read(8) - key_iv = bytes_to_key(passphrase, salt, 32 + 16) - key = key_iv[:32] - iv = key_iv[32:] - aes = AES.new(key, AES.MODE_CBC, iv) - length = 16 - (len(message) % 16) - data = message + (chr(length) * length).encode() - return base64.b64encode(b"Salted__" + salt + aes.encrypt(data)) - - -class PyCookieCloud: - def __init__(self, url: str, uuid: str, password: str): - self.url: str = url - self.uuid: str = uuid - self.password: str = password - - def check_connection(self) -> bool: - """ - Test the connection to the CookieCloud server. - - :return: True if the connection is successful, False otherwise. - """ - try: - resp = requests.get(self.url, timeout=3) # 设置超时为3秒 - return resp.status_code == 200 - except Exception as e: - return False - - def update_cookie(self, formatted_cookies: Dict[str, Any]) -> bool: - """ - Update cookie data to CookieCloud. - - :param formatted_cookies: cookie value to update. - :return: if update success, return True, else return False. - """ - if '.work.weixin.qq.com' not in formatted_cookies: - formatted_cookies['.work.weixin.qq.com'] = [] - formatted_cookies['.work.weixin.qq.com'].append({ - 'name': '_upload_type', - 'value': 'A', - 'domain': '.work.weixin.qq.com', - 'path': '/', - 'expires': -1, - 'httpOnly': False, - 'secure': False, - 'sameSite': 'Lax' - }) - - cookie = {'cookie_data': formatted_cookies} - raw_data = json.dumps(cookie) - encrypted_data = encrypt(raw_data.encode('utf-8'), self.get_the_key().encode('utf-8')).decode('utf-8') - cookie_cloud_request = requests.post(self.url + '/update', - json={'uuid': self.uuid, 'encrypted': encrypted_data}) - if cookie_cloud_request.status_code == 200: - if cookie_cloud_request.json().get('action') == 'done': - return True - return False - - def get_the_key(self) -> str: - """ - Get the key used to encrypt and decrypt data. - - :return: the key. - """ - md5 = hashlib.md5() - md5.update((self.uuid + '-' + self.password).encode('utf-8')) - return md5.hexdigest()[:16] - - @staticmethod - def load_cookie_lifetime(settings_file: str = None): # 返回时间戳 单位秒 - if os.path.exists(settings_file): - with open(settings_file, 'r') as file: - settings = json.load(file) - return settings.get('_cookie_lifetime', 0) - else: - return 0 - - @staticmethod - def save_cookie_lifetime(settings_file, cookie_lifetime): # 传入时间戳 单位秒 - with open(settings_file, 'w') as file: - json.dump({'_cookie_lifetime': cookie_lifetime}, file) - - @staticmethod - def increase_cookie_lifetime(settings_file, seconds: int): - if os.path.exists(settings_file): - with open(settings_file, 'r') as file: - settings = json.load(file) - current_lifetime = settings.get('_cookie_lifetime', 0) - else: - current_lifetime = 0 - new_lifetime = current_lifetime + seconds - # 保存新的 _cookie_lifetime - PyCookieCloud.save_cookie_lifetime(settings_file, new_lifetime) From 0368938004dabc02d1610985230e5e907d890d13 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sun, 15 Dec 2024 00:07:25 +0800 Subject: [PATCH 217/218] fix(WebHook): add compatibility with MoviePilot v2 --- plugins/webhook/__init__.py | 39 ++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/plugins/webhook/__init__.py b/plugins/webhook/__init__.py index 7a624e9..1d6f04d 100644 --- a/plugins/webhook/__init__.py +++ b/plugins/webhook/__init__.py @@ -1,9 +1,11 @@ -from app.plugins import _PluginBase +from typing import Any, List, Dict, Tuple + +from app.core.config import settings from app.core.event import eventmanager +from app.log import logger +from app.plugins import _PluginBase from app.schemas.types import EventType from app.utils.http import RequestUtils -from typing import Any, List, Dict, Tuple -from app.log import logger class WebHook(_PluginBase): @@ -134,6 +136,9 @@ class WebHook(_PluginBase): if not self._enabled or not self._webhook_url: return + if not event or not event.event_type: + return + def __to_dict(_event): """ 递归将对象转换为字典 @@ -159,21 +164,27 @@ class WebHook(_PluginBase): else: return str(_event) + version = getattr(settings, "VERSION_FLAG", "v1") + event_type = event.event_type if version == "v1" else event.event_type.value + event_info = { - "type": event.event_type, + "type": event_type, "data": __to_dict(event.event_data) } - if self._method == 'POST': - ret = RequestUtils(content_type="application/json").post_res(self._webhook_url, json=event_info) - else: - ret = RequestUtils().get_res(self._webhook_url, params=event_info) - if ret: - logger.info("发送成功:%s" % self._webhook_url) - elif ret is not None: - logger.error(f"发送失败,状态码:{ret.status_code},返回信息:{ret.text} {ret.reason}") - else: - logger.error("发送失败,未获取到返回信息") + try: + if self._method == 'POST': + ret = RequestUtils(content_type="application/json").post_res(self._webhook_url, json=event_info) + else: + ret = RequestUtils().get_res(self._webhook_url, params=event_info) + if ret: + logger.info(f"发送成功:{self._webhook_url}") + elif ret is not None: + logger.error(f"发送失败,状态码:{ret.status_code},返回信息:{ret.text} {ret.reason}") + else: + logger.error("发送失败,未获取到返回信息") + except Exception as e: + logger.error(f"发送请求时发生异常:{e}") def stop_service(self): """ From 401049064ef0409d68c0842e37cfa0acc9d75a85 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sun, 15 Dec 2024 00:10:41 +0800 Subject: [PATCH 218/218] fix(WebHook): v1.1 --- package.json | 8 ++++++-- plugins/webhook/__init__.py | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index f27986c..1d5ba1f 100644 --- a/package.json +++ b/package.json @@ -247,11 +247,15 @@ "WebHook": { "name": "Webhook", "description": "事件发生时向第三方地址发送请求。", - "version": "1.0", + "version": "1.1", "icon": "webhook.png", "author": "jxxghp", "level": 1, - "v2": true + "v2": true, + "history": { + "v1.1": "兼容MoviePilot V2 版本", + "v1.0": "新增Webhook插件,支持事件发生时向第三方地址发送请求" + } }, "ChatGPT": { "name": "ChatGPT", diff --git a/plugins/webhook/__init__.py b/plugins/webhook/__init__.py index 1d6f04d..6fb57ba 100644 --- a/plugins/webhook/__init__.py +++ b/plugins/webhook/__init__.py @@ -16,7 +16,7 @@ class WebHook(_PluginBase): # 插件图标 plugin_icon = "webhook.png" # 插件版本 - plugin_version = "1.0" + plugin_version = "1.1" # 插件作者 plugin_author = "jxxghp" # 作者主页